Merge branch 'dev' into sub_devices_esphome

This commit is contained in:
J. Nick Koston
2025-06-24 00:06:19 +02:00
committed by GitHub
161 changed files with 11496 additions and 1605 deletions

View File

@ -503,6 +503,7 @@ homeassistant.components.tautulli.*
homeassistant.components.tcp.*
homeassistant.components.technove.*
homeassistant.components.tedee.*
homeassistant.components.telegram_bot.*
homeassistant.components.text.*
homeassistant.components.thethingsnetwork.*
homeassistant.components.threshold.*

4
CODEOWNERS generated
View File

@ -788,8 +788,6 @@ build.json @home-assistant/supervisor
/tests/components/jellyfin/ @RunC0deRun @ctalkington
/homeassistant/components/jewish_calendar/ @tsvi
/tests/components/jewish_calendar/ @tsvi
/homeassistant/components/juicenet/ @jesserockz
/tests/components/juicenet/ @jesserockz
/homeassistant/components/justnimbus/ @kvanzuijlen
/tests/components/justnimbus/ @kvanzuijlen
/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi
@ -1171,6 +1169,8 @@ build.json @home-assistant/supervisor
/tests/components/ping/ @jpbede
/homeassistant/components/plaato/ @JohNan
/tests/components/plaato/ @JohNan
/homeassistant/components/playstation_network/ @jackjpowell
/tests/components/playstation_network/ @jackjpowell
/homeassistant/components/plex/ @jjlawren
/tests/components/plex/ @jjlawren
/homeassistant/components/plugwise/ @CoMPaTech @bouwew

View File

@ -1,5 +1,11 @@
{
"domain": "sony",
"name": "Sony",
"integrations": ["braviatv", "ps4", "sony_projector", "songpal"]
"integrations": [
"braviatv",
"ps4",
"sony_projector",
"songpal",
"playstation_network"
]
}

View File

@ -9,7 +9,10 @@ import voluptuous as vol
from homeassistant.const import CONF_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function
from homeassistant.helpers.condition import (
ConditionCheckerType,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType
from . import DeviceAutomationType, async_get_device_automation_platform
@ -19,13 +22,24 @@ if TYPE_CHECKING:
from homeassistant.helpers import condition
class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol):
class DeviceAutomationConditionProtocol(Protocol):
"""Define the format of device_condition modules.
Each module must define either CONDITION_SCHEMA or async_validate_condition_config
from ConditionProtocol.
Each module must define either CONDITION_SCHEMA or async_validate_condition_config.
"""
CONDITION_SCHEMA: vol.Schema
async def async_validate_condition_config(
self, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
def async_condition_from_config(
self, hass: HomeAssistant, config: ConfigType
) -> ConditionCheckerType:
"""Evaluate state based on configuration."""
async def async_get_condition_capabilities(
self, hass: HomeAssistant, config: ConfigType
) -> dict[str, vol.Schema]:

View File

@ -65,6 +65,7 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
"/ivp/ensemble/generator",
"/ivp/meters",
"/ivp/meters/readings",
"/ivp/pdm/device_data",
"/home",
]

View File

@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.0.1"],
"requirements": ["pyenphase==2.1.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@ -45,6 +45,7 @@ from homeassistant.const import (
UnitOfFrequency,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
@ -80,6 +81,114 @@ INVERTER_SENSORS = (
device_class=SensorDeviceClass.POWER,
value_fn=attrgetter("last_report_watts"),
),
EnvoyInverterSensorEntityDescription(
key="dc_voltage",
translation_key="dc_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("dc_voltage"),
),
EnvoyInverterSensorEntityDescription(
key="dc_current",
translation_key="dc_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("dc_current"),
),
EnvoyInverterSensorEntityDescription(
key="ac_voltage",
translation_key="ac_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLTAGE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("ac_voltage"),
),
EnvoyInverterSensorEntityDescription(
key="ac_current",
translation_key="ac_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.CURRENT,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("ac_current"),
),
EnvoyInverterSensorEntityDescription(
key="ac_frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.FREQUENCY,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("ac_frequency"),
),
EnvoyInverterSensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
suggested_display_precision=3,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("temperature"),
),
EnvoyInverterSensorEntityDescription(
key="lifetime_energy",
translation_key="lifetime_energy",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=attrgetter("lifetime_energy"),
),
EnvoyInverterSensorEntityDescription(
key="energy_today",
translation_key="energy_today",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
device_class=SensorDeviceClass.ENERGY,
entity_registry_enabled_default=False,
value_fn=attrgetter("energy_today"),
),
EnvoyInverterSensorEntityDescription(
key="last_report_duration",
translation_key="last_report_duration",
native_unit_of_measurement=UnitOfTime.SECONDS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.DURATION,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("last_report_duration"),
),
EnvoyInverterSensorEntityDescription(
key="energy_produced",
translation_key="energy_produced",
native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.ENERGY,
suggested_display_precision=3,
entity_registry_enabled_default=False,
value_fn=attrgetter("energy_produced"),
),
EnvoyInverterSensorEntityDescription(
key="max_reported",
translation_key="max_reported",
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.POWER,
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=attrgetter("max_report_watts"),
),
EnvoyInverterSensorEntityDescription(
key=LAST_REPORTED_KEY,
translation_key=LAST_REPORTED_KEY,

View File

@ -379,7 +379,34 @@
"name": "Aggregated Battery capacity"
},
"aggregated_soc": {
"name": "Aggregated battery soc"
"name": "Aggregated battery SOC"
},
"dc_voltage": {
"name": "DC voltage"
},
"dc_current": {
"name": "DC current"
},
"ac_voltage": {
"name": "AC voltage"
},
"ac_current": {
"name": "AC current"
},
"lifetime_energy": {
"name": "[%key:component::enphase_envoy::entity::sensor::lifetime_production::name%]"
},
"energy_today": {
"name": "[%key:component::enphase_envoy::entity::sensor::daily_production::name%]"
},
"energy_produced": {
"name": "Energy production since previous report"
},
"max_reported": {
"name": "Lifetime maximum power"
},
"last_report_duration": {
"name": "Last report duration"
}
},
"switch": {

View File

@ -1,6 +1,6 @@
"""The foscam component."""
from libpyfoscam import FoscamCamera
from libpyfoscamcgi import FoscamCamera
from homeassistant.const import (
CONF_HOST,

View File

@ -2,8 +2,8 @@
from typing import Any
from libpyfoscam import FoscamCamera
from libpyfoscam.foscam import (
from libpyfoscamcgi import FoscamCamera
from libpyfoscamcgi.foscamcgi import (
ERROR_FOSCAM_AUTH,
ERROR_FOSCAM_UNAVAILABLE,
FOSCAM_SUCCESS,

View File

@ -4,7 +4,7 @@ import asyncio
from datetime import timedelta
from typing import Any
from libpyfoscam import FoscamCamera
from libpyfoscamcgi import FoscamCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

View File

@ -5,6 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/foscam",
"iot_class": "local_polling",
"loggers": ["libpyfoscam"],
"requirements": ["libpyfoscam==1.2.2"]
"loggers": ["libpyfoscamcgi"],
"requirements": ["libpyfoscamcgi==0.0.6"]
}

View File

@ -9,7 +9,7 @@ CONF_PROMPT = "prompt"
ATTR_MODEL = "model"
CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash"
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts"
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0

View File

@ -1,95 +1,36 @@
"""The JuiceNet integration."""
import logging
import aiohttp
from pyjuicenet import Api, TokenError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .coordinator import JuiceNetCoordinator
from .device import JuiceNetApi
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
CONFIG_SCHEMA = vol.Schema(
vol.All(
cv.deprecated(DOMAIN),
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},
),
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the JuiceNet component."""
conf = config.get(DOMAIN)
hass.data.setdefault(DOMAIN, {})
if not conf:
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
)
)
return True
from .const import DOMAIN
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up JuiceNet from a config entry."""
config = entry.data
session = async_get_clientsession(hass)
access_token = config[CONF_ACCESS_TOKEN]
api = Api(access_token, session)
juicenet = JuiceNetApi(api)
try:
await juicenet.setup()
except TokenError as error:
_LOGGER.error("JuiceNet Error %s", error)
return False
except aiohttp.ClientError as error:
_LOGGER.error("Could not reach the JuiceNet API %s", error)
raise ConfigEntryNotReady from error
if not juicenet.devices:
_LOGGER.error("No JuiceNet devices found for this account")
return False
_LOGGER.debug("%d JuiceNet device(s) found", len(juicenet.devices))
coordinator = JuiceNetCoordinator(hass, entry, juicenet)
await coordinator.async_config_entry_first_refresh()
hass.data[DOMAIN][entry.entry_id] = {
JUICENET_API: juicenet,
JUICENET_COORDINATOR: coordinator,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"entries": "/config/integrations/integration/juicenet",
},
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
if all(
config_entry.state is ConfigEntryState.NOT_LOADED
for config_entry in hass.config_entries.async_entries(DOMAIN)
if config_entry.entry_id != entry.entry_id
):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
return True

View File

@ -1,82 +1,11 @@
"""Config flow for JuiceNet integration."""
import logging
from typing import Any
import aiohttp
from pyjuicenet import Api, TokenError
import voluptuous as vol
from homeassistant import core, exceptions
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.config_entries import ConfigFlow
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
juicenet = Api(data[CONF_ACCESS_TOKEN], session)
try:
await juicenet.get_devices()
except TokenError as error:
_LOGGER.error("Token Error %s", error)
raise InvalidAuth from error
except aiohttp.ClientError as error:
_LOGGER.error("Error connecting %s", error)
raise CannotConnect from error
# Return info that you want to store in the config entry.
return {"title": "JuiceNet"}
class JuiceNetConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for JuiceNet."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN])
self._abort_if_unique_id_configured()
try:
info = await validate_input(self.hass, user_input)
return self.async_create_entry(title=info["title"], data=user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import."""
return await self.async_step_user(import_data)
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(exceptions.HomeAssistantError):
"""Error to indicate there is invalid auth."""

View File

@ -1,6 +1,3 @@
"""Constants used by the JuiceNet component."""
DOMAIN = "juicenet"
JUICENET_API = "juicenet_api"
JUICENET_COORDINATOR = "juicenet_coordinator"

View File

@ -1,33 +0,0 @@
"""The JuiceNet integration."""
from datetime import timedelta
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .device import JuiceNetApi
_LOGGER = logging.getLogger(__name__)
class JuiceNetCoordinator(DataUpdateCoordinator[None]):
"""Coordinator for JuiceNet."""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, juicenet_api: JuiceNetApi
) -> None:
"""Initialize the JuiceNet coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name="JuiceNet",
update_interval=timedelta(seconds=30),
)
self.juicenet_api = juicenet_api
async def _async_update_data(self) -> None:
for device in self.juicenet_api.devices:
await device.update_state(True)

View File

@ -1,21 +0,0 @@
"""Adapter to wrap the pyjuicenet api for home assistant."""
from pyjuicenet import Api, Charger
class JuiceNetApi:
"""Represent a connection to JuiceNet."""
def __init__(self, api: Api) -> None:
"""Create an object from the provided API instance."""
self.api = api
self._devices: list[Charger] = []
async def setup(self) -> None:
"""JuiceNet device setup."""
self._devices = await self.api.get_devices()
@property
def devices(self) -> list[Charger]:
"""Get a list of devices managed by this account."""
return self._devices

View File

@ -1,32 +0,0 @@
"""Adapter to wrap the pyjuicenet api for home assistant."""
from pyjuicenet import Charger
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import JuiceNetCoordinator
class JuiceNetEntity(CoordinatorEntity[JuiceNetCoordinator]):
"""Represent a base JuiceNet device."""
_attr_has_entity_name = True
def __init__(
self, device: Charger, key: str, coordinator: JuiceNetCoordinator
) -> None:
"""Initialise the sensor."""
super().__init__(coordinator)
self.device = device
self.key = key
self._attr_unique_id = f"{device.id}-{key}"
self._attr_device_info = DeviceInfo(
configuration_url=(
f"https://home.juice.net/Portal/Details?unitID={device.id}"
),
identifiers={(DOMAIN, device.id)},
manufacturer="JuiceNet",
name=device.name,
)

View File

@ -1,10 +1,9 @@
{
"domain": "juicenet",
"name": "JuiceNet",
"codeowners": ["@jesserockz"],
"config_flow": true,
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/juicenet",
"integration_type": "system",
"iot_class": "cloud_polling",
"loggers": ["pyjuicenet"],
"requirements": ["python-juicenet==1.1.0"]
"requirements": []
}

View File

@ -1,93 +0,0 @@
"""Support for controlling juicenet/juicepoint/juicebox based EVSE numbers."""
from __future__ import annotations
from dataclasses import dataclass
from pyjuicenet import Charger
from homeassistant.components.number import (
DEFAULT_MAX_VALUE,
NumberEntity,
NumberEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .coordinator import JuiceNetCoordinator
from .device import JuiceNetApi
from .entity import JuiceNetEntity
@dataclass(frozen=True, kw_only=True)
class JuiceNetNumberEntityDescription(NumberEntityDescription):
"""An entity description for a JuiceNetNumber."""
setter_key: str
native_max_value_key: str | None = None
NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = (
JuiceNetNumberEntityDescription(
translation_key="amperage_limit",
key="current_charging_amperage_limit",
native_min_value=6,
native_max_value_key="max_charging_amperage",
native_step=1,
setter_key="set_charging_amperage_limit",
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JuiceNet Numbers."""
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
api: JuiceNetApi = juicenet_data[JUICENET_API]
coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR]
entities = [
JuiceNetNumber(device, description, coordinator)
for device in api.devices
for description in NUMBER_TYPES
]
async_add_entities(entities)
class JuiceNetNumber(JuiceNetEntity, NumberEntity):
"""Implementation of a JuiceNet number."""
entity_description: JuiceNetNumberEntityDescription
def __init__(
self,
device: Charger,
description: JuiceNetNumberEntityDescription,
coordinator: JuiceNetCoordinator,
) -> None:
"""Initialise the number."""
super().__init__(device, description.key, coordinator)
self.entity_description = description
@property
def native_value(self) -> float | None:
"""Return the value of the entity."""
return getattr(self.device, self.entity_description.key, None)
@property
def native_max_value(self) -> float:
"""Return the maximum value."""
if self.entity_description.native_max_value_key is not None:
return getattr(self.device, self.entity_description.native_max_value_key)
if self.entity_description.native_max_value is not None:
return self.entity_description.native_max_value
return DEFAULT_MAX_VALUE
async def async_set_native_value(self, value: float) -> None:
"""Update the current value."""
await getattr(self.device, self.entity_description.setter_key)(value)

View File

@ -1,124 +0,0 @@
"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors."""
from __future__ import annotations
from pyjuicenet import Charger
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .coordinator import JuiceNetCoordinator
from .device import JuiceNetApi
from .entity import JuiceNetEntity
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="status",
name="Charging Status",
),
SensorEntityDescription(
key="temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
),
SensorEntityDescription(
key="amps",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="watts",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charge_time",
translation_key="charge_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
icon="mdi:timer-outline",
),
SensorEntityDescription(
key="energy_added",
translation_key="energy_added",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JuiceNet Sensors."""
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
api: JuiceNetApi = juicenet_data[JUICENET_API]
coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR]
entities = [
JuiceNetSensorDevice(device, coordinator, description)
for device in api.devices
for description in SENSOR_TYPES
]
async_add_entities(entities)
class JuiceNetSensorDevice(JuiceNetEntity, SensorEntity):
"""Implementation of a JuiceNet sensor."""
def __init__(
self,
device: Charger,
coordinator: JuiceNetCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialise the sensor."""
super().__init__(device, description.key, coordinator)
self.entity_description = description
@property
def icon(self):
"""Return the icon of the sensor."""
icon = None
if self.entity_description.key == "status":
status = self.device.status
if status == "standby":
icon = "mdi:power-plug-off"
elif status == "plugged":
icon = "mdi:power-plug"
elif status == "charging":
icon = "mdi:battery-positive"
else:
icon = self.entity_description.icon
return icon
@property
def native_value(self):
"""Return the state."""
return getattr(self.device, self.entity_description.key, None)

View File

@ -1,41 +1,8 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"description": "You will need the API Token from https://home.juice.net/Manage.",
"title": "Connect to JuiceNet"
}
}
},
"entity": {
"number": {
"amperage_limit": {
"name": "Amperage limit"
}
},
"sensor": {
"charge_time": {
"name": "Charge time"
},
"energy_added": {
"name": "Energy added"
}
},
"switch": {
"charge_now": {
"name": "Charge now"
}
"issues": {
"integration_removed": {
"title": "The JuiceNet integration has been removed",
"description": "Enel X has dropped support for JuiceNet in favor of JuicePass, and the JuiceNet integration has been removed from Home Assistant as it was no longer working.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing JuiceNet integration entries]({entries})."
}
}
}

View File

@ -1,53 +0,0 @@
"""Support for monitoring juicenet/juicepoint/juicebox based EVSE switches."""
from typing import Any
from pyjuicenet import Charger
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .coordinator import JuiceNetCoordinator
from .device import JuiceNetApi
from .entity import JuiceNetEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JuiceNet switches."""
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
api: JuiceNetApi = juicenet_data[JUICENET_API]
coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR]
async_add_entities(
JuiceNetChargeNowSwitch(device, coordinator) for device in api.devices
)
class JuiceNetChargeNowSwitch(JuiceNetEntity, SwitchEntity):
"""Implementation of a JuiceNet switch."""
_attr_translation_key = "charge_now"
def __init__(self, device: Charger, coordinator: JuiceNetCoordinator) -> None:
"""Initialise the switch."""
super().__init__(device, "charge_now", coordinator)
@property
def is_on(self):
"""Return true if switch is on."""
return self.device.override_time != 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Charge now."""
await self.device.set_override(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Don't charge now."""
await self.device.set_override(False)

View File

@ -146,17 +146,27 @@ class KeeneticOptionsFlowHandler(OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if (
not hasattr(self.config_entry, "runtime_data")
or not self.config_entry.runtime_data
):
return self.async_abort(reason="not_initialized")
router = self.config_entry.runtime_data
try:
interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job(
router.client.get_interfaces
)
except ConnectionException:
return self.async_abort(reason="cannot_connect")
self._interface_options = {
interface.name: (interface.description or interface.name)
for interface in interfaces
if interface.type.lower() == "bridge"
}
return await self.async_step_user()
async def async_step_user(
@ -182,9 +192,13 @@ class KeeneticOptionsFlowHandler(OptionsFlow):
): int,
vol.Required(
CONF_INTERFACES,
default=self.config_entry.options.get(
default=[
item
for item in self.config_entry.options.get(
CONF_INTERFACES, [DEFAULT_INTERFACE]
),
)
if item in self._interface_options
],
): cv.multi_select(self._interface_options),
vol.Optional(
CONF_TRY_HOTSPOT,

View File

@ -36,6 +36,10 @@
"include_associated": "Use Wi-Fi AP associations data (ignored if hotspot data used)"
}
}
},
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"not_initialized": "The integration is not initialized yet. Can't display available options."
}
}
}

View File

@ -9,6 +9,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["xknx", "xknxproject"],
"quality_scale": "silver",
"requirements": [
"xknx==3.8.0",
"xknxproject==3.8.2",

View File

@ -13,7 +13,7 @@ rules:
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: todo
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name:
@ -41,8 +41,8 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
@ -64,21 +64,24 @@ rules:
comment: |
YAML entities don't support devices. UI entities support user-defined devices.
diagnostics: done
discovery-update-info: todo
discovery-update-info:
status: exempt
comment: |
KNX doesn't support any provided discovery method.
discovery:
status: exempt
comment: |
KNX doesn't support any provided discovery method.
docs-data-update: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: todo
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: |
Devices aren't supported directly since communication is on group address level.
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |

View File

@ -13,6 +13,7 @@ PLATFORMS = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
LOGGER = logging.getLogger(__package__)

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from homeassistant.helpers.device_registry import (
CONNECTION_BLUETOOTH,
CONNECTION_NETWORK_MAC,
DeviceInfo,
format_mac,
@ -21,10 +22,13 @@ class LaMetricEntity(CoordinatorEntity[LaMetricDataUpdateCoordinator]):
def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None:
"""Initialize the LaMetric entity."""
super().__init__(coordinator=coordinator)
connections = {(CONNECTION_NETWORK_MAC, format_mac(coordinator.data.wifi.mac))}
if coordinator.data.bluetooth is not None:
connections.add(
(CONNECTION_BLUETOOTH, format_mac(coordinator.data.bluetooth.address))
)
self._attr_device_info = DeviceInfo(
connections={
(CONNECTION_NETWORK_MAC, format_mac(coordinator.data.wifi.mac))
},
connections=connections,
identifiers={(DOMAIN, coordinator.data.serial_number)},
manufacturer="LaMetric Inc.",
model_id=coordinator.data.model,

View File

@ -0,0 +1,46 @@
"""LaMetric Update platform."""
from awesomeversion import AwesomeVersion
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator
from .entity import LaMetricEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LaMetricConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LaMetric update platform."""
coordinator = config_entry.runtime_data
if coordinator.data.os_version >= AwesomeVersion("2.3.0"):
async_add_entities([LaMetricUpdate(coordinator)])
class LaMetricUpdate(LaMetricEntity, UpdateEntity):
"""Representation of LaMetric Update."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.data.serial_number}-update"
@property
def installed_version(self) -> str:
"""Return the installed version of the entity."""
return self.coordinator.data.os_version
@property
def latest_version(self) -> str | None:
"""Return the latest version of the entity."""
if not self.coordinator.data.update:
return None
return self.coordinator.data.update.version

View File

@ -25,6 +25,8 @@ from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, DOMAIN, SETPOINTS
from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
def add_lcn_entities(
config_entry: LcnConfigEntry,

View File

@ -38,6 +38,8 @@ from .const import (
from .entity import LcnEntity
from .helpers import InputType, LcnConfigEntry
PARALLEL_UPDATES = 0
DEVICE_CLASS_MAPPING = {
pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE,
pypck.lcn_defs.VarUnit.KELVIN: SensorDeviceClass.TEMPERATURE,

View File

@ -13,7 +13,6 @@ from aiolifx.connection import LIFXConnection
import voluptuous as vol
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PORT,
@ -27,7 +26,7 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
from homeassistant.helpers.typing import ConfigType
from .const import _LOGGER, DATA_LIFX_MANAGER, DOMAIN, TARGET_ANY
from .coordinator import LIFXUpdateCoordinator
from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator
from .discovery import async_discover_devices, async_trigger_discovery
from .manager import LIFXManager
from .migration import async_migrate_entities_devices, async_migrate_legacy_entries
@ -73,7 +72,7 @@ DISCOVERY_COOLDOWN = 5
async def async_legacy_migration(
hass: HomeAssistant,
legacy_entry: ConfigEntry,
legacy_entry: LIFXConfigEntry,
discovered_devices: Iterable[Light],
) -> bool:
"""Migrate config entries."""
@ -157,7 +156,6 @@ class LIFXDiscoveryManager:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LIFX component."""
hass.data[DOMAIN] = {}
migrating = bool(async_get_legacy_entry(hass))
discovery_manager = LIFXDiscoveryManager(hass, migrating)
@ -187,7 +185,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool:
"""Set up LIFX from a config entry."""
if async_entry_is_legacy(entry):
return True
@ -198,10 +196,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async_migrate_entities_devices(hass, legacy_entry.entry_id, entry)
assert entry.unique_id is not None
domain_data = hass.data[DOMAIN]
if DATA_LIFX_MANAGER not in domain_data:
if DATA_LIFX_MANAGER not in hass.data:
manager = LIFXManager(hass)
domain_data[DATA_LIFX_MANAGER] = manager
hass.data[DATA_LIFX_MANAGER] = manager
manager.async_setup()
host = entry.data[CONF_HOST]
@ -229,21 +226,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady(
f"Unexpected device found at {host}; expected {entry.unique_id}, found {serial}"
)
domain_data[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool:
"""Unload a config entry."""
if async_entry_is_legacy(entry):
return True
domain_data = hass.data[DOMAIN]
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator: LIFXUpdateCoordinator = domain_data.pop(entry.entry_id)
coordinator.connection.async_stop()
entry.runtime_data.connection.async_stop()
# Only the DATA_LIFX_MANAGER left, remove it.
if len(domain_data) == 1:
manager: LIFXManager = domain_data.pop(DATA_LIFX_MANAGER)
if len(hass.config_entries.async_loaded_entries(DOMAIN)) == 0:
manager = hass.data.pop(DATA_LIFX_MANAGER)
manager.async_unload()
return unload_ok

View File

@ -7,13 +7,12 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, HEV_CYCLE_STATE
from .coordinator import LIFXUpdateCoordinator
from .const import HEV_CYCLE_STATE
from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator
from .entity import LIFXEntity
from .util import lifx_features
@ -27,11 +26,11 @@ HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: LIFXConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
if lifx_features(coordinator.device)["hev"]:
async_add_entities(

View File

@ -7,13 +7,12 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, IDENTIFY, RESTART
from .coordinator import LIFXUpdateCoordinator
from .const import IDENTIFY, RESTART
from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator
from .entity import LIFXEntity
RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription(
@ -31,12 +30,11 @@ IDENTIFY_BUTTON_DESCRIPTION = ButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: LIFXConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
domain_data = hass.data[DOMAIN]
coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
[LIFXRestartButton(coordinator), LIFXIdentifyButton(coordinator)]
)

View File

@ -1,8 +1,17 @@
"""Const for LIFX."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from .manager import LIFXManager
DOMAIN = "lifx"
DATA_LIFX_MANAGER: HassKey[LIFXManager] = HassKey(DOMAIN)
TARGET_ANY = "00:00:00:00:00:00"
@ -59,7 +68,6 @@ INFRARED_BRIGHTNESS_VALUES_MAP = {
32767: "50%",
65535: "100%",
}
DATA_LIFX_MANAGER = "lifx_manager"
LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202}

View File

@ -65,6 +65,8 @@ ZONES_PER_COLOR_UPDATE_REQUEST = 8
RSSI_DBM_FW = AwesomeVersion("2.77")
type LIFXConfigEntry = ConfigEntry[LIFXUpdateCoordinator]
class FirmwareEffect(IntEnum):
"""Enumeration of LIFX firmware effects."""
@ -87,12 +89,12 @@ class SkyType(IntEnum):
class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
"""DataUpdateCoordinator to gather data for a specific lifx device."""
config_entry: ConfigEntry
config_entry: LIFXConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: LIFXConfigEntry,
connection: LIFXConnection,
) -> None:
"""Initialize DataUpdateCoordinator."""

View File

@ -5,21 +5,20 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC
from homeassistant.core import HomeAssistant
from .const import CONF_LABEL, DOMAIN
from .coordinator import LIFXUpdateCoordinator
from .const import CONF_LABEL
from .coordinator import LIFXConfigEntry
TO_REDACT = [CONF_LABEL, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, entry: LIFXConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a LIFX config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
return {
"entry": {
"title": entry.title,

View File

@ -17,7 +17,6 @@ from homeassistant.components.light import (
LightEntity,
LightEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@ -37,7 +36,7 @@ from .const import (
INFRARED_BRIGHTNESS,
LIFX_CEILING_PRODUCT_IDS,
)
from .coordinator import FirmwareEffect, LIFXUpdateCoordinator
from .coordinator import FirmwareEffect, LIFXConfigEntry, LIFXUpdateCoordinator
from .entity import LIFXEntity
from .manager import (
SERVICE_EFFECT_COLORLOOP,
@ -78,13 +77,12 @@ HSBK_KELVIN = 3
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: LIFXConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
domain_data = hass.data[DOMAIN]
coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id]
manager: LIFXManager = domain_data[DATA_LIFX_MANAGER]
coordinator = entry.runtime_data
manager = hass.data[DATA_LIFX_MANAGER]
device = coordinator.device
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@ -123,7 +121,7 @@ class LIFXLight(LIFXEntity, LightEntity):
self,
coordinator: LIFXUpdateCoordinator,
manager: LIFXManager,
entry: ConfigEntry,
entry: LIFXConfigEntry,
) -> None:
"""Initialize the light."""
super().__init__(coordinator)

View File

@ -30,8 +30,8 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service import async_extract_referenced_entity_ids
from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN
from .coordinator import LIFXUpdateCoordinator
from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DOMAIN
from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator
from .util import convert_8_to_16, find_hsbk
if TYPE_CHECKING:
@ -494,13 +494,11 @@ class LIFXManager:
coordinators: list[LIFXUpdateCoordinator] = []
bulbs: list[Light] = []
for entry_id, coordinator in self.hass.data[DOMAIN].items():
if (
entry_id != DATA_LIFX_MANAGER
and self.entry_id_to_entity_id[entry_id] in entity_ids
):
coordinators.append(coordinator)
bulbs.append(coordinator.device)
entry: LIFXConfigEntry
for entry in self.hass.config_entries.async_loaded_entries(DOMAIN):
if self.entry_id_to_entity_id[entry.entry_id] in entity_ids:
coordinators.append(entry.runtime_data)
bulbs.append(entry.runtime_data.device)
if start_effect_func := self._effect_dispatch.get(service):
await start_effect_func(self, bulbs, coordinators, **kwargs)

View File

@ -2,11 +2,11 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import _LOGGER, DOMAIN
from .coordinator import LIFXConfigEntry
from .discovery import async_init_discovery_flow
@ -15,7 +15,7 @@ def async_migrate_legacy_entries(
hass: HomeAssistant,
discovered_hosts_by_serial: dict[str, str],
existing_serials: set[str],
legacy_entry: ConfigEntry,
legacy_entry: LIFXConfigEntry,
) -> int:
"""Migrate the legacy config entries to have an entry per device."""
_LOGGER.debug(
@ -45,7 +45,7 @@ def async_migrate_legacy_entries(
@callback
def async_migrate_entities_devices(
hass: HomeAssistant, legacy_entry_id: str, new_entry: ConfigEntry
hass: HomeAssistant, legacy_entry_id: str, new_entry: LIFXConfigEntry
) -> None:
"""Move entities and devices to the new config entry."""
migrated_devices = []

View File

@ -5,18 +5,12 @@ from __future__ import annotations
from aiolifx_themes.themes import ThemeLibrary
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_THEME,
DOMAIN,
INFRARED_BRIGHTNESS,
INFRARED_BRIGHTNESS_VALUES_MAP,
)
from .coordinator import LIFXUpdateCoordinator
from .const import ATTR_THEME, INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP
from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator
from .entity import LIFXEntity
from .util import lifx_features
@ -39,11 +33,11 @@ THEME_ENTITY = SelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: LIFXConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
entities: list[LIFXEntity] = []

View File

@ -10,13 +10,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_RSSI, DOMAIN
from .coordinator import LIFXUpdateCoordinator
from .const import ATTR_RSSI
from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator
from .entity import LIFXEntity
SCAN_INTERVAL = timedelta(seconds=30)
@ -33,11 +32,11 @@ RSSI_SENSOR = SensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: LIFXConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX sensor from config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities([LIFXRssiSensor(coordinator, RSSI_SENSOR)])

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
from functools import partial
from typing import Any
from typing import TYPE_CHECKING, Any
from aiolifx import products
from aiolifx.aiolifx import Light
@ -21,7 +21,6 @@ from homeassistant.components.light import (
ATTR_RGB_COLOR,
ATTR_XY_COLOR,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.util import color as color_util
@ -35,17 +34,20 @@ from .const import (
OVERALL_TIMEOUT,
)
if TYPE_CHECKING:
from .coordinator import LIFXConfigEntry
FIX_MAC_FW = AwesomeVersion("3.70")
@callback
def async_entry_is_legacy(entry: ConfigEntry) -> bool:
def async_entry_is_legacy(entry: LIFXConfigEntry) -> bool:
"""Check if a config entry is the legacy shared one."""
return entry.unique_id is None or entry.unique_id == DOMAIN
@callback
def async_get_legacy_entry(hass: HomeAssistant) -> ConfigEntry | None:
def async_get_legacy_entry(hass: HomeAssistant) -> LIFXConfigEntry | None:
"""Get the legacy config entry."""
for entry in hass.config_entries.async_entries(DOMAIN):
if async_entry_is_legacy(entry):

View File

@ -2,18 +2,17 @@
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from .const import DOMAIN
from .coordinator import LinearUpdateCoordinator
from .coordinator import LinearConfigEntry, LinearUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> bool:
"""Set up Linear Garage Door from a config entry."""
ir.async_create_issue(
@ -35,21 +34,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_remove_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)

View File

@ -19,6 +19,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type LinearConfigEntry = ConfigEntry[LinearUpdateCoordinator]
@dataclass
class LinearDevice:
@ -32,9 +34,9 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]):
"""DataUpdateCoordinator for Linear."""
_devices: list[dict[str, Any]] | None = None
config_entry: ConfigEntry
config_entry: LinearConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
def __init__(self, hass: HomeAssistant, config_entry: LinearConfigEntry) -> None:
"""Initialize DataUpdateCoordinator for Linear."""
super().__init__(
hass,

View File

@ -8,12 +8,10 @@ from homeassistant.components.cover import (
CoverEntity,
CoverEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LinearUpdateCoordinator
from .coordinator import LinearConfigEntry
from .entity import LinearEntity
SUPPORTED_SUBDEVICES = ["GDO"]
@ -23,11 +21,11 @@ SCAN_INTERVAL = timedelta(seconds=10)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: LinearConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Linear Garage Door cover."""
coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
async_add_entities(
LinearCoverEntity(coordinator, device_id, device_data.name, sub_device_id)

View File

@ -6,21 +6,19 @@ from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import LinearUpdateCoordinator
from .coordinator import LinearConfigEntry
TO_REDACT = {CONF_PASSWORD, CONF_EMAIL}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, entry: LinearConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),

View File

@ -5,12 +5,10 @@ from typing import Any
from linear_garage_door import Linear
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LinearUpdateCoordinator
from .coordinator import LinearConfigEntry
from .entity import LinearEntity
SUPPORTED_SUBDEVICES = ["Light"]
@ -18,11 +16,11 @@ SUPPORTED_SUBDEVICES = ["Light"]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: LinearConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Linear Garage Door cover."""
coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
data = coordinator.data
async_add_entities(

View File

@ -8,19 +8,18 @@ from aiohttp import ClientConnectorError
from livisi.aiolivisi import AioLivisi
from homeassistant import core
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, device_registry as dr
from .const import DOMAIN
from .coordinator import LivisiDataUpdateCoordinator
from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator
PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SWITCH]
async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: core.HomeAssistant, entry: LivisiConfigEntry) -> bool:
"""Set up Livisi Smart Home from a config entry."""
web_session = aiohttp_client.async_get_clientsession(hass)
aiolivisi = AioLivisi(web_session)
@ -31,7 +30,7 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo
except ClientConnectorError as exception:
raise ConfigEntryNotReady from exception
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
@ -45,16 +44,10 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo
entry.async_create_background_task(
hass, coordinator.ws_connect(), "livisi-ws_connect"
)
entry.async_on_unload(coordinator.websocket.disconnect)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: LivisiConfigEntry) -> bool:
"""Unload a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
unload_success = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await coordinator.websocket.disconnect()
if unload_success:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_success
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -8,23 +8,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE
from .coordinator import LivisiDataUpdateCoordinator
from .const import LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE
from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator
from .entity import LivisiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: LivisiConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary_sensor device."""
coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
known_devices = set()
@callback
@ -53,7 +52,7 @@ class LivisiBinarySensor(LivisiEntity, BinarySensorEntity):
def __init__(
self,
config_entry: ConfigEntry,
config_entry: LivisiConfigEntry,
coordinator: LivisiDataUpdateCoordinator,
device: dict[str, Any],
capability_name: str,
@ -86,7 +85,7 @@ class LivisiWindowDoorSensor(LivisiBinarySensor):
def __init__(
self,
config_entry: ConfigEntry,
config_entry: LivisiConfigEntry,
coordinator: LivisiDataUpdateCoordinator,
device: dict[str, Any],
) -> None:

View File

@ -11,7 +11,6 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@ -19,24 +18,23 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
LIVISI_STATE_CHANGE,
LOGGER,
MAX_TEMPERATURE,
MIN_TEMPERATURE,
VRCC_DEVICE_TYPE,
)
from .coordinator import LivisiDataUpdateCoordinator
from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator
from .entity import LivisiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: LivisiConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate device."""
coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
@callback
def handle_coordinator_update() -> None:
@ -71,7 +69,7 @@ class LivisiClimate(LivisiEntity, ClimateEntity):
def __init__(
self,
config_entry: ConfigEntry,
config_entry: LivisiConfigEntry,
coordinator: LivisiDataUpdateCoordinator,
device: dict[str, Any],
) -> None:

View File

@ -26,14 +26,16 @@ from .const import (
LOGGER,
)
type LivisiConfigEntry = ConfigEntry[LivisiDataUpdateCoordinator]
class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
"""Class to manage fetching LIVISI data API."""
config_entry: ConfigEntry
config_entry: LivisiConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: ConfigEntry, aiolivisi: AioLivisi
self, hass: HomeAssistant, config_entry: LivisiConfigEntry, aiolivisi: AioLivisi
) -> None:
"""Initialize my coordinator."""
super().__init__(

View File

@ -7,14 +7,13 @@ from typing import Any
from livisi.const import CAPABILITY_MAP
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE
from .coordinator import LivisiDataUpdateCoordinator
from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator
class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]):
@ -24,7 +23,7 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]):
def __init__(
self,
config_entry: ConfigEntry,
config_entry: LivisiConfigEntry,
coordinator: LivisiDataUpdateCoordinator,
device: dict[str, Any],
*,

View File

@ -5,24 +5,23 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES
from .coordinator import LivisiDataUpdateCoordinator
from .const import LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES
from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator
from .entity import LivisiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: LivisiConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch device."""
coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
@callback
def handle_coordinator_update() -> None:
@ -52,7 +51,7 @@ class LivisiSwitch(LivisiEntity, SwitchEntity):
def __init__(
self,
config_entry: ConfigEntry,
config_entry: LivisiConfigEntry,
coordinator: LivisiDataUpdateCoordinator,
device: dict[str, Any],
) -> None:

View File

@ -27,7 +27,12 @@ from . import (
MediaPlayerDeviceClass,
SearchMedia,
)
from .const import MediaPlayerEntityFeature, MediaPlayerState
from .const import (
ATTR_MEDIA_FILTER_CLASSES,
MediaClass,
MediaPlayerEntityFeature,
MediaPlayerState,
)
INTENT_MEDIA_PAUSE = "HassMediaPause"
INTENT_MEDIA_UNPAUSE = "HassMediaUnpause"
@ -231,6 +236,7 @@ class MediaSearchAndPlayHandler(intent.IntentHandler):
intent_type = INTENT_MEDIA_SEARCH_AND_PLAY
slot_schema = {
vol.Required("search_query"): cv.string,
vol.Optional("media_class"): vol.In([cls.value for cls in MediaClass]),
# Optional name/area/floor slots handled by intent matcher
vol.Optional("name"): cv.string,
vol.Optional("area"): cv.string,
@ -285,14 +291,23 @@ class MediaSearchAndPlayHandler(intent.IntentHandler):
target_entity = match_result.states[0]
target_entity_id = target_entity.entity_id
# Get media class if provided
media_class_slot = slots.get("media_class", {})
media_class_value = media_class_slot.get("value")
# Build search service data
search_data = {"search_query": search_query}
# Add media_filter_classes if media_class is provided
if media_class_value:
search_data[ATTR_MEDIA_FILTER_CLASSES] = [media_class_value]
# 1. Search Media
try:
search_response = await hass.services.async_call(
DOMAIN,
SERVICE_SEARCH_MEDIA,
{
"search_query": search_query,
},
search_data,
target={
"entity_id": target_entity_id,
},

View File

@ -1294,3 +1294,30 @@ STATE_PROGRAM_ID: dict[int, dict[int, str]] = {
MieleAppliance.ROBOT_VACUUM_CLEANER: ROBOT_VACUUM_CLEANER_PROGRAM_ID,
MieleAppliance.COFFEE_SYSTEM: COFFEE_SYSTEM_PROGRAM_ID,
}
class PlatePowerStep(MieleEnum):
"""Plate power settings."""
plate_step_0 = 0
plate_step_warming = 110, 220
plate_step_1 = 1
plate_step_2 = 2
plate_step_3 = 3
plate_step_4 = 4
plate_step_5 = 5
plate_step_6 = 6
plate_step_7 = 7
plate_step_8 = 8
plate_step_9 = 9
plate_step_10 = 10
plate_step_11 = 11
plate_step_12 = 12
plate_step_13 = 13
plate_step_14 = 4
plate_step_15 = 15
plate_step_16 = 16
plate_step_17 = 17
plate_step_18 = 18
plate_step_boost = 117, 118, 218
missing2none = -9999

View File

@ -56,30 +56,27 @@
"plate": {
"default": "mdi:circle-outline",
"state": {
"0": "mdi:circle-outline",
"110": "mdi:alpha-w-circle-outline",
"220": "mdi:alpha-w-circle-outline",
"1": "mdi:circle-slice-1",
"2": "mdi:circle-slice-1",
"3": "mdi:circle-slice-2",
"4": "mdi:circle-slice-2",
"5": "mdi:circle-slice-3",
"6": "mdi:circle-slice-3",
"7": "mdi:circle-slice-4",
"8": "mdi:circle-slice-4",
"9": "mdi:circle-slice-5",
"10": "mdi:circle-slice-5",
"11": "mdi:circle-slice-5",
"12": "mdi:circle-slice-6",
"13": "mdi:circle-slice-6",
"14": "mdi:circle-slice-6",
"15": "mdi:circle-slice-7",
"16": "mdi:circle-slice-7",
"17": "mdi:circle-slice-8",
"18": "mdi:circle-slice-8",
"117": "mdi:alpha-b-circle-outline",
"118": "mdi:alpha-b-circle-outline",
"217": "mdi:alpha-b-circle-outline"
"plate_step_0": "mdi:circle-outline",
"plate_step_warming": "mdi:alpha-w-circle-outline",
"plate_step_1": "mdi:circle-slice-1",
"plate_step_2": "mdi:circle-slice-1",
"plate_step_3": "mdi:circle-slice-2",
"plate_step_4": "mdi:circle-slice-2",
"plate_step_5": "mdi:circle-slice-3",
"plate_step_6": "mdi:circle-slice-3",
"plate_step_7": "mdi:circle-slice-4",
"plate_step_8": "mdi:circle-slice-4",
"plate_step_9": "mdi:circle-slice-5",
"plate_step_10": "mdi:circle-slice-5",
"plate_step_11": "mdi:circle-slice-5",
"plate_step_12": "mdi:circle-slice-6",
"plate_step_13": "mdi:circle-slice-6",
"plate_step_14": "mdi:circle-slice-6",
"plate_step_15": "mdi:circle-slice-7",
"plate_step_16": "mdi:circle-slice-7",
"plate_step_17": "mdi:circle-slice-8",
"plate_step_18": "mdi:circle-slice-8",
"plate_step_boost": "mdi:alpha-b-circle-outline"
}
},
"program_type": {

View File

@ -33,6 +33,7 @@ from .const import (
STATE_PROGRAM_PHASE,
STATE_STATUS_TAGS,
MieleAppliance,
PlatePowerStep,
StateDryingStep,
StateProgramType,
StateStatus,
@ -46,34 +47,6 @@ _LOGGER = logging.getLogger(__name__)
DISABLED_TEMPERATURE = -32768
PLATE_POWERS = [
"0",
"110",
"220",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"17",
"18",
"117",
"118",
"217",
]
DEFAULT_PLATE_COUNT = 4
PLATE_COUNT = {
@ -543,8 +516,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
translation_placeholders={"plate_no": str(i)},
zone=i,
device_class=SensorDeviceClass.ENUM,
options=PLATE_POWERS,
value_fn=lambda value: value.state_plate_step[0].value_raw,
options=sorted(PlatePowerStep.keys()),
value_fn=lambda value: None,
),
)
for i in range(1, 7)
@ -683,12 +656,19 @@ class MielePlateSensor(MieleSensor):
def native_value(self) -> StateType:
"""Return the state of the plate sensor."""
# state_plate_step is [] if all zones are off
plate_power = (
self.device.state_plate_step[self.entity_description.zone - 1].value_raw
if self.device.state_plate_step
else 0
return (
PlatePowerStep(
cast(
int,
self.device.state_plate_step[
self.entity_description.zone - 1
].value_raw,
)
).name
if self.device.state_plate_step
else PlatePowerStep.plate_step_0
)
return str(plate_power)
class MieleStatusSensor(MieleSensor):

View File

@ -203,30 +203,27 @@
"plate": {
"name": "Plate {plate_no}",
"state": {
"0": "0",
"110": "Warming",
"220": "[%key:component::miele::entity::sensor::plate::state::110%]",
"1": "1",
"2": "1\u2022",
"3": "2",
"4": "2\u2022",
"5": "3",
"6": "3\u2022",
"7": "4",
"8": "4\u2022",
"9": "5",
"10": "5\u2022",
"11": "6",
"12": "6\u2022",
"13": "7",
"14": "7\u2022",
"15": "8",
"16": "8\u2022",
"17": "9",
"18": "9\u2022",
"117": "Boost",
"118": "[%key:component::miele::entity::sensor::plate::state::117%]",
"217": "[%key:component::miele::entity::sensor::plate::state::117%]"
"power_step_0": "0",
"power_step_warm": "Warming",
"power_step_1": "1",
"power_step_2": "1\u2022",
"power_step_3": "2",
"power_step_4": "2\u2022",
"power_step_5": "3",
"power_step_6": "3\u2022",
"power_step_7": "4",
"power_step_8": "4\u2022",
"power_step_9": "5",
"power_step_10": "5\u2022",
"power_step_11": "6",
"power_step_12": "6\u2022",
"power_step_13": "7",
"power_step_14": "7\u2022",
"power_step_15": "8",
"power_step_16": "8\u2022",
"power_step_17": "9",
"power_step_18": "9\u2022",
"power_step_boost": "Boost"
}
},
"drying_step": {

View File

@ -3,7 +3,8 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from music_assistant_client import MusicAssistantClient
@ -31,7 +32,7 @@ if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
PLATFORMS = [Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30
@ -39,6 +40,7 @@ LISTEN_READY_TIMEOUT = 30
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData]
type PlayerAddCallback = Callable[[str], None]
@dataclass
@ -47,6 +49,8 @@ class MusicAssistantEntryData:
mass: MusicAssistantClient
listen_task: asyncio.Task
discovered_players: set[str] = field(default_factory=set)
platform_handlers: dict[Platform, PlayerAddCallback] = field(default_factory=dict)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@ -122,6 +126,33 @@ async def async_setup_entry(
# initialize platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# register listener for new players
async def handle_player_added(event: MassEvent) -> None:
"""Handle Mass Player Added event."""
if TYPE_CHECKING:
assert event.object_id is not None
if event.object_id in entry.runtime_data.discovered_players:
return
player = mass.players.get(event.object_id)
if TYPE_CHECKING:
assert player is not None
if not player.expose_to_ha:
return
entry.runtime_data.discovered_players.add(event.object_id)
# run callback for each platform
for callback in entry.runtime_data.platform_handlers.values():
callback(event.object_id)
entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED))
# add all current players
for player in mass.players:
if not player.expose_to_ha:
continue
entry.runtime_data.discovered_players.add(player.player_id)
for callback in entry.runtime_data.platform_handlers.values():
callback(player.player_id)
# register listener for removed players
async def handle_player_removed(event: MassEvent) -> None:
"""Handle Mass Player Removed event."""

View File

@ -0,0 +1,53 @@
"""Music Assistant Button platform."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MusicAssistantConfigEntry
from .entity import MusicAssistantEntity
from .helpers import catch_musicassistant_error
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Music Assistant MediaPlayer(s) from Config Entry."""
mass = entry.runtime_data.mass
def add_player(player_id: str) -> None:
"""Handle add player."""
async_add_entities(
[
# Add button entity to favorite the currently playing item on the player
MusicAssistantFavoriteButton(mass, player_id)
]
)
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.BUTTON, add_player)
class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity):
"""Representation of a Button entity to favorite the currently playing item on a player."""
entity_description = ButtonEntityDescription(
key="favorite_now_playing",
translation_key="favorite_now_playing",
)
@property
def available(self) -> bool:
"""Return availability of entity."""
# mark the button as unavailable if the player has no current media item
return super().available and self.player.current_media is not None
@catch_musicassistant_error
async def async_press(self) -> None:
"""Handle the button press command."""
await self.mass.players.add_currently_playing_to_favorites(self.player_id)

View File

@ -0,0 +1,28 @@
"""Helpers for the Music Assistant integration."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
import functools
from typing import Any
from music_assistant_models.errors import MusicAssistantError
from homeassistant.exceptions import HomeAssistantError
def catch_musicassistant_error[**_P, _R](
func: Callable[_P, Coroutine[Any, Any, _R]],
) -> Callable[_P, Coroutine[Any, Any, _R]]:
"""Check and convert commands to players."""
@functools.wraps(func)
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
"""Catch Music Assistant errors and convert to Home Assistant error."""
try:
return await func(*args, **kwargs)
except MusicAssistantError as err:
error_msg = str(err) or err.__class__.__name__
raise HomeAssistantError(error_msg) from err
return wrapper

View File

@ -1,4 +1,11 @@
{
"entity": {
"button": {
"favorite_now_playing": {
"default": "mdi:heart-plus"
}
}
},
"services": {
"play_media": { "service": "mdi:play" },
"play_announcement": { "service": "mdi:bullhorn" },

View File

@ -3,11 +3,10 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Coroutine, Mapping
from collections.abc import Mapping
from contextlib import suppress
import functools
import os
from typing import TYPE_CHECKING, Any, Concatenate
from typing import TYPE_CHECKING, Any
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
@ -18,7 +17,7 @@ from music_assistant_models.enums import (
QueueOption,
RepeatMode as MassRepeatMode,
)
from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
from music_assistant_models.errors import MediaNotFoundError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
from music_assistant_models.player_queue import PlayerQueue
@ -40,7 +39,7 @@ from homeassistant.components.media_player import (
SearchMediaQuery,
async_process_play_media_url,
)
from homeassistant.const import ATTR_NAME, STATE_OFF
from homeassistant.const import ATTR_NAME, STATE_OFF, Platform
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_registry as er
@ -76,6 +75,7 @@ from .const import (
DOMAIN,
)
from .entity import MusicAssistantEntity
from .helpers import catch_musicassistant_error
from .media_browser import async_browse_media, async_search_media
from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
@ -120,25 +120,6 @@ SERVICE_TRANSFER_QUEUE = "transfer_queue"
SERVICE_GET_QUEUE = "get_queue"
def catch_musicassistant_error[_R, **P](
func: Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]]:
"""Check and log commands to players."""
@functools.wraps(func)
async def wrapper(
self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs
) -> _R:
"""Catch Music Assistant errors and convert to Home Assistant error."""
try:
return await func(self, *args, **kwargs)
except MusicAssistantError as err:
error_msg = str(err) or err.__class__.__name__
raise HomeAssistantError(error_msg) from err
return wrapper
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
@ -146,33 +127,13 @@ async def async_setup_entry(
) -> None:
"""Set up Music Assistant MediaPlayer(s) from Config Entry."""
mass = entry.runtime_data.mass
added_ids = set()
async def handle_player_added(event: MassEvent) -> None:
"""Handle Mass Player Added event."""
if TYPE_CHECKING:
assert event.object_id is not None
if event.object_id in added_ids:
return
player = mass.players.get(event.object_id)
if TYPE_CHECKING:
assert player is not None
if not player.expose_to_ha:
return
added_ids.add(event.object_id)
async_add_entities([MusicAssistantPlayer(mass, event.object_id)])
def add_player(player_id: str) -> None:
"""Handle add player."""
async_add_entities([MusicAssistantPlayer(mass, player_id)])
# register listener for new players
entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED))
mass_players = []
# add all current players
for player in mass.players:
if not player.expose_to_ha:
continue
added_ids.add(player.player_id)
mass_players.append(MusicAssistantPlayer(mass, player.player_id))
async_add_entities(mass_players)
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player)
# add platform service for play_media with advanced options
platform = async_get_current_platform()

View File

@ -31,6 +31,13 @@
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"entity": {
"button": {
"favorite_now_playing": {
"name": "Favorite current song"
}
}
},
"issues": {
"invalid_server_version": {
"title": "The Music Assistant server is not the correct version",

View File

@ -12,19 +12,16 @@ from aiontfy.exceptions import (
NtfyUnauthorizedAuthenticationError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.NOTIFY]
type NtfyConfigEntry = ConfigEntry[Ntfy]
PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool:
@ -59,7 +56,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool
translation_key="timeout_error",
) from e
entry.runtime_data = ntfy
coordinator = NtfyDataUpdateCoordinator(hass, entry, ntfy)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -0,0 +1,74 @@
"""DataUpdateCoordinator for ntfy integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aiontfy import Account as NtfyAccount, Ntfy
from aiontfy.exceptions import (
NtfyConnectionError,
NtfyHTTPError,
NtfyTimeoutError,
NtfyUnauthorizedAuthenticationError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type NtfyConfigEntry = ConfigEntry[NtfyDataUpdateCoordinator]
class NtfyDataUpdateCoordinator(DataUpdateCoordinator[NtfyAccount]):
"""Ntfy data update coordinator."""
config_entry: NtfyConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: NtfyConfigEntry, ntfy: Ntfy
) -> None:
"""Initialize the ntfy data update coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(minutes=15),
)
self.ntfy = ntfy
async def _async_update_data(self) -> NtfyAccount:
"""Fetch account data from ntfy."""
try:
return await self.ntfy.account()
except NtfyUnauthorizedAuthenticationError as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
except NtfyHTTPError as e:
_LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="server_error",
translation_placeholders={"error_msg": str(e.error)},
) from e
except NtfyConnectionError as e:
_LOGGER.debug("Error", exc_info=True)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="connection_error",
) from e
except NtfyTimeoutError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_error",
) from e

View File

@ -4,6 +4,68 @@
"publish": {
"default": "mdi:console-line"
}
},
"sensor": {
"messages": {
"default": "mdi:message-arrow-right-outline"
},
"messages_remaining": {
"default": "mdi:message-plus-outline"
},
"messages_limit": {
"default": "mdi:message-alert-outline"
},
"messages_expiry_duration": {
"default": "mdi:message-text-clock"
},
"emails": {
"default": "mdi:email-arrow-right-outline"
},
"emails_remaining": {
"default": "mdi:email-plus-outline"
},
"emails_limit": {
"default": "mdi:email-alert-outline"
},
"calls": {
"default": "mdi:phone-outgoing"
},
"calls_remaining": {
"default": "mdi:phone-plus"
},
"calls_limit": {
"default": "mdi:phone-alert"
},
"reservations": {
"default": "mdi:lock"
},
"reservations_remaining": {
"default": "mdi:lock-plus"
},
"reservations_limit": {
"default": "mdi:lock-alert"
},
"attachment_total_size": {
"default": "mdi:database-arrow-right"
},
"attachment_total_size_remaining": {
"default": "mdi:database-plus"
},
"attachment_total_size_limit": {
"default": "mdi:database-alert"
},
"attachment_expiry_duration": {
"default": "mdi:cloud-clock"
},
"attachment_file_size": {
"default": "mdi:file-alert"
},
"attachment_bandwidth": {
"default": "mdi:cloud-upload"
},
"tier": {
"default": "mdi:star"
}
}
}
}

View File

@ -22,8 +22,8 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NtfyConfigEntry
from .const import CONF_TOPIC, DOMAIN
from .coordinator import NtfyConfigEntry
PARALLEL_UPDATES = 0
@ -69,9 +69,10 @@ class NtfyNotifyEntity(NotifyEntity):
name=subentry.data.get(CONF_NAME, self.topic),
configuration_url=URL(config_entry.data[CONF_URL]) / self.topic,
identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")},
via_device=(DOMAIN, config_entry.entry_id),
)
self.config_entry = config_entry
self.ntfy = config_entry.runtime_data
self.ntfy = config_entry.runtime_data.ntfy
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Publish a message to a topic."""

View File

@ -0,0 +1,272 @@
"""Sensor platform for ntfy integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from aiontfy import Account as NtfyAccount
from yarl import URL
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import CONF_URL, EntityCategory, UnitOfInformation, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator
PARALLEL_UPDATES = 0
@dataclass(kw_only=True, frozen=True)
class NtfySensorEntityDescription(SensorEntityDescription):
"""Ntfy Sensor Description."""
value_fn: Callable[[NtfyAccount], StateType]
class NtfySensor(StrEnum):
"""Ntfy sensors."""
MESSAGES = "messages"
MESSAGES_REMAINING = "messages_remaining"
MESSAGES_LIMIT = "messages_limit"
MESSAGES_EXPIRY_DURATION = "messages_expiry_duration"
EMAILS = "emails"
EMAILS_REMAINING = "emails_remaining"
EMAILS_LIMIT = "emails_limit"
CALLS = "calls"
CALLS_REMAINING = "calls_remaining"
CALLS_LIMIT = "calls_limit"
RESERVATIONS = "reservations"
RESERVATIONS_REMAINING = "reservations_remaining"
RESERVATIONS_LIMIT = "reservations_limit"
ATTACHMENT_TOTAL_SIZE = "attachment_total_size"
ATTACHMENT_TOTAL_SIZE_REMAINING = "attachment_total_size_remaining"
ATTACHMENT_TOTAL_SIZE_LIMIT = "attachment_total_size_limit"
ATTACHMENT_EXPIRY_DURATION = "attachment_expiry_duration"
ATTACHMENT_BANDWIDTH = "attachment_bandwidth"
ATTACHMENT_FILE_SIZE = "attachment_file_size"
TIER = "tier"
SENSOR_DESCRIPTIONS: tuple[NtfySensorEntityDescription, ...] = (
NtfySensorEntityDescription(
key=NtfySensor.MESSAGES,
translation_key=NtfySensor.MESSAGES,
value_fn=lambda account: account.stats.messages,
),
NtfySensorEntityDescription(
key=NtfySensor.MESSAGES_REMAINING,
translation_key=NtfySensor.MESSAGES_REMAINING,
value_fn=lambda account: account.stats.messages_remaining,
entity_registry_enabled_default=False,
),
NtfySensorEntityDescription(
key=NtfySensor.MESSAGES_LIMIT,
translation_key=NtfySensor.MESSAGES_LIMIT,
value_fn=lambda account: account.limits.messages if account.limits else None,
entity_category=EntityCategory.DIAGNOSTIC,
),
NtfySensorEntityDescription(
key=NtfySensor.MESSAGES_EXPIRY_DURATION,
translation_key=NtfySensor.MESSAGES_EXPIRY_DURATION,
value_fn=(
lambda account: account.limits.messages_expiry_duration
if account.limits
else None
),
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
),
NtfySensorEntityDescription(
key=NtfySensor.EMAILS,
translation_key=NtfySensor.EMAILS,
value_fn=lambda account: account.stats.emails,
),
NtfySensorEntityDescription(
key=NtfySensor.EMAILS_REMAINING,
translation_key=NtfySensor.EMAILS_REMAINING,
value_fn=lambda account: account.stats.emails_remaining,
entity_registry_enabled_default=False,
),
NtfySensorEntityDescription(
key=NtfySensor.EMAILS_LIMIT,
translation_key=NtfySensor.EMAILS_LIMIT,
value_fn=lambda account: account.limits.emails if account.limits else None,
entity_category=EntityCategory.DIAGNOSTIC,
),
NtfySensorEntityDescription(
key=NtfySensor.CALLS,
translation_key=NtfySensor.CALLS,
value_fn=lambda account: account.stats.calls,
),
NtfySensorEntityDescription(
key=NtfySensor.CALLS_REMAINING,
translation_key=NtfySensor.CALLS_REMAINING,
value_fn=lambda account: account.stats.calls_remaining,
entity_registry_enabled_default=False,
),
NtfySensorEntityDescription(
key=NtfySensor.CALLS_LIMIT,
translation_key=NtfySensor.CALLS_LIMIT,
value_fn=lambda account: account.limits.calls if account.limits else None,
entity_category=EntityCategory.DIAGNOSTIC,
),
NtfySensorEntityDescription(
key=NtfySensor.RESERVATIONS,
translation_key=NtfySensor.RESERVATIONS,
value_fn=lambda account: account.stats.reservations,
),
NtfySensorEntityDescription(
key=NtfySensor.RESERVATIONS_REMAINING,
translation_key=NtfySensor.RESERVATIONS_REMAINING,
value_fn=lambda account: account.stats.reservations_remaining,
entity_registry_enabled_default=False,
),
NtfySensorEntityDescription(
key=NtfySensor.RESERVATIONS_LIMIT,
translation_key=NtfySensor.RESERVATIONS_LIMIT,
value_fn=(
lambda account: account.limits.reservations if account.limits else None
),
entity_category=EntityCategory.DIAGNOSTIC,
),
NtfySensorEntityDescription(
key=NtfySensor.ATTACHMENT_EXPIRY_DURATION,
translation_key=NtfySensor.ATTACHMENT_EXPIRY_DURATION,
value_fn=(
lambda account: account.limits.attachment_expiry_duration
if account.limits
else None
),
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
),
NtfySensorEntityDescription(
key=NtfySensor.ATTACHMENT_TOTAL_SIZE,
translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE,
value_fn=lambda account: account.stats.attachment_total_size,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
suggested_display_precision=0,
),
NtfySensorEntityDescription(
key=NtfySensor.ATTACHMENT_TOTAL_SIZE_REMAINING,
translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE_REMAINING,
value_fn=lambda account: account.stats.attachment_total_size_remaining,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
suggested_display_precision=0,
entity_registry_enabled_default=False,
),
NtfySensorEntityDescription(
key=NtfySensor.ATTACHMENT_TOTAL_SIZE_LIMIT,
translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE_LIMIT,
value_fn=(
lambda account: account.limits.attachment_total_size
if account.limits
else None
),
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
suggested_display_precision=0,
),
NtfySensorEntityDescription(
key=NtfySensor.ATTACHMENT_FILE_SIZE,
translation_key=NtfySensor.ATTACHMENT_FILE_SIZE,
value_fn=(
lambda account: account.limits.attachment_file_size
if account.limits
else None
),
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
suggested_display_precision=0,
),
NtfySensorEntityDescription(
key=NtfySensor.ATTACHMENT_BANDWIDTH,
translation_key=NtfySensor.ATTACHMENT_BANDWIDTH,
value_fn=(
lambda account: account.limits.attachment_bandwidth
if account.limits
else None
),
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES,
suggested_display_precision=0,
),
NtfySensorEntityDescription(
key=NtfySensor.TIER,
translation_key=NtfySensor.TIER,
value_fn=lambda account: account.tier.name if account.tier else "free",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NtfyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
async_add_entities(
NtfySensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
)
class NtfySensorEntity(CoordinatorEntity[NtfyDataUpdateCoordinator], SensorEntity):
"""Representation of a ntfy sensor entity."""
entity_description: NtfySensorEntityDescription
coordinator: NtfyDataUpdateCoordinator
_attr_has_entity_name = True
def __init__(
self,
coordinator: NtfyDataUpdateCoordinator,
description: NtfySensorEntityDescription,
) -> None:
"""Initialize a sensor entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer="ntfy LLC",
model="ntfy",
configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app",
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@ -120,6 +120,88 @@
}
}
},
"entity": {
"sensor": {
"messages": {
"name": "Messages published",
"unit_of_measurement": "messages"
},
"messages_remaining": {
"name": "Messages remaining",
"unit_of_measurement": "[%key:component::ntfy::entity::sensor::messages::unit_of_measurement%]"
},
"messages_limit": {
"name": "Messages usage limit",
"unit_of_measurement": "[%key:component::ntfy::entity::sensor::messages::unit_of_measurement%]"
},
"messages_expiry_duration": {
"name": "Messages expiry duration"
},
"emails": {
"name": "Emails sent",
"unit_of_measurement": "emails"
},
"emails_remaining": {
"name": "Emails remaining",
"unit_of_measurement": "[%key:component::ntfy::entity::sensor::emails::unit_of_measurement%]"
},
"emails_limit": {
"name": "Email usage limit",
"unit_of_measurement": "[%key:component::ntfy::entity::sensor::emails::unit_of_measurement%]"
},
"calls": {
"name": "Phone calls made",
"unit_of_measurement": "calls"
},
"calls_remaining": {
"name": "Phone calls remaining",
"unit_of_measurement": "[%key:component::ntfy::entity::sensor::calls::unit_of_measurement%]"
},
"calls_limit": {
"name": "Phone calls usage limit",
"unit_of_measurement": "[%key:component::ntfy::entity::sensor::calls::unit_of_measurement%]"
},
"reservations": {
"name": "Reserved topics",
"unit_of_measurement": "topics"
},
"reservations_remaining": {
"name": "Reserved topics remaining",
"unit_of_measurement": "[%key:component::ntfy::entity::sensor::reservations::unit_of_measurement%]"
},
"reservations_limit": {
"name": "Reserved topics limit",
"unit_of_measurement": "[%key:component::ntfy::entity::sensor::reservations::unit_of_measurement%]"
},
"attachment_total_size": {
"name": "Attachment storage"
},
"attachment_total_size_remaining": {
"name": "Attachment storage remaining"
},
"attachment_total_size_limit": {
"name": "Attachment storage limit"
},
"attachment_expiry_duration": {
"name": "Attachment expiry duration"
},
"attachment_file_size": {
"name": "Attachment file size limit"
},
"attachment_bandwidth": {
"name": "Attachment bandwidth limit"
},
"tier": {
"name": "Subscription tier",
"state": {
"free": "Free",
"supporter": "Supporter",
"pro": "Pro",
"business": "Business"
}
}
}
},
"exceptions": {
"publish_failed_request_error": {
"message": "Failed to publish notification: {error_msg}"

View File

@ -0,0 +1,34 @@
"""The PlayStation Network integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import CONF_NPSSO
from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator
from .helpers import PlaystationNetwork
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
async def async_setup_entry(
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
) -> bool:
"""Set up Playstation Network from a config entry."""
psn = PlaystationNetwork(hass, entry.data[CONF_NPSSO])
coordinator = PlaystationNetworkCoordinator(hass, psn, 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: PlaystationNetworkConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,70 @@
"""Config flow for the PlayStation Network integration."""
import logging
from typing import Any
from psnawp_api.core.psnawp_exceptions import (
PSNAWPAuthenticationError,
PSNAWPError,
PSNAWPInvalidTokenError,
PSNAWPNotFoundError,
)
from psnawp_api.models.user import User
from psnawp_api.utils.misc import parse_npsso_token
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import CONF_NPSSO, DOMAIN
from .helpers import PlaystationNetwork
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_NPSSO): str})
class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Playstation Network."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
npsso: str | None = None
if user_input is not None:
try:
npsso = parse_npsso_token(user_input[CONF_NPSSO])
except PSNAWPInvalidTokenError:
errors["base"] = "invalid_account"
if npsso:
psn = PlaystationNetwork(self.hass, npsso)
try:
user: User = await psn.get_user()
except PSNAWPAuthenticationError:
errors["base"] = "invalid_auth"
except PSNAWPNotFoundError:
errors["base"] = "invalid_account"
except PSNAWPError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user.account_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user.online_id,
data={CONF_NPSSO: npsso},
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"npsso_link": "https://ca.account.sony.com/api/v1/ssocookie",
"psn_link": "https://playstation.com",
},
)

View File

@ -0,0 +1,15 @@
"""Constants for the Playstation Network integration."""
from typing import Final
from psnawp_api.models.trophies import PlatformType
DOMAIN = "playstation_network"
CONF_NPSSO: Final = "npsso"
SUPPORTED_PLATFORMS = {
PlatformType.PS5,
PlatformType.PS4,
PlatformType.PS3,
PlatformType.PSPC,
}

View File

@ -0,0 +1,69 @@
"""Coordinator for the PlayStation Network Integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from psnawp_api.core.psnawp_exceptions import (
PSNAWPAuthenticationError,
PSNAWPServerError,
)
from psnawp_api.models.user import User
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
from .helpers import PlaystationNetwork, PlaystationNetworkData
_LOGGER = logging.getLogger(__name__)
type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator]
class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]):
"""Data update coordinator for PSN."""
config_entry: PlaystationNetworkConfigEntry
user: User
def __init__(
self,
hass: HomeAssistant,
psn: PlaystationNetwork,
config_entry: PlaystationNetworkConfigEntry,
) -> None:
"""Initialize the Coordinator."""
super().__init__(
hass,
name=DOMAIN,
logger=_LOGGER,
config_entry=config_entry,
update_interval=timedelta(seconds=30),
)
self.psn = psn
async def _async_setup(self) -> None:
"""Set up the coordinator."""
try:
self.user = await self.psn.get_user()
except PSNAWPAuthenticationError as error:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="not_ready",
) from error
async def _async_update_data(self) -> PlaystationNetworkData:
"""Get the latest data from the PSN."""
try:
return await self.psn.get_data()
except (PSNAWPAuthenticationError, PSNAWPServerError) as error:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
) from error

View File

@ -0,0 +1,151 @@
"""Helper methods for common PlayStation Network integration operations."""
from __future__ import annotations
from dataclasses import dataclass, field
from functools import partial
from typing import Any
from psnawp_api import PSNAWP
from psnawp_api.core.psnawp_exceptions import PSNAWPNotFoundError
from psnawp_api.models.client import Client
from psnawp_api.models.trophies import PlatformType
from psnawp_api.models.user import User
from pyrate_limiter import Duration, Rate
from homeassistant.core import HomeAssistant
from .const import SUPPORTED_PLATFORMS
LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4}
@dataclass
class SessionData:
"""Dataclass representing console session data."""
platform: PlatformType = PlatformType.UNKNOWN
title_id: str | None = None
title_name: str | None = None
format: PlatformType | None = None
media_image_url: str | None = None
status: str = ""
@dataclass
class PlaystationNetworkData:
"""Dataclass representing data retrieved from the Playstation Network api."""
presence: dict[str, Any] = field(default_factory=dict)
username: str = ""
account_id: str = ""
available: bool = False
active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict)
registered_platforms: set[PlatformType] = field(default_factory=set)
class PlaystationNetwork:
"""Helper Class to return playstation network data in an easy to use structure."""
def __init__(self, hass: HomeAssistant, npsso: str) -> None:
"""Initialize the class with the npsso token."""
rate = Rate(300, Duration.MINUTE * 15)
self.psn = PSNAWP(npsso, rate_limit=rate)
self.client: Client | None = None
self.hass = hass
self.user: User
self.legacy_profile: dict[str, Any] | None = None
async def get_user(self) -> User:
"""Get the user object from the PlayStation Network."""
self.user = await self.hass.async_add_executor_job(
partial(self.psn.user, online_id="me")
)
return self.user
def retrieve_psn_data(self) -> PlaystationNetworkData:
"""Bundle api calls to retrieve data from the PlayStation Network."""
data = PlaystationNetworkData()
if not self.client:
self.client = self.psn.me()
data.registered_platforms = {
PlatformType(device["deviceType"])
for device in self.client.get_account_devices()
} & SUPPORTED_PLATFORMS
data.presence = self.user.get_presence()
# check legacy platforms if owned
if LEGACY_PLATFORMS & data.registered_platforms:
self.legacy_profile = self.client.get_profile_legacy()
return data
async def get_data(self) -> PlaystationNetworkData:
"""Get title data from the PlayStation Network."""
data = await self.hass.async_add_executor_job(self.retrieve_psn_data)
data.username = self.user.online_id
data.account_id = self.user.account_id
data.available = (
data.presence.get("basicPresence", {}).get("availability")
== "availableToPlay"
)
session = SessionData()
session.platform = PlatformType(
data.presence["basicPresence"]["primaryPlatformInfo"]["platform"]
)
if session.platform in SUPPORTED_PLATFORMS:
session.status = data.presence.get("basicPresence", {}).get(
"primaryPlatformInfo"
)["onlineStatus"]
game_title_info = data.presence.get("basicPresence", {}).get(
"gameTitleInfoList"
)
if game_title_info:
session.title_id = game_title_info[0]["npTitleId"]
session.title_name = game_title_info[0]["titleName"]
session.format = PlatformType(game_title_info[0]["format"])
if session.format in {PlatformType.PS5, PlatformType.PSPC}:
session.media_image_url = game_title_info[0]["conceptIconUrl"]
else:
session.media_image_url = game_title_info[0]["npTitleIconUrl"]
data.active_sessions[session.platform] = session
if self.legacy_profile:
presence = self.legacy_profile["profile"].get("presences", [])
game_title_info = presence[0] if presence else {}
session = SessionData()
# If primary console isn't online, check legacy platforms for status
if not data.available:
data.available = game_title_info["onlineStatus"] == "online"
if "npTitleId" in game_title_info:
session.title_id = game_title_info["npTitleId"]
session.title_name = game_title_info["titleName"]
session.format = game_title_info["platform"]
session.platform = game_title_info["platform"]
session.status = game_title_info["onlineStatus"]
if PlatformType(session.format) is PlatformType.PS4:
session.media_image_url = game_title_info["npTitleIconUrl"]
elif PlatformType(session.format) is PlatformType.PS3:
try:
title = self.psn.game_title(
session.title_id, platform=PlatformType.PS3, account_id="me"
)
except PSNAWPNotFoundError:
session.media_image_url = None
if title:
session.media_image_url = title.get_title_icon_url()
if game_title_info["onlineStatus"] == "online":
data.active_sessions[session.platform] = session
return data

View File

@ -0,0 +1,9 @@
{
"entity": {
"media_player": {
"playstation": {
"default": "mdi:sony-playstation"
}
}
}
}

View File

@ -0,0 +1,11 @@
{
"domain": "playstation_network",
"name": "PlayStation Network",
"codeowners": ["@jackjpowell"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/playstation_network",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["PSNAWP==3.0.0", "pyrate-limiter==3.7.0"]
}

View File

@ -0,0 +1,128 @@
"""Media player entity for the PlayStation Network Integration."""
import logging
from psnawp_api.models.trophies import PlatformType
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerState,
MediaType,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator
from .const import DOMAIN, SUPPORTED_PLATFORMS
_LOGGER = logging.getLogger(__name__)
PLATFORM_MAP = {
PlatformType.PS5: "PlayStation 5",
PlatformType.PS4: "PlayStation 4",
PlatformType.PS3: "PlayStation 3",
PlatformType.PSPC: "PlayStation PC",
}
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PlaystationNetworkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Media Player Entity Setup."""
coordinator = config_entry.runtime_data
devices_added: set[PlatformType] = set()
device_reg = dr.async_get(hass)
entities = []
@callback
def add_entities() -> None:
nonlocal devices_added
if not SUPPORTED_PLATFORMS - devices_added:
remove_listener()
new_platforms = set(coordinator.data.active_sessions.keys()) - devices_added
if new_platforms:
async_add_entities(
PsnMediaPlayerEntity(coordinator, platform_type)
for platform_type in new_platforms
)
devices_added |= new_platforms
for platform in SUPPORTED_PLATFORMS:
if device_reg.async_get_device(
identifiers={
(DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}")
}
):
entities.append(PsnMediaPlayerEntity(coordinator, platform))
devices_added.add(platform)
if entities:
async_add_entities(entities)
remove_listener = coordinator.async_add_listener(add_entities)
add_entities()
class PsnMediaPlayerEntity(
CoordinatorEntity[PlaystationNetworkCoordinator], MediaPlayerEntity
):
"""Media player entity representing currently playing game."""
_attr_media_image_remotely_accessible = True
_attr_media_content_type = MediaType.GAME
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_translation_key = "playstation"
_attr_has_entity_name = True
_attr_name = None
def __init__(
self, coordinator: PlaystationNetworkCoordinator, platform: PlatformType
) -> None:
"""Initialize PSN MediaPlayer."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{platform.value}"
self.key = platform
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=PLATFORM_MAP[platform],
manufacturer="Sony Interactive Entertainment",
model=PLATFORM_MAP[platform],
)
@property
def state(self) -> MediaPlayerState:
"""Media Player state getter."""
session = self.coordinator.data.active_sessions.get(self.key)
if session and session.status == "online":
if self.coordinator.data.available and session.title_id is not None:
return MediaPlayerState.PLAYING
return MediaPlayerState.ON
return MediaPlayerState.OFF
@property
def media_title(self) -> str | None:
"""Media title getter."""
session = self.coordinator.data.active_sessions.get(self.key)
return session.title_name if session else None
@property
def media_content_id(self) -> str | None:
"""Content ID of current playing media."""
session = self.coordinator.data.active_sessions.get(self.key)
return session.title_id if session else None
@property
def media_image_url(self) -> str | None:
"""Media image url getter."""
session = self.coordinator.data.active_sessions.get(self.key)
return session.media_image_url if session else None

View File

@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration has no actions
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration has no actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not register 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: Integration has no actions
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration has no configuration options
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Discovery flow is not applicable for this integration
discovery: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@ -0,0 +1,32 @@
{
"config": {
"step": {
"user": {
"data": {
"npsso": "NPSSO token"
},
"data_description": {
"npsso": "The NPSSO token is generated during successful login of your PlayStation Network account and is used to authenticate your requests from with Home Assistant."
},
"description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_account": "[%key:common::config_flow::error::invalid_access_token%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
}
},
"exceptions": {
"not_ready": {
"message": "Authentication to the PlayStation Network failed."
},
"update_failed": {
"message": "Data retrieval failed when trying to access the PlayStation Network."
}
}
}

View File

@ -32,6 +32,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import (
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
@ -107,6 +108,7 @@ async def async_setup_entry(
or host.api.supported(None, "privacy_mode")
!= config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT)
or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY)
):
if host.api.port != config_entry.data[CONF_PORT]:
_LOGGER.warning(
@ -130,6 +132,7 @@ async def async_setup_entry(
CONF_PORT: host.api.port,
CONF_USE_HTTPS: host.api.use_https,
CONF_BC_PORT: host.api.baichuan.port,
CONF_BC_ONLY: host.api.baichuan_only,
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
}
hass.config_entries.async_update_entry(config_entry, data=data)

View File

@ -69,21 +69,28 @@ CAMERA_ENTITIES = (
),
ReolinkCameraEntityDescription(
key="autotrack_sub",
stream="autotrack_sub",
translation_key="autotrack_sub",
stream="telephoto_sub",
translation_key="telephoto_sub",
supported=lambda api, ch: api.supported(ch, "autotrack_stream"),
),
ReolinkCameraEntityDescription(
key="autotrack_main",
stream="telephoto_main",
translation_key="telephoto_main",
supported=lambda api, ch: api.supported(ch, "autotrack_stream"),
entity_registry_enabled_default=False,
),
ReolinkCameraEntityDescription(
key="autotrack_snapshots_sub",
stream="autotrack_snapshots_sub",
translation_key="autotrack_snapshots_sub",
translation_key="telephoto_snapshots_sub",
supported=lambda api, ch: api.supported(ch, "autotrack_stream"),
entity_registry_enabled_default=False,
),
ReolinkCameraEntityDescription(
key="autotrack_snapshots_main",
stream="autotrack_snapshots_main",
translation_key="autotrack_snapshots_main",
translation_key="telephoto_snapshots_main",
supported=lambda api, ch: api.supported(ch, "autotrack_stream"),
entity_registry_enabled_default=False,
),

View File

@ -38,7 +38,13 @@ from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .const import (
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
DOMAIN,
)
from .exceptions import (
PasswordIncompatible,
ReolinkException,
@ -296,6 +302,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
user_input[CONF_PORT] = host.api.port
user_input[CONF_USE_HTTPS] = host.api.use_https
user_input[CONF_BC_PORT] = host.api.baichuan.port
user_input[CONF_BC_ONLY] = host.api.baichuan_only
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
None, "privacy_mode"
)

View File

@ -4,6 +4,7 @@ DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https"
CONF_BC_PORT = "baichuan_port"
CONF_BC_ONLY = "baichuan_only"
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
# Conserve battery by not waking the battery cameras each minute during normal update

View File

@ -24,7 +24,7 @@ class ReolinkEntityDescription(EntityDescription):
"""A class that describes entities for Reolink."""
cmd_key: str | None = None
cmd_id: int | None = None
cmd_id: int | list[int] | None = None
always_available: bool = False
@ -120,11 +120,14 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
"""Entity created."""
await super().async_added_to_hass()
cmd_key = self.entity_description.cmd_key
cmd_id = self.entity_description.cmd_id
cmd_ids = self.entity_description.cmd_id
callback_id = f"{self.platform.domain}_{self._attr_unique_id}"
if cmd_key is not None:
self._host.async_register_update_cmd(cmd_key)
if cmd_id is not None:
if isinstance(cmd_ids, int):
self.register_callback(callback_id, cmd_ids)
elif isinstance(cmd_ids, list):
for cmd_id in cmd_ids:
self.register_callback(callback_id, cmd_id)
# Privacy mode
self.register_callback(f"{callback_id}_623", 623)

View File

@ -38,6 +38,7 @@ from .const import (
BATTERY_ALL_WAKE_UPDATE_INTERVAL,
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
BATTERY_WAKE_UPDATE_INTERVAL,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
@ -97,6 +98,7 @@ class ReolinkHost:
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=get_aiohttp_session,
bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
bc_only=config.get(CONF_BC_ONLY, False),
)
self.last_wake: defaultdict[int, float] = defaultdict(float)
@ -220,19 +222,27 @@ class ReolinkHost:
enable_onvif = None
enable_rtmp = None
if not self._api.rtsp_enabled:
if not self._api.rtsp_enabled and not self._api.baichuan_only:
_LOGGER.debug(
"RTSP is disabled on %s, trying to enable it", self._api.nvr_name
)
enable_rtsp = True
if not self._api.onvif_enabled and onvif_supported:
if (
not self._api.onvif_enabled
and onvif_supported
and not self._api.baichuan_only
):
_LOGGER.debug(
"ONVIF is disabled on %s, trying to enable it", self._api.nvr_name
)
enable_onvif = True
if not self._api.rtmp_enabled and self._api.protocol == "rtmp":
if (
not self._api.rtmp_enabled
and self._api.protocol == "rtmp"
and not self._api.baichuan_only
):
_LOGGER.debug(
"RTMP is disabled on %s, trying to enable it", self._api.nvr_name
)

View File

@ -57,7 +57,7 @@ LIGHT_ENTITIES = (
ReolinkLightEntityDescription(
key="floodlight",
cmd_key="GetWhiteLed",
cmd_id=291,
cmd_id=[291, 289, 438],
translation_key="floodlight",
supported=lambda api, ch: api.supported(ch, "floodLight"),
is_on_fn=lambda api, ch: api.whiteled_state(ch),

View File

@ -42,9 +42,9 @@ def res_name(stream: str) -> str:
case "main":
return "High res."
case "autotrack_sub":
return "Autotrack low res."
return "Telephoto low res."
case "autotrack_main":
return "Autotrack high res."
return "Telephoto high res."
case _:
return "Low res."
@ -284,7 +284,7 @@ class ReolinkVODMediaSource(MediaSource):
identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub",
media_class=MediaClass.CHANNEL,
media_content_type=MediaType.PLAYLIST,
title="Autotrack low resolution",
title="Telephoto low resolution",
can_play=False,
can_expand=True,
),
@ -293,7 +293,7 @@ class ReolinkVODMediaSource(MediaSource):
identifier=f"RES|{config_entry_id}|{channel}|autotrack_main",
media_class=MediaClass.CHANNEL,
media_content_type=MediaType.PLAYLIST,
title="Autotrack high resolution",
title="Telephoto high resolution",
can_play=False,
can_expand=True,
),

View File

@ -113,6 +113,7 @@ NUMBER_ENTITIES = (
ReolinkNumberEntityDescription(
key="floodlight_brightness",
cmd_key="GetWhiteLed",
cmd_id=[289, 438],
translation_key="floodlight_brightness",
entity_category=EntityCategory.CONFIG,
native_step=1,

View File

@ -504,14 +504,17 @@
"ext_lens_1": {
"name": "Balanced lens 1"
},
"autotrack_sub": {
"name": "Autotrack fluent"
"telephoto_sub": {
"name": "Telephoto fluent"
},
"autotrack_snapshots_sub": {
"name": "Autotrack snapshots fluent"
"telephoto_main": {
"name": "Telephoto clear"
},
"autotrack_snapshots_main": {
"name": "Autotrack snapshots clear"
"telephoto_snapshots_sub": {
"name": "Telephoto snapshots fluent"
},
"telephoto_snapshots_main": {
"name": "Telephoto snapshots clear"
}
},
"light": {

View File

@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["aiotedee"],
"quality_scale": "platinum",
"requirements": ["aiotedee==0.2.23"]
"requirements": ["aiotedee==0.2.25"]
}

View File

@ -2,8 +2,10 @@
from abc import abstractmethod
import asyncio
from collections.abc import Callable, Sequence
import io
import logging
from ssl import SSLContext
from types import MappingProxyType
from typing import Any
@ -13,6 +15,7 @@ from telegram import (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
InputPollOption,
Message,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
@ -262,7 +265,9 @@ class TelegramNotificationService:
return allowed_chat_ids
def _get_msg_ids(self, msg_data, chat_id):
def _get_msg_ids(
self, msg_data: dict[str, Any], chat_id: int
) -> tuple[Any | None, int | None]:
"""Get the message id to edit.
This can be one of (message_id, inline_message_id) from a msg dict,
@ -270,7 +275,8 @@ class TelegramNotificationService:
**You can use 'last' as message_id** to edit
the message last sent in the chat_id.
"""
message_id = inline_message_id = None
message_id: Any | None = None
inline_message_id: int | None = None
if ATTR_MESSAGEID in msg_data:
message_id = msg_data[ATTR_MESSAGEID]
if (
@ -283,7 +289,7 @@ class TelegramNotificationService:
inline_message_id = msg_data["inline_message_id"]
return message_id, inline_message_id
def _get_target_chat_ids(self, target):
def _get_target_chat_ids(self, target: Any) -> list[int]:
"""Validate chat_id targets or return default target (first).
:param target: optional list of integers ([12234, -12345])
@ -302,10 +308,10 @@ class TelegramNotificationService:
)
return [default_user]
def _get_msg_kwargs(self, data):
def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]:
"""Get parameters in message data kwargs."""
def _make_row_inline_keyboard(row_keyboard):
def _make_row_inline_keyboard(row_keyboard: Any) -> list[InlineKeyboardButton]:
"""Make a list of InlineKeyboardButtons.
It can accept:
@ -350,7 +356,7 @@ class TelegramNotificationService:
return buttons
# Defaults
params = {
params: dict[str, Any] = {
ATTR_PARSER: self.parse_mode,
ATTR_DISABLE_NOTIF: False,
ATTR_DISABLE_WEB_PREV: None,
@ -399,8 +405,14 @@ class TelegramNotificationService:
return params
async def _send_msg(
self, func_send, msg_error, message_tag, *args_msg, context=None, **kwargs_msg
):
self,
func_send: Callable,
msg_error: str,
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
**kwargs_msg: Any,
) -> Any:
"""Send one message."""
try:
out = await func_send(*args_msg, **kwargs_msg)
@ -438,7 +450,13 @@ class TelegramNotificationService:
return None
return out
async def send_message(self, message="", target=None, context=None, **kwargs):
async def send_message(
self,
message: str = "",
target: Any = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> dict[int, int]:
"""Send a message to one or multiple pre-allowed chat IDs."""
title = kwargs.get(ATTR_TITLE)
text = f"{title}\n{message}" if title else message
@ -465,12 +483,17 @@ class TelegramNotificationService:
msg_ids[chat_id] = msg.id
return msg_ids
async def delete_message(self, chat_id=None, context=None, **kwargs):
async def delete_message(
self,
chat_id: int | None = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> bool:
"""Delete a previously sent message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
message_id, _ = self._get_msg_ids(kwargs, chat_id)
_LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id)
deleted = await self._send_msg(
deleted: bool = await self._send_msg(
self.bot.delete_message,
"Error deleting message",
None,
@ -484,7 +507,13 @@ class TelegramNotificationService:
self._last_message_id[chat_id] -= 1
return deleted
async def edit_message(self, type_edit, chat_id=None, context=None, **kwargs):
async def edit_message(
self,
type_edit: str,
chat_id: int | None = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> Any:
"""Edit a previously sent message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id)
@ -542,8 +571,13 @@ class TelegramNotificationService:
)
async def answer_callback_query(
self, message, callback_query_id, show_alert=False, context=None, **kwargs
):
self,
message: str | None,
callback_query_id: str,
show_alert: bool = False,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> None:
"""Answer a callback originated with a press in an inline keyboard."""
params = self._get_msg_kwargs(kwargs)
_LOGGER.debug(
@ -564,16 +598,20 @@ class TelegramNotificationService:
)
async def send_file(
self, file_type=SERVICE_SEND_PHOTO, target=None, context=None, **kwargs
):
self,
file_type: str,
target: Any = None,
context: Context | None = None,
**kwargs: Any,
) -> dict[int, int]:
"""Send a photo, sticker, video, or document."""
params = self._get_msg_kwargs(kwargs)
file_content = await load_data(
self.hass,
url=kwargs.get(ATTR_URL),
filepath=kwargs.get(ATTR_FILE),
username=kwargs.get(ATTR_USERNAME),
password=kwargs.get(ATTR_PASSWORD),
username=kwargs.get(ATTR_USERNAME, ""),
password=kwargs.get(ATTR_PASSWORD, ""),
authentication=kwargs.get(ATTR_AUTHENTICATION),
verify_ssl=(
get_default_context()
@ -690,7 +728,12 @@ class TelegramNotificationService:
return msg_ids
async def send_sticker(self, target=None, context=None, **kwargs) -> dict:
async def send_sticker(
self,
target: Any = None,
context: Context | None = None,
**kwargs: Any,
) -> dict[int, int]:
"""Send a sticker from a telegram sticker pack."""
params = self._get_msg_kwargs(kwargs)
stickerid = kwargs.get(ATTR_STICKER_ID)
@ -713,11 +756,16 @@ class TelegramNotificationService:
)
msg_ids[chat_id] = msg.id
return msg_ids
return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs)
return await self.send_file(SERVICE_SEND_STICKER, target, context, **kwargs)
async def send_location(
self, latitude, longitude, target=None, context=None, **kwargs
):
self,
latitude: Any,
longitude: Any,
target: Any = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> dict[int, int]:
"""Send a location."""
latitude = float(latitude)
longitude = float(longitude)
@ -745,14 +793,14 @@ class TelegramNotificationService:
async def send_poll(
self,
question,
options,
is_anonymous,
allows_multiple_answers,
target=None,
context=None,
**kwargs,
):
question: str,
options: Sequence[str | InputPollOption],
is_anonymous: bool | None,
allows_multiple_answers: bool | None,
target: Any = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> dict[int, int]:
"""Send a poll."""
params = self._get_msg_kwargs(kwargs)
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
@ -778,7 +826,12 @@ class TelegramNotificationService:
msg_ids[chat_id] = msg.id
return msg_ids
async def leave_chat(self, chat_id=None, context=None, **kwargs):
async def leave_chat(
self,
chat_id: Any = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> Any:
"""Remove bot from chat."""
chat_id = self._get_target_chat_ids(chat_id)[0]
_LOGGER.debug("Leave from chat ID %s", chat_id)
@ -792,7 +845,7 @@ class TelegramNotificationService:
reaction: str,
is_big: bool = False,
context: Context | None = None,
**kwargs,
**kwargs: dict[str, Any],
) -> None:
"""Set the bot's reaction for a given message."""
chat_id = self._get_target_chat_ids(chat_id)[0]
@ -878,19 +931,19 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) ->
async def load_data(
hass: HomeAssistant,
url=None,
filepath=None,
username=None,
password=None,
authentication=None,
num_retries=5,
verify_ssl=None,
):
url: str | None,
filepath: str | None,
username: str,
password: str,
authentication: str | None,
verify_ssl: SSLContext,
num_retries: int = 5,
) -> io.BytesIO:
"""Load data into ByteIO/File container from a source."""
if url is not None:
# Load data from URL
params: dict[str, Any] = {}
headers = {}
headers: dict[str, str] = {}
_validate_credentials_input(authentication, username, password)
if authentication == HTTP_BEARER_AUTHENTICATION:
headers = {"Authorization": f"Bearer {password}"}
@ -963,7 +1016,7 @@ def _validate_credentials_input(
) -> None:
if (
authentication in (HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
and username is None
and not username
):
raise ServiceValidationError(
"Username is required.",
@ -979,7 +1032,7 @@ def _validate_credentials_input(
HTTP_BEARER_AUTHENTICATION,
HTTP_BEARER_AUTHENTICATION,
)
and password is None
and not password
):
raise ServiceValidationError(
"Password is required.",

View File

@ -64,16 +64,18 @@ class PollBot(BaseTelegramBot):
"""Shutdown the app."""
await self.stop_polling()
async def start_polling(self, event=None):
async def start_polling(self) -> None:
"""Start the polling task."""
_LOGGER.debug("Starting polling")
await self.application.initialize()
if self.application.updater:
await self.application.updater.start_polling(error_callback=error_callback)
await self.application.start()
async def stop_polling(self, event=None):
async def stop_polling(self) -> None:
"""Stop the polling task."""
_LOGGER.debug("Stopping polling")
if self.application.updater:
await self.application.updater.stop()
await self.application.stop()
await self.application.shutdown()

View File

@ -6,11 +6,12 @@ import logging
import secrets
import string
from aiohttp.web_response import Response
from telegram import Bot, Update
from telegram.error import NetworkError, TelegramError
from telegram.ext import ApplicationBuilder, TypeHandler
from telegram.ext import Application, ApplicationBuilder, TypeHandler
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http import HomeAssistantRequest, HomeAssistantView
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@ -87,7 +88,7 @@ class PushBot(BaseTelegramBot):
"""Shutdown the app."""
await self.stop_application()
async def _try_to_set_webhook(self):
async def _try_to_set_webhook(self) -> bool:
_LOGGER.debug("Registering webhook URL: %s", self.webhook_url)
retry_num = 0
while retry_num < 3:
@ -103,12 +104,12 @@ class PushBot(BaseTelegramBot):
return False
async def start_application(self):
async def start_application(self) -> None:
"""Handle starting the Application object."""
await self.application.initialize()
await self.application.start()
async def register_webhook(self):
async def register_webhook(self) -> bool:
"""Query telegram and register the URL for our webhook."""
current_status = await self.bot.get_webhook_info()
# Some logging of Bot current status:
@ -123,13 +124,13 @@ class PushBot(BaseTelegramBot):
return True
async def stop_application(self, event=None):
async def stop_application(self) -> None:
"""Handle gracefully stopping the Application object."""
await self.deregister_webhook()
await self.application.stop()
await self.application.shutdown()
async def deregister_webhook(self):
async def deregister_webhook(self) -> None:
"""Query telegram and deregister the URL for our webhook."""
_LOGGER.debug("Deregistering webhook URL")
try:
@ -149,7 +150,7 @@ class PushBotView(HomeAssistantView):
self,
hass: HomeAssistant,
bot: Bot,
application,
application: Application,
trusted_networks: list[IPv4Network],
secret_token: str,
) -> None:
@ -160,15 +161,16 @@ class PushBotView(HomeAssistantView):
self.trusted_networks = trusted_networks
self.secret_token = secret_token
async def post(self, request):
async def post(self, request: HomeAssistantRequest) -> Response | None:
"""Accept the POST from telegram."""
real_ip = ip_address(request.remote)
if not any(real_ip in net for net in self.trusted_networks):
_LOGGER.warning("Access denied from %s", real_ip)
if not request.remote or not any(
ip_address(request.remote) in net for net in self.trusted_networks
):
_LOGGER.warning("Access denied from %s", request.remote)
return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED)
secret_token_header = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if secret_token_header is None or self.secret_token != secret_token_header:
_LOGGER.warning("Invalid secret token from %s", real_ip)
_LOGGER.warning("Invalid secret token from %s", request.remote)
return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED)
try:

View File

@ -158,8 +158,6 @@ CONFIG_SECTION_SCHEMA = vol.All(
),
ensure_domains_do_not_have_trigger_or_action(
DOMAIN_BUTTON,
DOMAIN_FAN,
DOMAIN_VACUUM,
),
)

View File

@ -15,6 +15,7 @@ from homeassistant.components.fan import (
ATTR_PRESET_MODE,
DIRECTION_FORWARD,
DIRECTION_REVERSE,
DOMAIN as FAN_DOMAIN,
ENTITY_ID_FORMAT,
FanEntity,
FanEntityFeature,
@ -38,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
@ -46,6 +48,7 @@ from .template_entity import (
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@ -193,6 +196,13 @@ async def async_setup_platform(
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerFanEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
hass,
@ -228,7 +238,11 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES)
self._attr_assumed_state = self._template is None
def _register_scripts(
self._attr_supported_features |= (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
def _iterate_scripts(
self, config: dict[str, Any]
) -> Generator[tuple[str, Sequence[dict[str, Any]], FanEntityFeature | int]]:
for action_id, supported_feature in (
@ -492,10 +506,7 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan):
if TYPE_CHECKING:
assert name is not None
self._attr_supported_features |= (
FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
)
for action_id, action_config, supported_feature in self._register_scripts(
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
@ -551,3 +562,67 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan):
none_on_template_error=True,
)
super()._async_setup_templates()
class TriggerFanEntity(TriggerEntity, AbstractTemplateFan):
"""Fan entity based on trigger data."""
domain = FAN_DOMAIN
def __init__(
self,
hass: HomeAssistant,
coordinator: TriggerUpdateCoordinator,
config: ConfigType,
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateFan.__init__(self, config)
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
for key in (
CONF_STATE,
CONF_PRESET_MODE,
CONF_PERCENTAGE,
CONF_OSCILLATING,
CONF_DIRECTION,
):
if isinstance(config.get(key), template.Template):
self._to_render_simple.append(key)
self._parse_result.add(key)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle update of the data."""
self._process_data()
if not self.available:
self.async_write_ha_state()
return
write_ha_state = False
for key, updater in (
(CONF_STATE, self._handle_state),
(CONF_PRESET_MODE, self._update_preset_mode),
(CONF_PERCENTAGE, self._update_percentage),
(CONF_OSCILLATING, self._update_oscillating),
(CONF_DIRECTION, self._update_direction),
):
if (rendered := self._rendered.get(key)) is not None:
updater(rendered)
write_ha_state = True
if len(self._rendered) > 0:
# In case any non optimistic template
write_ha_state = True
if write_ha_state:
self.async_set_context(self.coordinator.data["context"])
self.async_write_ha_state()

View File

@ -39,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
@ -48,6 +49,7 @@ from .template_entity import (
make_template_entity_common_modern_attributes_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@ -187,6 +189,13 @@ async def async_setup_platform(
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerVacuumEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
hass,
@ -213,7 +222,14 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
# List of valid fan speeds
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
def _register_scripts(
self._attr_supported_features = (
VacuumEntityFeature.START | VacuumEntityFeature.STATE
)
if self._battery_level_template:
self._attr_supported_features |= VacuumEntityFeature.BATTERY
def _iterate_scripts(
self, config: dict[str, Any]
) -> Generator[tuple[str, Sequence[dict[str, Any]], VacuumEntityFeature | int]]:
for action_id, supported_feature in (
@ -356,18 +372,12 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum):
if TYPE_CHECKING:
assert name is not None
self._attr_supported_features = (
VacuumEntityFeature.START | VacuumEntityFeature.STATE
)
for action_id, action_config, supported_feature in self._register_scripts(
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
if self._battery_level_template:
self._attr_supported_features |= VacuumEntityFeature.BATTERY
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
@ -403,3 +413,59 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum):
return
self._handle_state(result)
class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum):
"""Vacuum entity based on trigger data."""
domain = VACUUM_DOMAIN
def __init__(
self,
hass: HomeAssistant,
coordinator: TriggerUpdateCoordinator,
config: ConfigType,
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateVacuum.__init__(self, config)
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
for key in (CONF_STATE, CONF_FAN_SPEED, CONF_BATTERY_LEVEL):
if isinstance(config.get(key), template.Template):
self._to_render_simple.append(key)
self._parse_result.add(key)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle update of the data."""
self._process_data()
if not self.available:
self.async_write_ha_state()
return
write_ha_state = False
for key, updater in (
(CONF_STATE, self._handle_state),
(CONF_FAN_SPEED, self._update_fan_speed),
(CONF_BATTERY_LEVEL, self._update_battery_level),
):
if (rendered := self._rendered.get(key)) is not None:
updater(rendered)
write_ha_state = True
if len(self._rendered) > 0:
# In case any non optimistic template
write_ha_state = True
if write_ha_state:
self.async_set_context(self.coordinator.data["context"])
self.async_write_ha_state()

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