mirror of
https://github.com/home-assistant/core.git
synced 2025-07-30 02:38:10 +02:00
Merge branch 'dev' into sub_devices_esphome
This commit is contained in:
@ -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
4
CODEOWNERS
generated
@ -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
|
||||
|
@ -1,5 +1,11 @@
|
||||
{
|
||||
"domain": "sony",
|
||||
"name": "Sony",
|
||||
"integrations": ["braviatv", "ps4", "sony_projector", "songpal"]
|
||||
"integrations": [
|
||||
"braviatv",
|
||||
"ps4",
|
||||
"sony_projector",
|
||||
"songpal",
|
||||
"playstation_network"
|
||||
]
|
||||
}
|
||||
|
@ -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]:
|
||||
|
@ -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",
|
||||
]
|
||||
|
||||
|
@ -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."
|
||||
|
@ -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,
|
||||
|
@ -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": {
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""The foscam component."""
|
||||
|
||||
from libpyfoscam import FoscamCamera
|
||||
from libpyfoscamcgi import FoscamCamera
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -1,6 +1,3 @@
|
||||
"""Constants used by the JuiceNet component."""
|
||||
|
||||
DOMAIN = "juicenet"
|
||||
|
||||
JUICENET_API = "juicenet_api"
|
||||
JUICENET_COORDINATOR = "juicenet_coordinator"
|
||||
|
@ -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)
|
@ -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
|
@ -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,
|
||||
)
|
@ -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": []
|
||||
}
|
||||
|
@ -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)
|
@ -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)
|
@ -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})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
@ -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
|
||||
|
||||
interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job(
|
||||
router.client.get_interfaces
|
||||
)
|
||||
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(
|
||||
CONF_INTERFACES, [DEFAULT_INTERFACE]
|
||||
),
|
||||
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,
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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: |
|
||||
|
@ -13,6 +13,7 @@ PLATFORMS = [
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
@ -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,
|
||||
|
46
homeassistant/components/lametric/update.py
Normal file
46
homeassistant/components/lametric/update.py
Normal 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
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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)]
|
||||
)
|
||||
|
@ -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}
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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 = []
|
||||
|
@ -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] = []
|
||||
|
||||
|
@ -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)])
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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),
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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__(
|
||||
|
@ -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],
|
||||
*,
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
||||
return (
|
||||
PlatePowerStep(
|
||||
cast(
|
||||
int,
|
||||
self.device.state_plate_step[
|
||||
self.entity_description.zone - 1
|
||||
].value_raw,
|
||||
)
|
||||
).name
|
||||
if self.device.state_plate_step
|
||||
else 0
|
||||
else PlatePowerStep.plate_step_0
|
||||
)
|
||||
return str(plate_power)
|
||||
|
||||
|
||||
class MieleStatusSensor(MieleSensor):
|
||||
|
@ -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": {
|
||||
|
@ -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."""
|
||||
|
53
homeassistant/components/music_assistant/button.py
Normal file
53
homeassistant/components/music_assistant/button.py
Normal 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)
|
28
homeassistant/components/music_assistant/helpers.py
Normal file
28
homeassistant/components/music_assistant/helpers.py
Normal 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
|
@ -1,4 +1,11 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"favorite_now_playing": {
|
||||
"default": "mdi:heart-plus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"play_media": { "service": "mdi:play" },
|
||||
"play_announcement": { "service": "mdi:bullhorn" },
|
||||
|
@ -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()
|
||||
|
@ -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",
|
||||
|
@ -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)
|
||||
|
||||
|
74
homeassistant/components/ntfy/coordinator.py
Normal file
74
homeassistant/components/ntfy/coordinator.py
Normal 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
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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."""
|
||||
|
272
homeassistant/components/ntfy/sensor.py
Normal file
272
homeassistant/components/ntfy/sensor.py
Normal 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)
|
@ -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}"
|
||||
|
34
homeassistant/components/playstation_network/__init__.py
Normal file
34
homeassistant/components/playstation_network/__init__.py
Normal 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)
|
70
homeassistant/components/playstation_network/config_flow.py
Normal file
70
homeassistant/components/playstation_network/config_flow.py
Normal 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",
|
||||
},
|
||||
)
|
15
homeassistant/components/playstation_network/const.py
Normal file
15
homeassistant/components/playstation_network/const.py
Normal 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,
|
||||
}
|
69
homeassistant/components/playstation_network/coordinator.py
Normal file
69
homeassistant/components/playstation_network/coordinator.py
Normal 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
|
151
homeassistant/components/playstation_network/helpers.py
Normal file
151
homeassistant/components/playstation_network/helpers.py
Normal 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
|
9
homeassistant/components/playstation_network/icons.json
Normal file
9
homeassistant/components/playstation_network/icons.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"media_player": {
|
||||
"playstation": {
|
||||
"default": "mdi:sony-playstation"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
homeassistant/components/playstation_network/manifest.json
Normal file
11
homeassistant/components/playstation_network/manifest.json
Normal 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"]
|
||||
}
|
128
homeassistant/components/playstation_network/media_player.py
Normal file
128
homeassistant/components/playstation_network/media_player.py
Normal 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
|
@ -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
|
32
homeassistant/components/playstation_network/strings.json
Normal file
32
homeassistant/components/playstation_network/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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,12 +120,15 @@ 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:
|
||||
self.register_callback(callback_id, cmd_id)
|
||||
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)
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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": {
|
||||
|
@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiotedee"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiotedee==0.2.23"]
|
||||
"requirements": ["aiotedee==0.2.25"]
|
||||
}
|
||||
|
@ -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.",
|
||||
|
@ -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()
|
||||
await self.application.updater.start_polling(error_callback=error_callback)
|
||||
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")
|
||||
await self.application.updater.stop()
|
||||
if self.application.updater:
|
||||
await self.application.updater.stop()
|
||||
await self.application.stop()
|
||||
await self.application.shutdown()
|
||||
|
@ -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:
|
||||
|
@ -158,8 +158,6 @@ CONFIG_SECTION_SCHEMA = vol.All(
|
||||
),
|
||||
ensure_domains_do_not_have_trigger_or_action(
|
||||
DOMAIN_BUTTON,
|
||||
DOMAIN_FAN,
|
||||
DOMAIN_VACUUM,
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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
Reference in New Issue
Block a user