mirror of
https://github.com/home-assistant/core.git
synced 2025-11-20 00:10:13 +00:00
Compare commits
145 Commits
ma_identif
...
mqtt-entit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e4676b4c | ||
|
|
f852220282 | ||
|
|
5dd3bf04eb | ||
|
|
b0c2fdc57b | ||
|
|
617d44ffcf | ||
|
|
8fb8eed1c8 | ||
|
|
1ddbd4755b | ||
|
|
3bd76294dc | ||
|
|
bb97822db9 | ||
|
|
33ffccabd1 | ||
|
|
56de03ce33 | ||
|
|
0cbf7002a8 | ||
|
|
cffceffe04 | ||
|
|
253189805e | ||
|
|
2e91725ac0 | ||
|
|
3b54dddc08 | ||
|
|
9bc3d83a55 | ||
|
|
d62a554cbf | ||
|
|
f071b7cd46 | ||
|
|
37f34f6189 | ||
|
|
27dc5b6d18 | ||
|
|
0bbc2f49a6 | ||
|
|
c121fa25e8 | ||
|
|
660cea8b65 | ||
|
|
c7749ebae1 | ||
|
|
a2acb744b3 | ||
|
|
0d9158689d | ||
|
|
f85e8d6c1f | ||
|
|
9be4cc5af1 | ||
|
|
a141eedf2c | ||
|
|
03040c131c | ||
|
|
3eef50632c | ||
|
|
eff150cd54 | ||
|
|
6dcc94b0a1 | ||
|
|
7201903877 | ||
|
|
5b776307ea | ||
|
|
3cb414511b | ||
|
|
f55c36d42d | ||
|
|
26bb301cc0 | ||
|
|
4159e483ee | ||
|
|
7eb6f7cc07 | ||
|
|
a7d01b0b03 | ||
|
|
1e5cfddf83 | ||
|
|
006fc5b10a | ||
|
|
35a4b685b3 | ||
|
|
b166818ef4 | ||
|
|
34cd9f11d0 | ||
|
|
0711d62085 | ||
|
|
f70aeafb5f | ||
|
|
e2279b3589 | ||
|
|
87b68e99ec | ||
|
|
b6c8b787e8 | ||
|
|
78f26edc29 | ||
|
|
5e6a72de90 | ||
|
|
dcc559f8b6 | ||
|
|
eda49cced0 | ||
|
|
14e41ab119 | ||
|
|
46151456d8 | ||
|
|
39773a022a | ||
|
|
5f49a6450f | ||
|
|
dc8425c580 | ||
|
|
910bd371e4 | ||
|
|
802a225e11 | ||
|
|
84f66fa689 | ||
|
|
0b7e88d0e0 | ||
|
|
1fcaf95df5 | ||
|
|
6c7434531f | ||
|
|
5ec1c2b68b | ||
|
|
d8636d8346 | ||
|
|
434763c74d | ||
|
|
8cd2c1b43b | ||
|
|
44711787a4 | ||
|
|
98fd0ee683 | ||
|
|
303e4ce961 | ||
|
|
76f29298cd | ||
|
|
17f5d0a69f | ||
|
|
90561de438 | ||
|
|
aedd48c298 | ||
|
|
febbb85532 | ||
|
|
af67a35b75 | ||
|
|
dd34d458f5 | ||
|
|
603d4bcf87 | ||
|
|
2dadc1f2b3 | ||
|
|
936151fae5 | ||
|
|
9760eb7f2b | ||
|
|
7851bed00c | ||
|
|
6aba0b20c6 | ||
|
|
cadfed2348 | ||
|
|
44e2fa6996 | ||
|
|
d0ff617e17 | ||
|
|
8e499569a4 | ||
|
|
5e0ebddd6f | ||
|
|
c0f61f6c2b | ||
|
|
df60de38b0 | ||
|
|
cb086bb8e9 | ||
|
|
ee2e9dc7d6 | ||
|
|
85cd3c68b7 | ||
|
|
1b0b6e63f2 | ||
|
|
12fc79e8d3 | ||
|
|
ca2e7b9509 | ||
|
|
8e8becc43e | ||
|
|
dcec6c3dc8 | ||
|
|
c0e59c4508 | ||
|
|
cd379aadbf | ||
|
|
ccdd54b187 | ||
|
|
3f22dbaa2e | ||
|
|
c18dc0a9ab | ||
|
|
f0e4296d93 | ||
|
|
b3750109c6 | ||
|
|
93025c9845 | ||
|
|
df348644b1 | ||
|
|
8749b0d750 | ||
|
|
a6a1519c06 | ||
|
|
3068e19843 | ||
|
|
55feb1e735 | ||
|
|
bb7dc69131 | ||
|
|
aa9003a524 | ||
|
|
4e9da5249d | ||
|
|
f502739df2 | ||
|
|
0f2ff29378 | ||
|
|
2921e7ed3c | ||
|
|
25d44e8d37 | ||
|
|
0a480a26a3 | ||
|
|
d5da64dd8d | ||
|
|
92adcd8635 | ||
|
|
ee0c4b15c2 | ||
|
|
507f54198e | ||
|
|
0ed342b433 | ||
|
|
363c86faf3 | ||
|
|
095a7ad060 | ||
|
|
ab5981bbbd | ||
|
|
ac2fb53dfd | ||
|
|
02ff5de1ff | ||
|
|
5cd5d480d9 | ||
|
|
a3c7d772fc | ||
|
|
fe0c69dba7 | ||
|
|
e5365234c3 | ||
|
|
1531175bd3 | ||
|
|
62add59ff4 | ||
|
|
d8daca657b | ||
|
|
1891da46ea | ||
|
|
22ae894745 | ||
|
|
160810c69d | ||
|
|
2ae23b920a | ||
|
|
a7edfb082f |
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 1
|
||||
CACHE_VERSION: 2
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.12"
|
||||
@@ -622,7 +622,7 @@ jobs:
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
license-check: false # We use our own license audit checks
|
||||
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/msteams/ @peroyvind
|
||||
/homeassistant/components/mullvad/ @meichthys
|
||||
/tests/components/mullvad/ @meichthys
|
||||
/homeassistant/components/music_assistant/ @music-assistant
|
||||
/tests/components/music_assistant/ @music-assistant
|
||||
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
|
||||
/tests/components/music_assistant/ @music-assistant @arturpragacz
|
||||
/homeassistant/components/mutesync/ @currentoor
|
||||
/tests/components/mutesync/ @currentoor
|
||||
/homeassistant/components/my/ @home-assistant/core
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from hassil.recognize import RecognizeResult
|
||||
import voluptuous as vol
|
||||
@@ -21,6 +21,7 @@ from homeassistant.core import (
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
@@ -52,6 +53,8 @@ from .const import (
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
HOME_ASSISTANT_AGENT,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
SERVICE_PROCESS,
|
||||
SERVICE_RELOAD,
|
||||
ConversationEntityFeature,
|
||||
@@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
|
||||
hass.data[DATA_COMPONENT] = entity_component
|
||||
|
||||
agent_config = config.get(DOMAIN, {})
|
||||
await async_setup_default_agent(
|
||||
hass, entity_component, config_intents=agent_config.get("intents", {})
|
||||
)
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
hass_config_path = hass.config.path()
|
||||
config_intents = _get_config_intents(config, hass_config_path)
|
||||
manager.update_config_intents(config_intents)
|
||||
|
||||
await async_setup_default_agent(hass, entity_component)
|
||||
|
||||
async def handle_process(service: ServiceCall) -> ServiceResponse:
|
||||
"""Parse text into commands."""
|
||||
@@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def handle_reload(service: ServiceCall) -> None:
|
||||
"""Reload intents."""
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
language = service.data.get(ATTR_LANGUAGE)
|
||||
if language is None:
|
||||
conf = await async_integration_yaml_config(hass, DOMAIN)
|
||||
if conf is not None:
|
||||
config_intents = _get_config_intents(conf, hass_config_path)
|
||||
manager.update_config_intents(config_intents)
|
||||
|
||||
agent = manager.default_agent
|
||||
if agent is not None:
|
||||
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
|
||||
await agent.async_reload(language=language)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
@@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]:
|
||||
"""Return config intents."""
|
||||
intents = config.get(DOMAIN, {}).get("intents", {})
|
||||
return {
|
||||
"intents": {
|
||||
intent_name: {
|
||||
"data": [
|
||||
{
|
||||
"sentences": sentences,
|
||||
"metadata": {
|
||||
METADATA_CUSTOM_SENTENCE: True,
|
||||
METADATA_CUSTOM_FILE: hass_config_path,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
for intent_name, sentences in intents.items()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
@@ -147,6 +147,7 @@ class AgentManager:
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
self.config_intents: dict[str, Any] = {}
|
||||
self.triggers_details: list[TriggerDetails] = []
|
||||
|
||||
@callback
|
||||
@@ -199,9 +200,16 @@ class AgentManager:
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
agent.update_config_intents(self.config_intents)
|
||||
agent.update_triggers(self.triggers_details)
|
||||
self.default_agent = agent
|
||||
|
||||
def update_config_intents(self, intents: dict[str, Any]) -> None:
|
||||
"""Update config intents."""
|
||||
self.config_intents = intents
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_config_intents(intents)
|
||||
|
||||
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||
"""Register a trigger."""
|
||||
self.triggers_details.append(trigger_details)
|
||||
|
||||
@@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag):
|
||||
"""Supported features of the conversation entity."""
|
||||
|
||||
CONTROL = 1
|
||||
|
||||
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
|
||||
@@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent_manager import get_agent_manager
|
||||
from .chat_log import AssistantContent, ChatLog
|
||||
from .const import DOMAIN, ConversationEntityFeature
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
ConversationEntityFeature,
|
||||
)
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
@@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
||||
|
||||
ERROR_SENTINEL = object()
|
||||
@@ -202,10 +205,9 @@ class IntentCache:
|
||||
async def async_setup_default_agent(
|
||||
hass: HomeAssistant,
|
||||
entity_component: EntityComponent[ConversationEntity],
|
||||
config_intents: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set up entity registry listener for the default agent."""
|
||||
agent = DefaultAgent(hass, config_intents)
|
||||
agent = DefaultAgent(hass)
|
||||
await entity_component.async_add_entities([agent])
|
||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||
|
||||
@@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity):
|
||||
_attr_name = "Home Assistant"
|
||||
_attr_supported_features = ConversationEntityFeature.CONTROL
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# intent -> [sentences]
|
||||
self._config_intents: dict[str, Any] = config_intents
|
||||
# Intents from common conversation config
|
||||
self._config_intents: dict[str, Any] = {}
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self._triggers_details: list[TriggerDetails] = []
|
||||
@@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity):
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
@callback
|
||||
def update_config_intents(self, intents: dict[str, Any]) -> None:
|
||||
"""Update config intents."""
|
||||
self._config_intents = intents
|
||||
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
async def async_prepare(self, language: str | None = None) -> None:
|
||||
"""Load intents for a language."""
|
||||
if language is None:
|
||||
@@ -1159,33 +1169,10 @@ class DefaultAgent(ConversationEntity):
|
||||
custom_sentences_path,
|
||||
)
|
||||
|
||||
# Load sentences from HA config for default language only
|
||||
if self._config_intents and (
|
||||
self.hass.config.language in (language, language_variant)
|
||||
):
|
||||
hass_config_path = self.hass.config.path()
|
||||
merge_dict(
|
||||
intents_dict,
|
||||
{
|
||||
"intents": {
|
||||
intent_name: {
|
||||
"data": [
|
||||
{
|
||||
"sentences": sentences,
|
||||
"metadata": {
|
||||
METADATA_CUSTOM_SENTENCE: True,
|
||||
METADATA_CUSTOM_FILE: hass_config_path,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
for intent_name, sentences in self._config_intents.items()
|
||||
}
|
||||
},
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Loaded intents from configuration.yaml",
|
||||
)
|
||||
merge_dict(
|
||||
intents_dict,
|
||||
self._config_intents,
|
||||
)
|
||||
|
||||
if not intents_dict:
|
||||
return None
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyecobee"],
|
||||
"requirements": ["python-ecobee-api==0.2.20"],
|
||||
"requirements": ["python-ecobee-api==0.3.2"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections import Counter
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Literal, TypedDict
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
|
||||
class FlowFromGridSourceType(TypedDict):
|
||||
"""Dictionary describing the 'from' stat for the grid source."""
|
||||
|
||||
# statistic_id of a an energy meter (kWh)
|
||||
# statistic_id of an energy meter (kWh)
|
||||
stat_energy_from: str
|
||||
|
||||
# statistic_id of costs ($) incurred from the energy meter
|
||||
@@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict):
|
||||
number_energy_price: float | None # Price for energy ($/kWh)
|
||||
|
||||
|
||||
class GridPowerSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid power consumption."""
|
||||
|
||||
# statistic_id of a power meter (kW)
|
||||
# negative values indicate grid return
|
||||
stat_rate: str
|
||||
|
||||
|
||||
class GridSourceType(TypedDict):
|
||||
"""Dictionary holding the source of grid energy consumption."""
|
||||
|
||||
@@ -65,6 +73,7 @@ class GridSourceType(TypedDict):
|
||||
|
||||
flow_from: list[FlowFromGridSourceType]
|
||||
flow_to: list[FlowToGridSourceType]
|
||||
power: NotRequired[list[GridPowerSourceType]]
|
||||
|
||||
cost_adjustment_day: float
|
||||
|
||||
@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
|
||||
type: Literal["solar"]
|
||||
|
||||
stat_energy_from: str
|
||||
stat_rate: NotRequired[str]
|
||||
config_entry_solar_forecast: list[str] | None
|
||||
|
||||
|
||||
@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
|
||||
|
||||
stat_energy_from: str
|
||||
stat_energy_to: str
|
||||
# positive when discharging, negative when charging
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
|
||||
class GasSourceType(TypedDict):
|
||||
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
|
||||
# This is an ever increasing value
|
||||
stat_consumption: str
|
||||
|
||||
# Instantaneous rate of flow: W, L/min or m³/h
|
||||
stat_rate: NotRequired[str]
|
||||
|
||||
# An optional custom name for display in energy graphs
|
||||
name: str | None
|
||||
|
||||
# An optional statistic_id identifying a device
|
||||
# that includes this device's consumption in its total
|
||||
included_in_stat: str | None
|
||||
included_in_stat: NotRequired[str]
|
||||
|
||||
|
||||
class EnergyPreferences(TypedDict):
|
||||
@@ -194,6 +209,12 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_rate"): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
|
||||
"""Generate a validator that ensures a value is only used once."""
|
||||
@@ -224,6 +245,10 @@ GRID_SOURCE_SCHEMA = vol.Schema(
|
||||
[FLOW_TO_GRID_SOURCE_SCHEMA],
|
||||
_generate_unique_value_validator("stat_energy_to"),
|
||||
),
|
||||
vol.Optional("power"): vol.All(
|
||||
[GRID_POWER_SOURCE_SCHEMA],
|
||||
_generate_unique_value_validator("stat_rate"),
|
||||
),
|
||||
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
@@ -231,6 +256,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("type"): "solar",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
|
||||
}
|
||||
)
|
||||
@@ -239,6 +265,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
||||
vol.Required("type"): "battery",
|
||||
vol.Required("stat_energy_from"): str,
|
||||
vol.Required("stat_energy_to"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
}
|
||||
)
|
||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||
@@ -294,6 +321,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
|
||||
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("stat_consumption"): str,
|
||||
vol.Optional("stat_rate"): str,
|
||||
vol.Optional("name"): str,
|
||||
vol.Optional("included_in_stat"): str,
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.const import (
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||
@@ -23,12 +24,17 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
|
||||
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
||||
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
|
||||
}
|
||||
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
|
||||
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
|
||||
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
|
||||
}
|
||||
|
||||
ENERGY_PRICE_UNITS = tuple(
|
||||
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
||||
)
|
||||
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
||||
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
|
||||
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
|
||||
GAS_USAGE_DEVICE_CLASSES = (
|
||||
sensor.SensorDeviceClass.ENERGY,
|
||||
sensor.SensorDeviceClass.GAS,
|
||||
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
|
||||
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
|
||||
),
|
||||
}
|
||||
if issue_type == POWER_UNIT_ERROR:
|
||||
return {
|
||||
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
|
||||
}
|
||||
if issue_type == GAS_UNIT_ERROR:
|
||||
return {
|
||||
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
|
||||
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_usage_stat(
|
||||
def _async_validate_stat_common(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
) -> None:
|
||||
"""Validate a statistic."""
|
||||
check_negative: bool = False,
|
||||
) -> str | None:
|
||||
"""Validate common aspects of a statistic.
|
||||
|
||||
Returns the entity_id if validation succeeds, None otherwise.
|
||||
"""
|
||||
if stat_id not in metadata:
|
||||
issues.add_issue(hass, "statistics_not_defined", stat_id)
|
||||
|
||||
has_entity_source = valid_entity_id(stat_id)
|
||||
|
||||
if not has_entity_source:
|
||||
return
|
||||
return None
|
||||
|
||||
entity_id = stat_id
|
||||
|
||||
if not recorder.is_entity_recorded(hass, entity_id):
|
||||
issues.add_issue(hass, "recorder_untracked", entity_id)
|
||||
return
|
||||
return None
|
||||
|
||||
if (state := hass.states.get(entity_id)) is None:
|
||||
issues.add_issue(hass, "entity_not_defined", entity_id)
|
||||
return
|
||||
return None
|
||||
|
||||
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
|
||||
return
|
||||
return None
|
||||
|
||||
try:
|
||||
current_value: float | None = float(state.state)
|
||||
except ValueError:
|
||||
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
|
||||
return
|
||||
return None
|
||||
|
||||
if current_value is not None and current_value < 0:
|
||||
if check_negative and current_value is not None and current_value < 0:
|
||||
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
|
||||
|
||||
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
|
||||
if device_class and unit not in allowed_units.get(device_class, []):
|
||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||
|
||||
return entity_id
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_usage_stat(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
allowed_device_classes: Sequence[str],
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
) -> None:
|
||||
"""Validate a statistic."""
|
||||
entity_id = _async_validate_stat_common(
|
||||
hass,
|
||||
metadata,
|
||||
stat_id,
|
||||
allowed_device_classes,
|
||||
allowed_units,
|
||||
unit_error,
|
||||
issues,
|
||||
check_negative=True,
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||
|
||||
allowed_state_classes = [
|
||||
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
|
||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_power_stat(
|
||||
hass: HomeAssistant,
|
||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||
stat_id: str,
|
||||
allowed_device_classes: Sequence[str],
|
||||
allowed_units: Mapping[str, Sequence[str]],
|
||||
unit_error: str,
|
||||
issues: ValidationIssues,
|
||||
) -> None:
|
||||
"""Validate a power statistic."""
|
||||
entity_id = _async_validate_stat_common(
|
||||
hass,
|
||||
metadata,
|
||||
stat_id,
|
||||
allowed_device_classes,
|
||||
allowed_units,
|
||||
unit_error,
|
||||
issues,
|
||||
check_negative=False,
|
||||
)
|
||||
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||
|
||||
if state_class != sensor.SensorStateClass.MEASUREMENT:
|
||||
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
|
||||
|
||||
|
||||
@callback
|
||||
def _async_validate_cost_stat(
|
||||
hass: HomeAssistant,
|
||||
@@ -434,6 +511,21 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
||||
)
|
||||
)
|
||||
|
||||
for power_stat in source.get("power", []):
|
||||
wanted_statistics_metadata.add(power_stat["stat_rate"])
|
||||
validate_calls.append(
|
||||
functools.partial(
|
||||
_async_validate_power_stat,
|
||||
hass,
|
||||
statistics_metadata,
|
||||
power_stat["stat_rate"],
|
||||
POWER_USAGE_DEVICE_CLASSES,
|
||||
POWER_USAGE_UNITS,
|
||||
POWER_UNIT_ERROR,
|
||||
source_result,
|
||||
)
|
||||
)
|
||||
|
||||
elif source["type"] == "gas":
|
||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||
validate_calls.append(
|
||||
|
||||
@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
sidebar_title="climate",
|
||||
sidebar_default_visible=False,
|
||||
)
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
"home",
|
||||
sidebar_icon="mdi:home",
|
||||
sidebar_title="home",
|
||||
sidebar_default_visible=False,
|
||||
)
|
||||
|
||||
async_register_built_in_panel(hass, "profile")
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
},
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["apyhiveapi"],
|
||||
"requirements": ["pyhive-integration==1.0.6"]
|
||||
"requirements": ["pyhive-integration==1.0.7"]
|
||||
}
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.23.0"],
|
||||
"requirements": ["aiohomeconnect==0.23.1"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -412,8 +412,8 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
"""Set the program value."""
|
||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||
self._attr_current_option = (
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
|
||||
if event
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP.get(ProgramKey(event_value))
|
||||
if event and isinstance(event_value := event.value, str)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
||||
self._update_native_value(status)
|
||||
|
||||
def _update_native_value(self, status: str | float) -> None:
|
||||
def _update_native_value(self, status: str | float | None) -> None:
|
||||
"""Set the value of the sensor based on the given value."""
|
||||
if status is None:
|
||||
self._attr_native_value = None
|
||||
return
|
||||
match self.device_class:
|
||||
case SensorDeviceClass.TIMESTAMP:
|
||||
self._attr_native_value = dt_util.utcnow() + timedelta(
|
||||
|
||||
@@ -1237,7 +1237,7 @@
|
||||
"message": "Error obtaining data from the API: {error}"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation temporarily unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"pause_program": {
|
||||
"message": "Error pausing program: {error}"
|
||||
|
||||
@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
||||
|
||||
context: ConfigFlowContext
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
|
||||
|
||||
ZIGBEE_BAUDRATE = 460800
|
||||
|
||||
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
|
||||
# baudrate method. Since the two are mutually exclusive we just use both.
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||
APPLICATION_PROBE_METHODS = [
|
||||
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||
(ApplicationType.SPINEL, 460800),
|
||||
]
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeAssistantConnectZBT2ConfigEntry
|
||||
from .config_flow import ZBT2FirmwareMixin
|
||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -134,7 +134,8 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Connect ZBT-2 firmware update entity."""
|
||||
|
||||
bootloader_reset_methods = [ResetTarget.RTS_DTR]
|
||||
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
||||
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
|
||||
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||
@@ -230,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
)
|
||||
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||
@@ -295,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||
"integration_type": "system",
|
||||
"requirements": [
|
||||
"universal-silabs-flasher==0.0.37",
|
||||
"universal-silabs-flasher==0.1.0",
|
||||
"ha-silabs-firmware-client==0.3.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
|
||||
|
||||
# Subclasses provide the mapping between firmware types and entity descriptions
|
||||
entity_description: FirmwareUpdateEntityDescription
|
||||
bootloader_reset_methods: list[ResetTarget] = []
|
||||
BOOTLOADER_RESET_METHODS: list[ResetTarget]
|
||||
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
|
||||
|
||||
_attr_supported_features = (
|
||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
|
||||
device=self._current_device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||
bootloader_reset_methods=self.bootloader_reset_methods,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
progress_callback=self._update_progress,
|
||||
domain=self._config_entry.domain,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
|
||||
|
||||
|
||||
async def probe_silabs_firmware_info(
|
||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
||||
device: str,
|
||||
*,
|
||||
bootloader_reset_methods: Sequence[ResetTarget],
|
||||
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||
) -> FirmwareInfo | None:
|
||||
"""Probe the running firmware on a SiLabs device."""
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
**(
|
||||
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
|
||||
if probe_methods
|
||||
else {}
|
||||
probe_methods=tuple(
|
||||
(m.as_flasher_application_type(), baudrate)
|
||||
for m, baudrate in application_probe_methods
|
||||
),
|
||||
bootloader_reset=tuple(
|
||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||
),
|
||||
)
|
||||
|
||||
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
|
||||
|
||||
|
||||
async def probe_silabs_firmware_type(
|
||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
||||
device: str,
|
||||
*,
|
||||
bootloader_reset_methods: Sequence[ResetTarget],
|
||||
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||
) -> ApplicationType | None:
|
||||
"""Probe the running firmware type on a SiLabs device."""
|
||||
|
||||
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
|
||||
fw_info = await probe_silabs_firmware_info(
|
||||
device,
|
||||
bootloader_reset_methods=bootloader_reset_methods,
|
||||
application_probe_methods=application_probe_methods,
|
||||
)
|
||||
if fw_info is None:
|
||||
return None
|
||||
|
||||
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
|
||||
device: str,
|
||||
fw_data: bytes,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
bootloader_reset_methods: Sequence[ResetTarget] = (),
|
||||
bootloader_reset_methods: Sequence[ResetTarget],
|
||||
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
*,
|
||||
domain: str = DOMAIN,
|
||||
) -> FirmwareInfo:
|
||||
"""Flash firmware to the SiLabs device."""
|
||||
if not any(
|
||||
method == expected_installed_firmware_type
|
||||
for method, _ in application_probe_methods
|
||||
):
|
||||
raise ValueError(
|
||||
f"Expected installed firmware type {expected_installed_firmware_type!r}"
|
||||
f" not in application probe methods {application_probe_methods!r}"
|
||||
)
|
||||
|
||||
async with async_firmware_update_context(hass, device, domain):
|
||||
firmware_info = await guess_firmware_info(hass, device)
|
||||
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
||||
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
|
||||
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
probe_methods=(
|
||||
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
|
||||
ApplicationType.EZSP.as_flasher_application_type(),
|
||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
||||
ApplicationType.CPC.as_flasher_application_type(),
|
||||
probe_methods=tuple(
|
||||
(m.as_flasher_application_type(), baudrate)
|
||||
for m, baudrate in application_probe_methods
|
||||
),
|
||||
bootloader_reset=tuple(
|
||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
|
||||
|
||||
probed_firmware_info = await probe_silabs_firmware_info(
|
||||
device,
|
||||
probe_methods=(expected_installed_firmware_type,),
|
||||
bootloader_reset_methods=bootloader_reset_methods,
|
||||
# Only probe for the expected installed firmware type
|
||||
application_probe_methods=[
|
||||
(method, baudrate)
|
||||
for method, baudrate in application_probe_methods
|
||||
if method == expected_installed_firmware_type
|
||||
],
|
||||
)
|
||||
|
||||
if probed_firmware_info is None:
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.usb import (
|
||||
usb_service_info_from_device,
|
||||
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
|
||||
context: ConfigFlowContext
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200
|
||||
# There is no hardware bootloader trigger
|
||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
|
||||
APPLICATION_PROBE_METHODS = [
|
||||
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||
(ApplicationType.SPINEL, 460800),
|
||||
# CPC baudrates can be removed once multiprotocol is removed
|
||||
(ApplicationType.CPC, 115200),
|
||||
(ApplicationType.CPC, 230400),
|
||||
(ApplicationType.CPC, 460800),
|
||||
(ApplicationType.ROUTER, 115200),
|
||||
]
|
||||
|
||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||
"""Shared translation placeholders."""
|
||||
placeholders = {
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeAssistantSkyConnectConfigEntry
|
||||
from .config_flow import SkyConnectFirmwareMixin
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FIRMWARE,
|
||||
@@ -151,8 +152,8 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""SkyConnect firmware update entity."""
|
||||
|
||||
# The ZBT-1 does not have a hardware bootloader trigger
|
||||
bootloader_reset_methods = []
|
||||
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -82,7 +82,18 @@ else:
|
||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
||||
APPLICATION_PROBE_METHODS = [
|
||||
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||
(ApplicationType.SPINEL, 460800),
|
||||
# CPC baudrates can be removed once multiprotocol is removed
|
||||
(ApplicationType.CPC, 115200),
|
||||
(ApplicationType.CPC, 230400),
|
||||
(ApplicationType.CPC, 460800),
|
||||
(ApplicationType.ROUTER, 115200),
|
||||
]
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
|
||||
assert self._device is not None
|
||||
|
||||
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
||||
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||
self._device,
|
||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||
)
|
||||
|
||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||
if (
|
||||
|
||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
||||
from homeassistant.components.homeassistant_hardware.util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
ResetTarget,
|
||||
)
|
||||
from homeassistant.components.update import UpdateDeviceClass
|
||||
from homeassistant.const import EntityCategory
|
||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HomeAssistantYellowConfigEntry
|
||||
from .config_flow import YellowFirmwareMixin
|
||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -150,7 +150,8 @@ async def async_setup_entry(
|
||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||
"""Yellow firmware update entity."""
|
||||
|
||||
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
|
||||
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Any
|
||||
from aiohttp import web
|
||||
from hyperion import client
|
||||
from hyperion.const import (
|
||||
KEY_DATA,
|
||||
KEY_IMAGE,
|
||||
KEY_IMAGE_STREAM,
|
||||
KEY_LEDCOLORS,
|
||||
@@ -155,7 +156,8 @@ class HyperionCamera(Camera):
|
||||
"""Update Hyperion components."""
|
||||
if not img:
|
||||
return
|
||||
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE)
|
||||
# Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to KEY_RESULT for older server versions
|
||||
img_data = img.get(KEY_DATA, img.get(KEY_RESULT, {})).get(KEY_IMAGE)
|
||||
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
|
||||
return
|
||||
async with self._image_cond:
|
||||
|
||||
@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
|
||||
await self.coordinator.device.update_firmware()
|
||||
while (
|
||||
update_progress := await self.coordinator.device.get_firmware()
|
||||
).command_status is UpdateStatus.IN_PROGRESS:
|
||||
).command_status is not UpdateStatus.UPDATED:
|
||||
if counter >= MAX_UPDATE_WAIT:
|
||||
_raise_timeout_error()
|
||||
self._attr_update_percentage = update_progress.progress_percentage
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==11.0.0"]
|
||||
"requirements": ["ical==11.1.0"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==11.0.0"]
|
||||
"requirements": ["ical==11.1.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lunatone-rest-api-client==0.5.3"]
|
||||
"requirements": ["lunatone-rest-api-client==0.5.7"]
|
||||
}
|
||||
|
||||
@@ -234,22 +234,12 @@ class MatterAdapter:
|
||||
self._create_device_registry(endpoint)
|
||||
# run platform discovery from device type instances
|
||||
for entity_info in async_discover_entities(endpoint):
|
||||
# For entities that should only exist once per device (not per endpoint),
|
||||
# exclude endpoint_id from the discovery key
|
||||
if entity_info.discovery_schema.once_per_device:
|
||||
discovery_key = (
|
||||
f"{entity_info.platform}_{endpoint.node.node_id}_"
|
||||
f"{entity_info.primary_attribute.cluster_id}_"
|
||||
f"{entity_info.primary_attribute.attribute_id}_"
|
||||
f"{entity_info.entity_description.key}"
|
||||
)
|
||||
else:
|
||||
discovery_key = (
|
||||
f"{entity_info.platform}_{endpoint.node.node_id}_{endpoint.endpoint_id}_"
|
||||
f"{entity_info.primary_attribute.cluster_id}_"
|
||||
f"{entity_info.primary_attribute.attribute_id}_"
|
||||
f"{entity_info.entity_description.key}"
|
||||
)
|
||||
discovery_key = (
|
||||
f"{entity_info.platform}_{endpoint.node.node_id}_{endpoint.endpoint_id}_"
|
||||
f"{entity_info.primary_attribute.cluster_id}_"
|
||||
f"{entity_info.primary_attribute.attribute_id}_"
|
||||
f"{entity_info.entity_description.key}"
|
||||
)
|
||||
if discovery_key in self.discovered_entities:
|
||||
continue
|
||||
LOGGER.debug(
|
||||
|
||||
@@ -65,10 +65,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_class=MatterCommandButton,
|
||||
required_attributes=(clusters.Identify.Attributes.IdentifyType,),
|
||||
value_is_not=clusters.Identify.Enums.IdentifyTypeEnum.kNone,
|
||||
# Only create a single Identify button per device, not one per endpoint.
|
||||
# The Identify cluster can appear on multiple endpoints; once_per_device=True
|
||||
# ensures only one button is created for the entire device.
|
||||
once_per_device=True,
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BUTTON,
|
||||
|
||||
@@ -152,7 +152,3 @@ class MatterDiscoverySchema:
|
||||
# [optional] the secondary (required) attribute value must NOT have this value
|
||||
# for example to filter out empty lists in list sensor values
|
||||
secondary_value_is_not: Any = UNSET
|
||||
|
||||
# [optional] bool to specify if this entity should only be created once per device
|
||||
# instead of once per endpoint (useful for device-level entities like identify button)
|
||||
once_per_device: bool = False
|
||||
|
||||
@@ -1089,7 +1089,7 @@
|
||||
"message": "Invalid device targeted."
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"set_program_error": {
|
||||
"message": "'Set program' action failed: {status} / {message}"
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/mill",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["mill", "mill_local"],
|
||||
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
|
||||
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -61,10 +61,12 @@ async def async_setup_entry(
|
||||
|
||||
async_add_entities([MobileAppBinarySensor(data, config_entry)])
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||
handle_sensor_registration,
|
||||
config_entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||
handle_sensor_registration,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,10 +72,12 @@ async def async_setup_entry(
|
||||
|
||||
async_add_entities([MobileAppSensor(data, config_entry)])
|
||||
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||
handle_sensor_registration,
|
||||
config_entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||
handle_sensor_registration,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ ABBREVIATIONS = {
|
||||
"fan_mode_stat_t": "fan_mode_state_topic",
|
||||
"frc_upd": "force_update",
|
||||
"g_tpl": "green_template",
|
||||
"grp": "group",
|
||||
"hs_cmd_t": "hs_command_topic",
|
||||
"hs_cmd_tpl": "hs_command_template",
|
||||
"hs_stat_t": "hs_state_topic",
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from .const import (
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_GROUP,
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
|
||||
SCHEMA_BASE = {
|
||||
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
|
||||
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
|
||||
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
|
||||
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)
|
||||
|
||||
@@ -110,6 +110,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
|
||||
CONF_GET_POSITION_TEMPLATE = "position_template"
|
||||
CONF_GET_POSITION_TOPIC = "position_topic"
|
||||
CONF_GREEN_TEMPLATE = "green_template"
|
||||
CONF_GROUP = "group"
|
||||
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
|
||||
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
|
||||
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
||||
|
||||
@@ -79,6 +79,7 @@ from .const import (
|
||||
CONF_ENABLED_BY_DEFAULT,
|
||||
CONF_ENCODING,
|
||||
CONF_ENTITY_PICTURE,
|
||||
CONF_GROUP,
|
||||
CONF_HW_VERSION,
|
||||
CONF_IDENTIFIERS,
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
@@ -136,6 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"device_class",
|
||||
"device_info",
|
||||
"entity_category",
|
||||
"entity_id",
|
||||
"entity_picture",
|
||||
"entity_registry_enabled_default",
|
||||
"extra_state_attributes",
|
||||
@@ -475,6 +477,8 @@ class MqttAttributesMixin(Entity):
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize the JSON attributes mixin."""
|
||||
self._attributes_sub_state: dict[str, EntitySubscription] = {}
|
||||
if CONF_GROUP in config:
|
||||
self._attr_included_unique_ids = config[CONF_GROUP]
|
||||
self._attributes_config = config
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -546,7 +550,7 @@ class MqttAttributesMixin(Entity):
|
||||
_LOGGER.warning("Erroneous JSON: %s", payload)
|
||||
else:
|
||||
if isinstance(json_dict, dict):
|
||||
filtered_dict = {
|
||||
filtered_dict: dict[str, Any] = {
|
||||
k: v
|
||||
for k, v in json_dict.items()
|
||||
if k not in MQTT_ATTRIBUTES_BLOCKED
|
||||
|
||||
@@ -13,7 +13,7 @@ from music_assistant_client.exceptions import (
|
||||
from music_assistant_models.api import ServerInfoMessage
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
@@ -21,21 +21,14 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
DEFAULT_URL = "http://mass.local:8095"
|
||||
DEFAULT_TITLE = "Music Assistant"
|
||||
DEFAULT_URL = "http://mass.local:8095"
|
||||
|
||||
|
||||
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
|
||||
"""Return a schema for the manual step."""
|
||||
default_url = user_input.get(CONF_URL, DEFAULT_URL)
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL, default=default_url): str,
|
||||
}
|
||||
)
|
||||
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_URL): str})
|
||||
|
||||
|
||||
async def get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
|
||||
async def _get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
|
||||
"""Validate the user input allows us to connect."""
|
||||
async with MusicAssistantClient(
|
||||
url, aiohttp_client.async_get_clientsession(hass)
|
||||
@@ -52,25 +45,17 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Set up flow instance."""
|
||||
self.server_info: ServerInfoMessage | None = None
|
||||
self.url: str | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a manual configuration."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
self.server_info = await get_server_info(
|
||||
self.hass, user_input[CONF_URL]
|
||||
)
|
||||
await self.async_set_unique_id(
|
||||
self.server_info.server_id, raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_URL: user_input[CONF_URL]},
|
||||
reload_on_update=True,
|
||||
)
|
||||
server_info = await _get_server_info(self.hass, user_input[CONF_URL])
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidServerVersion:
|
||||
@@ -79,68 +64,49 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_TITLE,
|
||||
data={
|
||||
CONF_URL: user_input[CONF_URL],
|
||||
},
|
||||
await self.async_set_unique_id(
|
||||
server_info.server_id, raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_URL: user_input[CONF_URL]}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=get_manual_schema(user_input), errors=errors
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_TITLE,
|
||||
data={CONF_URL: user_input[CONF_URL]},
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="user", data_schema=get_manual_schema({}))
|
||||
suggested_values = user_input
|
||||
if suggested_values is None:
|
||||
suggested_values = {CONF_URL: DEFAULT_URL}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_SCHEMA, suggested_values
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a discovered Mass server.
|
||||
|
||||
This flow is triggered by the Zeroconf component. It will check if the
|
||||
host is already configured and delegate to the import step if not.
|
||||
"""
|
||||
# abort if discovery info is not what we expect
|
||||
if "server_id" not in discovery_info.properties:
|
||||
return self.async_abort(reason="missing_server_id")
|
||||
|
||||
self.server_info = ServerInfoMessage.from_dict(discovery_info.properties)
|
||||
await self.async_set_unique_id(self.server_info.server_id)
|
||||
|
||||
# Check if we already have a config entry for this server_id
|
||||
existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
|
||||
DOMAIN, self.server_info.server_id
|
||||
)
|
||||
|
||||
if existing_entry:
|
||||
# If the entry was ignored or disabled, don't make any changes
|
||||
if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Test connectivity to the current URL first
|
||||
current_url = existing_entry.data[CONF_URL]
|
||||
try:
|
||||
await get_server_info(self.hass, current_url)
|
||||
# Current URL is working, no need to update
|
||||
return self.async_abort(reason="already_configured")
|
||||
except CannotConnect:
|
||||
# Current URL is not working, update to the discovered URL
|
||||
# and continue to discovery confirm
|
||||
self.hass.config_entries.async_update_entry(
|
||||
existing_entry,
|
||||
data={**existing_entry.data, CONF_URL: self.server_info.base_url},
|
||||
)
|
||||
# Schedule reload since URL changed
|
||||
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
|
||||
else:
|
||||
# No existing entry, proceed with normal flow
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Test connectivity to the discovered URL
|
||||
"""Handle a zeroconf discovery for a Music Assistant server."""
|
||||
try:
|
||||
await get_server_info(self.hass, self.server_info.base_url)
|
||||
server_info = ServerInfoMessage.from_dict(discovery_info.properties)
|
||||
except LookupError:
|
||||
return self.async_abort(reason="invalid_discovery_info")
|
||||
|
||||
self.url = server_info.base_url
|
||||
|
||||
await self.async_set_unique_id(server_info.server_id)
|
||||
self._abort_if_unique_id_configured(updates={CONF_URL: self.url})
|
||||
|
||||
try:
|
||||
await _get_server_info(self.hass, self.url)
|
||||
except CannotConnect:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
@@ -148,16 +114,16 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user-confirmation of discovered server."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.server_info is not None
|
||||
assert self.url is not None
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=DEFAULT_TITLE,
|
||||
data={
|
||||
CONF_URL: self.server_info.base_url,
|
||||
},
|
||||
data={CONF_URL: self.url},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"url": self.server_info.base_url},
|
||||
description_placeholders={"url": self.url},
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "music_assistant",
|
||||
"name": "Music Assistant",
|
||||
"after_dependencies": ["media_source", "media_player"],
|
||||
"codeowners": ["@music-assistant"],
|
||||
"codeowners": ["@music-assistant", "@arturpragacz"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"message": "Error while loading the integration."
|
||||
},
|
||||
"implementation_unavailable": {
|
||||
"message": "OAuth2 implementation is not available, will retry."
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"incorrect_oauth2_scope": {
|
||||
"message": "Stored permissions are invalid. Please login again to update permissions."
|
||||
|
||||
@@ -20,10 +20,11 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
@@ -73,17 +74,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Netatmo from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
# Set unique id if non was set (migration)
|
||||
if not entry.unique_id:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
|
||||
@@ -143,6 +143,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"public_weather": {
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "Failed to update drive state"
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from ..const import SupportedDialect
|
||||
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE
|
||||
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE, MYSQL_COLLATE
|
||||
from ..util import session_scope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -105,12 +105,13 @@ def _validate_table_schema_has_correct_collation(
|
||||
or dialect_kwargs.get("mariadb_collate")
|
||||
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
|
||||
)
|
||||
if collate and collate != "utf8mb4_unicode_ci":
|
||||
if collate and collate != MYSQL_COLLATE:
|
||||
_LOGGER.debug(
|
||||
"Database %s collation is not utf8mb4_unicode_ci",
|
||||
"Database %s collation is not %s",
|
||||
table,
|
||||
MYSQL_COLLATE,
|
||||
)
|
||||
schema_errors.add(f"{table}.utf8mb4_unicode_ci")
|
||||
schema_errors.add(f"{table}.{MYSQL_COLLATE}")
|
||||
return schema_errors
|
||||
|
||||
|
||||
@@ -240,7 +241,7 @@ def correct_db_schema_utf8(
|
||||
table_name = table_object.__tablename__
|
||||
if (
|
||||
f"{table_name}.4-byte UTF-8" in schema_errors
|
||||
or f"{table_name}.utf8mb4_unicode_ci" in schema_errors
|
||||
or f"{table_name}.{MYSQL_COLLATE}" in schema_errors
|
||||
):
|
||||
from ..migration import ( # noqa: PLC0415
|
||||
_correct_table_character_set_and_collation,
|
||||
|
||||
@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
|
||||
schema_errors |= validate_table_schema_supports_utf8(
|
||||
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
|
||||
)
|
||||
schema_errors |= validate_table_schema_has_correct_collation(
|
||||
instance, StatisticsMeta
|
||||
)
|
||||
for table in (Statistics, StatisticsShortTerm):
|
||||
schema_errors |= validate_db_schema_precision(instance, table)
|
||||
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
||||
|
||||
@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
|
||||
"""Base class for tables, used for schema migration."""
|
||||
|
||||
|
||||
SCHEMA_VERSION = 52
|
||||
SCHEMA_VERSION = 53
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -128,7 +128,7 @@ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_update
|
||||
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
|
||||
CONTEXT_ID_BIN_MAX_LENGTH = 16
|
||||
|
||||
MYSQL_COLLATE = "utf8mb4_unicode_ci"
|
||||
MYSQL_COLLATE = "utf8mb4_bin"
|
||||
MYSQL_DEFAULT_CHARSET = "utf8mb4"
|
||||
MYSQL_ENGINE = "InnoDB"
|
||||
|
||||
|
||||
@@ -1361,7 +1361,7 @@ class _SchemaVersion20Migrator(_SchemaVersionMigrator, target_version=20):
|
||||
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
|
||||
def _apply_update(self) -> None:
|
||||
"""Version specific update method."""
|
||||
# Try to change the character set of the statistic_meta table
|
||||
# Try to change the character set of events, states and statistics_meta tables
|
||||
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||
for table in ("events", "states", "statistics_meta"):
|
||||
_correct_table_character_set_and_collation(table, self.session_maker)
|
||||
@@ -2125,6 +2125,23 @@ class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
|
||||
)
|
||||
|
||||
|
||||
class _SchemaVersion53Migrator(_SchemaVersionMigrator, target_version=53):
|
||||
def _apply_update(self) -> None:
|
||||
"""Version specific update method."""
|
||||
# Try to change the character set of events, states and statistics_meta tables
|
||||
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||
for table in (
|
||||
"events",
|
||||
"event_data",
|
||||
"states",
|
||||
"state_attributes",
|
||||
"statistics",
|
||||
"statistics_meta",
|
||||
"statistics_short_term",
|
||||
):
|
||||
_correct_table_character_set_and_collation(table, self.session_maker)
|
||||
|
||||
|
||||
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
|
||||
hass: HomeAssistant,
|
||||
instance: Recorder,
|
||||
@@ -2167,8 +2184,10 @@ def _correct_table_character_set_and_collation(
|
||||
"""Correct issues detected by validate_db_schema."""
|
||||
# Attempt to convert the table to utf8mb4
|
||||
_LOGGER.warning(
|
||||
"Updating character set and collation of table %s to utf8mb4. %s",
|
||||
"Updating table %s to character set %s and collation %s. %s",
|
||||
table,
|
||||
MYSQL_DEFAULT_CHARSET,
|
||||
MYSQL_COLLATE,
|
||||
MIGRATION_NOTE_MINUTES,
|
||||
)
|
||||
with (
|
||||
|
||||
@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
QUERY_STATISTIC_META = (
|
||||
QUERY_STATISTICS_META = (
|
||||
StatisticsMeta.id,
|
||||
StatisticsMeta.statistic_id,
|
||||
StatisticsMeta.source,
|
||||
@@ -55,7 +55,7 @@ def _generate_get_metadata_stmt(
|
||||
|
||||
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
|
||||
"""
|
||||
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
|
||||
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META)
|
||||
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
|
||||
columns.append(StatisticsMeta.mean_type)
|
||||
else:
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==11.0.0"]
|
||||
"requirements": ["ical==11.1.0"]
|
||||
}
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
import logging
|
||||
|
||||
from satel_integra.satel_integra import AlarmState
|
||||
from satel_integra.satel_integra import AlarmState, AsyncSatel
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntity,
|
||||
@@ -16,17 +15,31 @@ from homeassistant.components.alarm_control_panel import (
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
CONF_ARM_HOME_MODE,
|
||||
CONF_PARTITION_NUMBER,
|
||||
DOMAIN,
|
||||
SIGNAL_PANEL_MESSAGE,
|
||||
SUBENTRY_TYPE_PARTITION,
|
||||
SatelConfigEntry,
|
||||
)
|
||||
|
||||
ALARM_STATE_MAP = {
|
||||
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
|
||||
AlarmState.TRIGGERED_FIRE: AlarmControlPanelState.TRIGGERED,
|
||||
AlarmState.ENTRY_TIME: AlarmControlPanelState.PENDING,
|
||||
AlarmState.ARMED_MODE3: AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmState.ARMED_MODE2: AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmState.ARMED_MODE1: AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmState.ARMED_MODE0: AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmState.EXIT_COUNTDOWN_OVER_10: AlarmControlPanelState.ARMING,
|
||||
AlarmState.EXIT_COUNTDOWN_UNDER_10: AlarmControlPanelState.ARMING,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -45,9 +58,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for subentry in partition_subentries:
|
||||
partition_num = subentry.data[CONF_PARTITION_NUMBER]
|
||||
zone_name = subentry.data[CONF_NAME]
|
||||
arm_home_mode = subentry.data[CONF_ARM_HOME_MODE]
|
||||
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
|
||||
zone_name: str = subentry.data[CONF_NAME]
|
||||
arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
@@ -73,20 +86,31 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
)
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, controller, name, arm_home_mode, partition_id, config_entry_id
|
||||
self,
|
||||
controller: AsyncSatel,
|
||||
device_name: str,
|
||||
arm_home_mode: int,
|
||||
partition_id: int,
|
||||
config_entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the alarm panel."""
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
|
||||
self._arm_home_mode = arm_home_mode
|
||||
self._partition_id = partition_id
|
||||
self._satel = controller
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Update alarm status and register callbacks for future updates."""
|
||||
_LOGGER.debug("Starts listening for panel messages")
|
||||
self._update_alarm_status()
|
||||
self._attr_alarm_state = self._read_alarm_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
|
||||
@@ -94,55 +118,29 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_alarm_status(self):
|
||||
def _update_alarm_status(self) -> None:
|
||||
"""Handle alarm status update."""
|
||||
state = self._read_alarm_state()
|
||||
_LOGGER.debug("Got status update, current status: %s", state)
|
||||
|
||||
if state != self._attr_alarm_state:
|
||||
self._attr_alarm_state = state
|
||||
self.async_write_ha_state()
|
||||
else:
|
||||
_LOGGER.debug("Ignoring alarm status message, same state")
|
||||
|
||||
def _read_alarm_state(self):
|
||||
def _read_alarm_state(self) -> AlarmControlPanelState | None:
|
||||
"""Read current status of the alarm and translate it into HA status."""
|
||||
|
||||
# Default - disarmed:
|
||||
hass_alarm_status = AlarmControlPanelState.DISARMED
|
||||
|
||||
if not self._satel.connected:
|
||||
_LOGGER.debug("Alarm panel not connected")
|
||||
return None
|
||||
|
||||
state_map = OrderedDict(
|
||||
[
|
||||
(AlarmState.TRIGGERED, AlarmControlPanelState.TRIGGERED),
|
||||
(AlarmState.TRIGGERED_FIRE, AlarmControlPanelState.TRIGGERED),
|
||||
(AlarmState.ENTRY_TIME, AlarmControlPanelState.PENDING),
|
||||
(AlarmState.ARMED_MODE3, AlarmControlPanelState.ARMED_HOME),
|
||||
(AlarmState.ARMED_MODE2, AlarmControlPanelState.ARMED_HOME),
|
||||
(AlarmState.ARMED_MODE1, AlarmControlPanelState.ARMED_HOME),
|
||||
(AlarmState.ARMED_MODE0, AlarmControlPanelState.ARMED_AWAY),
|
||||
(
|
||||
AlarmState.EXIT_COUNTDOWN_OVER_10,
|
||||
AlarmControlPanelState.PENDING,
|
||||
),
|
||||
(
|
||||
AlarmState.EXIT_COUNTDOWN_UNDER_10,
|
||||
AlarmControlPanelState.PENDING,
|
||||
),
|
||||
]
|
||||
)
|
||||
_LOGGER.debug("State map of Satel: %s", self._satel.partition_states)
|
||||
|
||||
for satel_state, ha_state in state_map.items():
|
||||
for satel_state, ha_state in ALARM_STATE_MAP.items():
|
||||
if (
|
||||
satel_state in self._satel.partition_states
|
||||
and self._partition_id in self._satel.partition_states[satel_state]
|
||||
):
|
||||
hass_alarm_status = ha_state
|
||||
break
|
||||
return ha_state
|
||||
|
||||
return hass_alarm_status
|
||||
return AlarmControlPanelState.DISARMED
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
@@ -154,8 +152,6 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
|
||||
)
|
||||
|
||||
_LOGGER.debug("Disarming, self._attr_alarm_state: %s", self._attr_alarm_state)
|
||||
|
||||
await self._satel.disarm(code, [self._partition_id])
|
||||
|
||||
if clear_alarm_necessary:
|
||||
@@ -165,14 +161,12 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
_LOGGER.debug("Arming away")
|
||||
|
||||
if code:
|
||||
await self._satel.arm(code, [self._partition_id])
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
_LOGGER.debug("Arming home")
|
||||
|
||||
if code:
|
||||
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)
|
||||
|
||||
@@ -118,6 +118,9 @@
|
||||
"pm25": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"pm4": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"power": {
|
||||
"default": "mdi:flash"
|
||||
},
|
||||
|
||||
@@ -12,10 +12,11 @@ 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 (
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
httpx_client,
|
||||
from homeassistant.helpers import config_validation as cv, httpx_client
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
@@ -28,19 +29,22 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||
|
||||
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
|
||||
type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
|
||||
"""Set up SENZ from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
|
||||
senz_api = SENZAPI(auth)
|
||||
|
||||
@@ -68,16 +72,13 @@ 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: SENZConfigEntry) -> 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)
|
||||
|
||||
@@ -12,30 +12,29 @@ from homeassistant.components.climate import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import SENZDataUpdateCoordinator
|
||||
from . import SENZConfigEntry, SENZDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: SENZConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the SENZ climate entities from a config entry."""
|
||||
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
|
||||
)
|
||||
|
||||
|
||||
class SENZClimate(CoordinatorEntity, ClimateEntity):
|
||||
class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
|
||||
"""Representation of a SENZ climate entity."""
|
||||
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
|
||||
26
homeassistant/components/senz/diagnostics.py
Normal file
26
homeassistant/components/senz/diagnostics.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Diagnostics platform for Senz integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import SENZConfigEntry
|
||||
|
||||
TO_REDACT = [
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
]
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: SENZConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
raw_data = ([device.raw_data for device in entry.runtime_data.data.values()],)
|
||||
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"thermostats": raw_data,
|
||||
}
|
||||
92
homeassistant/components/senz/sensor.py
Normal file
92
homeassistant/components/senz/sensor.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""nVent RAYCHEM SENZ sensor platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiosenz import Thermostat
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import SENZConfigEntry, SENZDataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class SenzSensorDescription(SensorEntityDescription):
|
||||
"""Describes SENZ sensor entity."""
|
||||
|
||||
value_fn: Callable[[Thermostat], str | int | float | None]
|
||||
|
||||
|
||||
SENSORS: tuple[SenzSensorDescription, ...] = (
|
||||
SenzSensorDescription(
|
||||
key="temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda data: data.current_temperatue,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SENZConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the SENZ sensor entities from a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
SENZSensor(thermostat, coordinator, description)
|
||||
for description in SENSORS
|
||||
for thermostat in coordinator.data.values()
|
||||
)
|
||||
|
||||
|
||||
class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
|
||||
"""Representation of a SENZ sensor entity."""
|
||||
|
||||
entity_description: SenzSensorDescription
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
thermostat: Thermostat,
|
||||
coordinator: SENZDataUpdateCoordinator,
|
||||
description: SenzSensorDescription,
|
||||
) -> None:
|
||||
"""Init SENZ sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._thermostat = thermostat
|
||||
self._attr_unique_id = f"{thermostat.serial_number}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, thermostat.serial_number)},
|
||||
manufacturer="nVent Raychem",
|
||||
model="SENZ WIFI",
|
||||
name=thermostat.name,
|
||||
serial_number=thermostat.serial_number,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the thermostat is available."""
|
||||
return super().available and self._thermostat.online
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | float | int | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self._thermostat)
|
||||
@@ -25,5 +25,10 @@
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,7 +663,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
|
||||
@@ -75,6 +75,7 @@ PLATFORMS_BY_TYPE = {
|
||||
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
|
||||
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
|
||||
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.K10_PRO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
SupportedModels.K10_PRO_COMBO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
|
||||
@@ -102,6 +103,10 @@ PLATFORMS_BY_TYPE = {
|
||||
SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR],
|
||||
SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR],
|
||||
SupportedModels.CLIMATE_PANEL.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
|
||||
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: [
|
||||
Platform.CLIMATE,
|
||||
Platform.SENSOR,
|
||||
],
|
||||
}
|
||||
CLASS_BY_DEVICE = {
|
||||
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
|
||||
@@ -119,6 +124,7 @@ CLASS_BY_DEVICE = {
|
||||
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
|
||||
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
|
||||
SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.S20_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.K10_PRO_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum,
|
||||
@@ -136,6 +142,7 @@ CLASS_BY_DEVICE = {
|
||||
SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch,
|
||||
SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM,
|
||||
SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener,
|
||||
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: switchbot.SwitchbotSmartThermostatRadiator,
|
||||
}
|
||||
|
||||
|
||||
|
||||
140
homeassistant/components/switchbot/climate.py
Normal file
140
homeassistant/components/switchbot/climate.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Support for Switchbot Climate devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import switchbot
|
||||
from switchbot import (
|
||||
ClimateAction as SwitchBotClimateAction,
|
||||
ClimateMode as SwitchBotClimateMode,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import SwitchbotConfigEntry
|
||||
from .entity import SwitchbotEntity, exception_handler
|
||||
|
||||
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE = {
|
||||
SwitchBotClimateMode.HEAT: HVACMode.HEAT,
|
||||
SwitchBotClimateMode.OFF: HVACMode.OFF,
|
||||
}
|
||||
|
||||
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE = {
|
||||
HVACMode.HEAT: SwitchBotClimateMode.HEAT,
|
||||
HVACMode.OFF: SwitchBotClimateMode.OFF,
|
||||
}
|
||||
|
||||
SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION = {
|
||||
SwitchBotClimateAction.HEATING: HVACAction.HEATING,
|
||||
SwitchBotClimateAction.IDLE: HVACAction.IDLE,
|
||||
SwitchBotClimateAction.OFF: HVACAction.OFF,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SwitchbotConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Switchbot climate based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities([SwitchBotClimateEntity(coordinator)])
|
||||
|
||||
|
||||
class SwitchBotClimateEntity(SwitchbotEntity, ClimateEntity):
|
||||
"""Representation of a Switchbot Climate device."""
|
||||
|
||||
_device: switchbot.SwitchbotDevice
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
)
|
||||
_attr_target_temperature_step = 0.5
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = "climate"
|
||||
_attr_name = None
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature."""
|
||||
return self._device.min_temperature
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
"""Return the maximum temperature."""
|
||||
return self._device.max_temperature
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str] | None:
|
||||
"""Return the list of available preset modes."""
|
||||
return self._device.preset_modes
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
return self._device.preset_mode
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode | None:
|
||||
"""Return the current HVAC mode."""
|
||||
return SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE.get(
|
||||
self._device.hvac_mode, HVACMode.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of available HVAC modes."""
|
||||
return [
|
||||
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE[mode]
|
||||
for mode in self._device.hvac_modes
|
||||
]
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction | None:
|
||||
"""Return the current HVAC action."""
|
||||
return SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION.get(
|
||||
self._device.hvac_action, HVACAction.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._device.current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._device.target_temperature
|
||||
|
||||
@exception_handler
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new HVAC mode."""
|
||||
return await self._device.set_hvac_mode(
|
||||
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE[hvac_mode]
|
||||
)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
return await self._device.set_preset_mode(preset_mode)
|
||||
|
||||
@exception_handler
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
return await self._device.set_target_temperature(temperature)
|
||||
@@ -58,6 +58,8 @@ class SupportedModels(StrEnum):
|
||||
K11_PLUS_VACUUM = "k11+_vacuum"
|
||||
GARAGE_DOOR_OPENER = "garage_door_opener"
|
||||
CLIMATE_PANEL = "climate_panel"
|
||||
SMART_THERMOSTAT_RADIATOR = "smart_thermostat_radiator"
|
||||
S20_VACUUM = "s20_vacuum"
|
||||
|
||||
|
||||
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -78,6 +80,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.CIRCULATOR_FAN: SupportedModels.CIRCULATOR_FAN,
|
||||
SwitchbotModel.K20_VACUUM: SupportedModels.K20_VACUUM,
|
||||
SwitchbotModel.S10_VACUUM: SupportedModels.S10_VACUUM,
|
||||
SwitchbotModel.S20_VACUUM: SupportedModels.S20_VACUUM,
|
||||
SwitchbotModel.K10_VACUUM: SupportedModels.K10_VACUUM,
|
||||
SwitchbotModel.K10_PRO_VACUUM: SupportedModels.K10_PRO_VACUUM,
|
||||
SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM,
|
||||
@@ -95,6 +98,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER,
|
||||
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
|
||||
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: SupportedModels.SMART_THERMOSTAT_RADIATOR,
|
||||
}
|
||||
|
||||
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -132,6 +136,7 @@ ENCRYPTED_MODELS = {
|
||||
SwitchbotModel.PLUG_MINI_EU,
|
||||
SwitchbotModel.RELAY_SWITCH_2PM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER,
|
||||
SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
|
||||
}
|
||||
|
||||
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
|
||||
@@ -153,6 +158,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
|
||||
SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch,
|
||||
SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM,
|
||||
SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch,
|
||||
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: switchbot.SwitchbotSmartThermostatRadiator,
|
||||
}
|
||||
|
||||
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"climate": {
|
||||
"climate": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"manual": "mdi:hand-back-right",
|
||||
"off": "mdi:hvac-off",
|
||||
"schedule": "mdi:calendar-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_purifier": {
|
||||
"default": "mdi:air-purifier",
|
||||
|
||||
@@ -100,6 +100,19 @@
|
||||
"name": "Unlocked alarm"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"climate": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"manual": "[%key:common::state::manual%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cover": {
|
||||
"cover": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"abort": {
|
||||
"already_configured": "Chat already configured"
|
||||
},
|
||||
"entry_type": "Allowed chat ID",
|
||||
"error": {
|
||||
"chat_not_found": "Chat not found"
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -61,6 +62,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
|
||||
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
except ValueError as e:
|
||||
# Remove invalid implementation from config entry then raise AuthFailed
|
||||
hass.config_entries.async_update_entry(
|
||||
|
||||
@@ -609,6 +609,9 @@
|
||||
"no_cable": {
|
||||
"message": "Charge cable will lock automatically when connected"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"update_failed": {
|
||||
"message": "{endpoint} data request failed: {message}"
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["tesla_wall_connector"],
|
||||
"requirements": ["tesla-wall-connector==1.0.2"]
|
||||
"requirements": ["tesla-wall-connector==1.1.0"]
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ class TeslemetryStreamingUpdateEntity(
|
||||
if self._download_percentage > 1 and self._download_percentage < 100:
|
||||
self._attr_in_progress = True
|
||||
self._attr_update_percentage = self._download_percentage
|
||||
elif self._install_percentage > 1:
|
||||
elif self._install_percentage > 10:
|
||||
self._attr_in_progress = True
|
||||
self._attr_update_percentage = self._install_percentage
|
||||
else:
|
||||
|
||||
@@ -11,8 +11,10 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -86,7 +88,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Toon from a config entry."""
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
coordinator = ToonDataUpdateCoordinator(hass, entry, session)
|
||||
|
||||
@@ -32,6 +32,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"update": {
|
||||
"description": "Updates all entities with fresh data from Toon.",
|
||||
|
||||
@@ -181,15 +181,14 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity):
|
||||
HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF
|
||||
)
|
||||
|
||||
if (
|
||||
self._thermostat_module.mode not in STATE_TO_ACTION
|
||||
and self._attr_hvac_action is not HVACAction.OFF
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Unknown thermostat state, defaulting to OFF: %s",
|
||||
self._thermostat_module.mode,
|
||||
)
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
if self._thermostat_module.mode not in STATE_TO_ACTION:
|
||||
# Report a warning on the first non-default unknown mode
|
||||
if self._attr_hvac_action is not HVACAction.OFF:
|
||||
_LOGGER.warning(
|
||||
"Unknown thermostat state, defaulting to OFF: %s",
|
||||
self._thermostat_module.mode,
|
||||
)
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
return True
|
||||
|
||||
self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode]
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
|
||||
from .const import (
|
||||
@@ -23,7 +24,7 @@ from .const import (
|
||||
SERVICE_START_TORRENT,
|
||||
SERVICE_STOP_TORRENT,
|
||||
)
|
||||
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
|
||||
from .coordinator import TransmissionDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -67,45 +68,52 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All(
|
||||
|
||||
|
||||
def _get_coordinator_from_service_data(
|
||||
hass: HomeAssistant, entry_id: str
|
||||
call: ServiceCall,
|
||||
) -> TransmissionDataUpdateCoordinator:
|
||||
"""Return coordinator for entry id."""
|
||||
entry: TransmissionConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
entry_id
|
||||
)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
raise HomeAssistantError(f"Config entry {entry_id} is not found or not loaded")
|
||||
return entry.runtime_data
|
||||
config_entry_id: str = call.data[CONF_ENTRY_ID]
|
||||
if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": DOMAIN},
|
||||
)
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": entry.title},
|
||||
)
|
||||
return cast(TransmissionDataUpdateCoordinator, entry.runtime_data)
|
||||
|
||||
|
||||
async def _async_add_torrent(service: ServiceCall) -> None:
|
||||
"""Add new torrent to download."""
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent: str = service.data[ATTR_TORRENT]
|
||||
download_path: str | None = service.data.get(ATTR_DOWNLOAD_PATH)
|
||||
if torrent.startswith(
|
||||
("http", "ftp:", "magnet:")
|
||||
) or service.hass.config.is_allowed_path(torrent):
|
||||
if download_path:
|
||||
await service.hass.async_add_executor_job(
|
||||
partial(
|
||||
coordinator.api.add_torrent, torrent, download_dir=download_path
|
||||
)
|
||||
)
|
||||
else:
|
||||
await service.hass.async_add_executor_job(
|
||||
coordinator.api.add_torrent, torrent
|
||||
)
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
if not (
|
||||
torrent.startswith(("http", "ftp:", "magnet:"))
|
||||
or service.hass.config.is_allowed_path(torrent)
|
||||
):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="could_not_add_torrent",
|
||||
)
|
||||
|
||||
if download_path:
|
||||
await service.hass.async_add_executor_job(
|
||||
partial(coordinator.api.add_torrent, torrent, download_dir=download_path)
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning("Could not add torrent: unsupported type or no permission")
|
||||
await service.hass.async_add_executor_job(coordinator.api.add_torrent, torrent)
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def _async_start_torrent(service: ServiceCall) -> None:
|
||||
"""Start torrent."""
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
await service.hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id)
|
||||
await coordinator.async_request_refresh()
|
||||
@@ -113,8 +121,7 @@ async def _async_start_torrent(service: ServiceCall) -> None:
|
||||
|
||||
async def _async_stop_torrent(service: ServiceCall) -> None:
|
||||
"""Stop torrent."""
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
await service.hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id)
|
||||
await coordinator.async_request_refresh()
|
||||
@@ -122,8 +129,7 @@ async def _async_stop_torrent(service: ServiceCall) -> None:
|
||||
|
||||
async def _async_remove_torrent(service: ServiceCall) -> None:
|
||||
"""Remove torrent."""
|
||||
entry_id: str = service.data[CONF_ENTRY_ID]
|
||||
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
|
||||
coordinator = _get_coordinator_from_service_data(service)
|
||||
torrent_id = service.data[CONF_ID]
|
||||
delete_data = service.data[ATTR_DELETE_DATA]
|
||||
await service.hass.async_add_executor_job(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
add_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
@@ -18,6 +19,7 @@ add_torrent:
|
||||
remove_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
@@ -27,6 +29,7 @@ remove_torrent:
|
||||
selector:
|
||||
text:
|
||||
delete_data:
|
||||
required: true
|
||||
default: false
|
||||
selector:
|
||||
boolean:
|
||||
@@ -34,17 +37,20 @@ remove_torrent:
|
||||
start_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
id:
|
||||
example: 123
|
||||
selector:
|
||||
text:
|
||||
|
||||
stop_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
id:
|
||||
required: true
|
||||
example: 123
|
||||
selector:
|
||||
text:
|
||||
|
||||
stop_torrent:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
|
||||
@@ -87,6 +87,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"could_not_add_torrent": {
|
||||
"message": "Could not add torrent: unsupported type or no permission."
|
||||
},
|
||||
"integration_not_found": {
|
||||
"message": "Integration \"{target}\" not found in registry."
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "{target} is not loaded."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -19,9 +19,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import EnumTypeData, find_dpcode
|
||||
from .models import DPCodeEnumWrapper
|
||||
from .util import get_dpcode
|
||||
|
||||
|
||||
@@ -85,9 +85,21 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := ALARM.get(device.category):
|
||||
entities.extend(
|
||||
TuyaAlarmEntity(device, manager, description)
|
||||
TuyaAlarmEntity(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
action_dpcode_wrapper=action_dpcode_wrapper,
|
||||
state_dpcode_wrapper=DPCodeEnumWrapper.find_dpcode(
|
||||
device, description.master_state
|
||||
),
|
||||
)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
action_dpcode_wrapper := DPCodeEnumWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -103,7 +115,6 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
|
||||
_attr_name = None
|
||||
_attr_code_arm_required = False
|
||||
_master_state: EnumTypeData | None = None
|
||||
_alarm_msg_dpcode: DPCode | None = None
|
||||
|
||||
def __init__(
|
||||
@@ -111,33 +122,24 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: TuyaAlarmControlPanelEntityDescription,
|
||||
*,
|
||||
action_dpcode_wrapper: DPCodeEnumWrapper,
|
||||
state_dpcode_wrapper: DPCodeEnumWrapper | None,
|
||||
) -> None:
|
||||
"""Init Tuya Alarm."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._action_dpcode_wrapper = action_dpcode_wrapper
|
||||
self._state_dpcode_wrapper = state_dpcode_wrapper
|
||||
|
||||
# Determine supported modes
|
||||
if supported_modes := find_dpcode(
|
||||
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
if Mode.HOME in supported_modes.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
|
||||
if Mode.ARM in supported_modes.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
if Mode.SOS in supported_modes.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
|
||||
# Determine master state
|
||||
if enum_type := find_dpcode(
|
||||
self.device,
|
||||
description.master_state,
|
||||
dptype=DPType.ENUM,
|
||||
prefer_function=True,
|
||||
):
|
||||
self._master_state = enum_type
|
||||
if Mode.HOME in action_dpcode_wrapper.type_information.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
|
||||
if Mode.ARM in action_dpcode_wrapper.type_information.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
if Mode.SOS in action_dpcode_wrapper.type_information.range:
|
||||
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
|
||||
|
||||
# Determine alarm message
|
||||
if dp_code := get_dpcode(self.device, description.alarm_msg):
|
||||
@@ -149,8 +151,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
|
||||
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
|
||||
if (
|
||||
self._master_state is not None
|
||||
and self.device.status.get(self._master_state.dpcode) == State.ALARM
|
||||
self._state_dpcode_wrapper is not None
|
||||
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
|
||||
):
|
||||
# Only report as triggered if NOT a battery warning
|
||||
if (
|
||||
@@ -166,28 +168,26 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
|
||||
def changed_by(self) -> str | None:
|
||||
"""Last change triggered by."""
|
||||
if (
|
||||
self._master_state is not None
|
||||
self._state_dpcode_wrapper is not None
|
||||
and self._alarm_msg_dpcode is not None
|
||||
and self.device.status.get(self._master_state.dpcode) == State.ALARM
|
||||
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
|
||||
and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
|
||||
):
|
||||
return b64decode(encoded_msg).decode("utf-16be")
|
||||
return None
|
||||
|
||||
def alarm_disarm(self, code: str | None = None) -> None:
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send Disarm command."""
|
||||
self._send_command(
|
||||
[{"code": self.entity_description.key, "value": Mode.DISARMED}]
|
||||
)
|
||||
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.DISARMED)
|
||||
|
||||
def alarm_arm_home(self, code: str | None = None) -> None:
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send Home command."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}])
|
||||
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.HOME)
|
||||
|
||||
def alarm_arm_away(self, code: str | None = None) -> None:
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send Arm command."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}])
|
||||
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.ARM)
|
||||
|
||||
def alarm_trigger(self, code: str | None = None) -> None:
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
"""Send SOS command."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}])
|
||||
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.SOS)
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import json
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from tuya_sharing import CustomerDevice
|
||||
|
||||
@@ -101,30 +99,20 @@ def _async_device_as_dict(
|
||||
data["status"][dpcode] = REDACTED
|
||||
continue
|
||||
|
||||
with suppress(ValueError, TypeError):
|
||||
value = json.loads(value)
|
||||
data["status"][dpcode] = value
|
||||
|
||||
# Gather Tuya functions
|
||||
for function in device.function.values():
|
||||
value = function.values
|
||||
with suppress(ValueError, TypeError, AttributeError):
|
||||
value = json.loads(cast(str, function.values))
|
||||
|
||||
data["function"][function.code] = {
|
||||
"type": function.type,
|
||||
"value": value,
|
||||
"value": function.values,
|
||||
}
|
||||
|
||||
# Gather Tuya status ranges
|
||||
for status_range in device.status_range.values():
|
||||
value = status_range.values
|
||||
with suppress(ValueError, TypeError, AttributeError):
|
||||
value = json.loads(status_range.values)
|
||||
|
||||
data["status_range"][status_range.code] = {
|
||||
"type": status_range.type,
|
||||
"value": value,
|
||||
"value": status_range.values,
|
||||
}
|
||||
|
||||
# Gather information how this Tuya device is represented in Home Assistant
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import color as color_util
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
|
||||
@@ -499,11 +500,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
values = self.device.status_range[dpcode].values
|
||||
|
||||
# Fetch color data type information
|
||||
if function_data := json.loads(values):
|
||||
if function_data := json_loads_object(values):
|
||||
self._color_data_type = ColorTypeData(
|
||||
h_type=IntegerTypeData(dpcode, **function_data["h"]),
|
||||
s_type=IntegerTypeData(dpcode, **function_data["s"]),
|
||||
v_type=IntegerTypeData(dpcode, **function_data["v"]),
|
||||
h_type=IntegerTypeData(dpcode, **cast(dict, function_data["h"])),
|
||||
s_type=IntegerTypeData(dpcode, **cast(dict, function_data["s"])),
|
||||
v_type=IntegerTypeData(dpcode, **cast(dict, function_data["v"])),
|
||||
)
|
||||
else:
|
||||
# If no type is found, use a default one
|
||||
@@ -770,12 +771,12 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
|
||||
if not (status_data := self.device.status[self._color_data_dpcode]):
|
||||
return None
|
||||
|
||||
if not (status := json.loads(status_data)):
|
||||
if not (status := json_loads_object(status_data)):
|
||||
return None
|
||||
|
||||
return ColorData(
|
||||
type_data=self._color_data_type,
|
||||
h_value=status["h"],
|
||||
s_value=status["s"],
|
||||
v_value=status["v"],
|
||||
h_value=cast(int, status["h"]),
|
||||
s_value=cast(int, status["s"]),
|
||||
v_value=cast(int, status["v"]),
|
||||
)
|
||||
|
||||
@@ -5,14 +5,14 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import struct
|
||||
from typing import Any, Literal, Self, overload
|
||||
from typing import Any, Literal, Self, cast, overload
|
||||
|
||||
from tuya_sharing import CustomerDevice
|
||||
|
||||
from homeassistant.util.json import json_loads, json_loads_object
|
||||
|
||||
from .const import DPCode, DPType
|
||||
from .util import remap_value
|
||||
from .util import parse_dptype, remap_value
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -87,7 +87,7 @@ class IntegerTypeData(TypeInformation):
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
"""Load JSON string and return a IntegerTypeData object."""
|
||||
if not (parsed := json.loads(data)):
|
||||
if not (parsed := cast(dict[str, Any] | None, json_loads_object(data))):
|
||||
return None
|
||||
|
||||
return cls(
|
||||
@@ -110,9 +110,9 @@ class BitmapTypeInformation(TypeInformation):
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
"""Load JSON string and return a BitmapTypeInformation object."""
|
||||
if not (parsed := json.loads(data)):
|
||||
if not (parsed := json_loads_object(data)):
|
||||
return None
|
||||
return cls(dpcode, **parsed)
|
||||
return cls(dpcode, **cast(dict[str, list[str]], parsed))
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -124,9 +124,9 @@ class EnumTypeData(TypeInformation):
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
"""Load JSON string and return a EnumTypeData object."""
|
||||
if not (parsed := json.loads(data)):
|
||||
if not (parsed := json_loads_object(data)):
|
||||
return None
|
||||
return cls(dpcode, **parsed)
|
||||
return cls(dpcode, **cast(dict[str, list[str]], parsed))
|
||||
|
||||
|
||||
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
@@ -134,6 +134,8 @@ _TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
DPType.BOOLEAN: TypeInformation,
|
||||
DPType.ENUM: EnumTypeData,
|
||||
DPType.INTEGER: IntegerTypeData,
|
||||
DPType.JSON: TypeInformation,
|
||||
DPType.RAW: TypeInformation,
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +146,9 @@ class DPCodeWrapper(ABC):
|
||||
access read conversion routines.
|
||||
"""
|
||||
|
||||
native_unit: str | None = None
|
||||
suggested_unit: str | None = None
|
||||
|
||||
def __init__(self, dpcode: str) -> None:
|
||||
"""Init DPCodeWrapper."""
|
||||
self.dpcode = dpcode
|
||||
@@ -196,7 +201,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
def find_dpcode(
|
||||
cls,
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...],
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
) -> Self | None:
|
||||
@@ -210,6 +215,20 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
return None
|
||||
|
||||
|
||||
class DPCodeBase64Wrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
"""Wrapper to extract information from a RAW/binary value."""
|
||||
|
||||
DPTYPE = DPType.RAW
|
||||
|
||||
def read_bytes(self, device: CustomerDevice) -> bytes | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None or (
|
||||
len(decoded := base64.b64decode(raw_value)) == 0
|
||||
):
|
||||
return None
|
||||
return decoded
|
||||
|
||||
|
||||
class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
"""Simple wrapper for boolean values.
|
||||
|
||||
@@ -235,6 +254,18 @@ class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
raise ValueError(f"Invalid boolean value `{value}`")
|
||||
|
||||
|
||||
class DPCodeJsonWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
"""Wrapper to extract information from a JSON value."""
|
||||
|
||||
DPTYPE = DPType.JSON
|
||||
|
||||
def read_json(self, device: CustomerDevice) -> Any | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
return json_loads(raw_value)
|
||||
|
||||
|
||||
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
"""Simple wrapper for EnumTypeData values."""
|
||||
|
||||
@@ -268,6 +299,11 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
|
||||
|
||||
DPTYPE = DPType.INTEGER
|
||||
|
||||
def __init__(self, dpcode: str, type_information: IntegerTypeData) -> None:
|
||||
"""Init DPCodeIntegerWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self.native_unit = type_information.unit
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode.
|
||||
|
||||
@@ -352,6 +388,16 @@ def find_dpcode(
|
||||
) -> IntegerTypeData | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.BOOLEAN, DPType.JSON, DPType.RAW],
|
||||
) -> TypeInformation | None: ...
|
||||
|
||||
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
|
||||
@@ -381,7 +427,7 @@ def find_dpcode(
|
||||
for device_specs in lookup_tuple:
|
||||
if (
|
||||
(current_definition := device_specs.get(dpcode))
|
||||
and current_definition.type == dptype
|
||||
and parse_dptype(current_definition.type) is dptype
|
||||
and (
|
||||
type_information := type_information_cls.from_json(
|
||||
dpcode, current_definition.values
|
||||
@@ -391,44 +437,3 @@ def find_dpcode(
|
||||
return type_information
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ComplexValue:
|
||||
"""Complex value (for JSON/RAW parsing)."""
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> Self:
|
||||
"""Load JSON string and return a ComplexValue object."""
|
||||
raise NotImplementedError("from_json is not implemented for this type")
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, data: str) -> Self | None:
|
||||
"""Decode base64 string and return a ComplexValue object."""
|
||||
raise NotImplementedError("from_raw is not implemented for this type")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElectricityValue(ComplexValue):
|
||||
"""Electricity complex value."""
|
||||
|
||||
electriccurrent: str | None = None
|
||||
power: str | None = None
|
||||
voltage: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> Self:
|
||||
"""Load JSON string and return a ElectricityValue object."""
|
||||
return cls(**json.loads(data.lower()))
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, data: str) -> Self | None:
|
||||
"""Decode base64 string and return a ElectricityValue object."""
|
||||
raw = base64.b64decode(data)
|
||||
if len(raw) == 0:
|
||||
return None
|
||||
voltage = struct.unpack(">H", raw[0:2])[0] / 10.0
|
||||
electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0
|
||||
power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0
|
||||
return cls(
|
||||
electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage)
|
||||
)
|
||||
|
||||
@@ -502,14 +502,19 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
|
||||
self._attr_native_min_value = dpcode_wrapper.type_information.min_scaled
|
||||
self._attr_native_step = dpcode_wrapper.type_information.step_scaled
|
||||
if description.native_unit_of_measurement is None:
|
||||
self._attr_native_unit_of_measurement = dpcode_wrapper.type_information.unit
|
||||
self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit
|
||||
|
||||
self._validate_device_class_unit()
|
||||
|
||||
def _validate_device_class_unit(self) -> None:
|
||||
"""Validate device class unit compatibility."""
|
||||
|
||||
# Logic to ensure the set device class and API received Unit Of Measurement
|
||||
# match Home Assistants requirements.
|
||||
if (
|
||||
self.device_class is not None
|
||||
and not self.device_class.startswith(DOMAIN)
|
||||
and description.native_unit_of_measurement is None
|
||||
and self.entity_description.native_unit_of_measurement is None
|
||||
# we do not need to check mappings if the API UOM is allowed
|
||||
and self.native_unit_of_measurement
|
||||
not in NUMBER_DEVICE_CLASS_UNITS[self.device_class]
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import struct
|
||||
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -42,41 +41,134 @@ from .const import (
|
||||
)
|
||||
from .entity import TuyaEntity
|
||||
from .models import (
|
||||
ComplexValue,
|
||||
ElectricityValue,
|
||||
DPCodeBase64Wrapper,
|
||||
DPCodeEnumWrapper,
|
||||
DPCodeIntegerWrapper,
|
||||
DPCodeJsonWrapper,
|
||||
DPCodeTypeInformationWrapper,
|
||||
DPCodeWrapper,
|
||||
EnumTypeData,
|
||||
IntegerTypeData,
|
||||
find_dpcode,
|
||||
)
|
||||
from .util import get_dptype
|
||||
|
||||
_WIND_DIRECTIONS = {
|
||||
"north": 0.0,
|
||||
"north_north_east": 22.5,
|
||||
"north_east": 45.0,
|
||||
"east_north_east": 67.5,
|
||||
"east": 90.0,
|
||||
"east_south_east": 112.5,
|
||||
"south_east": 135.0,
|
||||
"south_south_east": 157.5,
|
||||
"south": 180.0,
|
||||
"south_south_west": 202.5,
|
||||
"south_west": 225.0,
|
||||
"west_south_west": 247.5,
|
||||
"west": 270.0,
|
||||
"west_north_west": 292.5,
|
||||
"north_west": 315.0,
|
||||
"north_north_west": 337.5,
|
||||
}
|
||||
|
||||
class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
"""Custom DPCode Wrapper for converting enum to wind direction."""
|
||||
|
||||
DPTYPE = DPType.ENUM
|
||||
|
||||
_WIND_DIRECTIONS = {
|
||||
"north": 0.0,
|
||||
"north_north_east": 22.5,
|
||||
"north_east": 45.0,
|
||||
"east_north_east": 67.5,
|
||||
"east": 90.0,
|
||||
"east_south_east": 112.5,
|
||||
"south_east": 135.0,
|
||||
"south_south_east": 157.5,
|
||||
"south": 180.0,
|
||||
"south_south_west": 202.5,
|
||||
"south_west": 225.0,
|
||||
"west_south_west": 247.5,
|
||||
"west": 270.0,
|
||||
"west_north_west": 292.5,
|
||||
"north_west": 315.0,
|
||||
"north_north_west": 337.5,
|
||||
}
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (
|
||||
raw_value := self._read_device_status_raw(device)
|
||||
) in self.type_information.range:
|
||||
return self._WIND_DIRECTIONS.get(raw_value)
|
||||
return None
|
||||
|
||||
|
||||
class _JsonElectricityCurrentWrapper(DPCodeJsonWrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity current from JSON."""
|
||||
|
||||
native_unit = UnitOfElectricCurrent.AMPERE
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_json(device)) is None:
|
||||
return None
|
||||
return raw_value.get("electricCurrent")
|
||||
|
||||
|
||||
class _JsonElectricityPowerWrapper(DPCodeJsonWrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity power from JSON."""
|
||||
|
||||
native_unit = UnitOfPower.KILO_WATT
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_json(device)) is None:
|
||||
return None
|
||||
return raw_value.get("power")
|
||||
|
||||
|
||||
class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity voltage from JSON."""
|
||||
|
||||
native_unit = UnitOfElectricPotential.VOLT
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_json(device)) is None:
|
||||
return None
|
||||
return raw_value.get("voltage")
|
||||
|
||||
|
||||
class _RawElectricityCurrentWrapper(DPCodeBase64Wrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity current from base64."""
|
||||
|
||||
native_unit = UnitOfElectricCurrent.MILLIAMPERE
|
||||
suggested_unit = UnitOfElectricCurrent.AMPERE
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_bytes(device)) is None:
|
||||
return None
|
||||
return struct.unpack(">L", b"\x00" + raw_value[2:5])[0]
|
||||
|
||||
|
||||
class _RawElectricityPowerWrapper(DPCodeBase64Wrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity power from base64."""
|
||||
|
||||
native_unit = UnitOfPower.WATT
|
||||
suggested_unit = UnitOfPower.KILO_WATT
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_bytes(device)) is None:
|
||||
return None
|
||||
return struct.unpack(">L", b"\x00" + raw_value[5:8])[0]
|
||||
|
||||
|
||||
class _RawElectricityVoltageWrapper(DPCodeBase64Wrapper):
|
||||
"""Custom DPCode Wrapper for extracting electricity voltage from base64."""
|
||||
|
||||
native_unit = UnitOfElectricPotential.VOLT
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> float | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := super().read_bytes(device)) is None:
|
||||
return None
|
||||
return struct.unpack(">H", raw_value[0:2])[0] / 10.0
|
||||
|
||||
|
||||
CURRENT_WRAPPER = (_RawElectricityCurrentWrapper, _JsonElectricityCurrentWrapper)
|
||||
POWER_WRAPPER = (_RawElectricityPowerWrapper, _JsonElectricityPowerWrapper)
|
||||
VOLTAGE_WRAPPER = (_RawElectricityVoltageWrapper, _JsonElectricityVoltageWrapper)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TuyaSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Tuya sensor entity."""
|
||||
|
||||
complex_type: type[ComplexValue] | None = None
|
||||
subkey: str | None = None
|
||||
state_conversion: Callable[[Any], StateType] | None = None
|
||||
dpcode: DPCode | None = None
|
||||
wrapper_class: tuple[type[DPCodeTypeInformationWrapper], ...] | None = None
|
||||
|
||||
|
||||
# Commonly used battery sensors, that are reused in the sensors down below.
|
||||
@@ -394,85 +486,76 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}electriccurrent",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}power",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}voltage",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}electriccurrent",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}power",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}voltage",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}electriccurrent",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}power",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}voltage",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.CUR_CURRENT,
|
||||
@@ -972,7 +1055,7 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
translation_key="wind_direction",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_conversion=lambda state: _WIND_DIRECTIONS.get(str(state)),
|
||||
wrapper_class=(_WindDirectionWrapper,),
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.DEW_POINT_TEMP,
|
||||
@@ -1485,12 +1568,11 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.TOTAL_POWER,
|
||||
key=f"{DPCode.TOTAL_POWER}power",
|
||||
dpcode=DPCode.TOTAL_POWER,
|
||||
translation_key="total_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.SUPPLY_FREQUENCY,
|
||||
@@ -1500,85 +1582,76 @@ SENSORS: dict[DeviceCategory, tuple[TuyaSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}electriccurrent",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}power",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_A,
|
||||
key=f"{DPCode.PHASE_A}voltage",
|
||||
dpcode=DPCode.PHASE_A,
|
||||
translation_key="phase_a_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}electriccurrent",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}power",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_B,
|
||||
key=f"{DPCode.PHASE_B}voltage",
|
||||
dpcode=DPCode.PHASE_B,
|
||||
translation_key="phase_b_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}electriccurrent",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_current",
|
||||
device_class=SensorDeviceClass.CURRENT,
|
||||
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="electriccurrent",
|
||||
wrapper_class=CURRENT_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}power",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_power",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.KILO_WATT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="power",
|
||||
wrapper_class=POWER_WRAPPER,
|
||||
),
|
||||
TuyaSensorEntityDescription(
|
||||
key=DPCode.PHASE_C,
|
||||
key=f"{DPCode.PHASE_C}voltage",
|
||||
dpcode=DPCode.PHASE_C,
|
||||
translation_key="phase_c_voltage",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
complex_type=ElectricityValue,
|
||||
subkey="voltage",
|
||||
wrapper_class=VOLTAGE_WRAPPER,
|
||||
),
|
||||
),
|
||||
DeviceCategory.ZNNBQ: (
|
||||
@@ -1639,6 +1712,27 @@ SENSORS[DeviceCategory.DGHSXJ] = SENSORS[DeviceCategory.SP]
|
||||
SENSORS[DeviceCategory.PC] = SENSORS[DeviceCategory.KG]
|
||||
|
||||
|
||||
def _get_dpcode_wrapper(
|
||||
device: CustomerDevice,
|
||||
description: TuyaSensorEntityDescription,
|
||||
) -> DPCodeWrapper | None:
|
||||
"""Get DPCode wrapper for an entity description."""
|
||||
dpcode = description.dpcode or description.key
|
||||
wrapper: DPCodeWrapper | None
|
||||
|
||||
if description.wrapper_class:
|
||||
for cls in description.wrapper_class:
|
||||
if wrapper := cls.find_dpcode(device, dpcode):
|
||||
return wrapper
|
||||
return None
|
||||
|
||||
for cls in (DPCodeIntegerWrapper, DPCodeEnumWrapper):
|
||||
if wrapper := cls.find_dpcode(device, dpcode):
|
||||
return wrapper
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TuyaConfigEntry,
|
||||
@@ -1655,9 +1749,9 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := SENSORS.get(device.category):
|
||||
entities.extend(
|
||||
TuyaSensorEntity(device, manager, description)
|
||||
TuyaSensorEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -1673,35 +1767,25 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
|
||||
"""Tuya Sensor Entity."""
|
||||
|
||||
entity_description: TuyaSensorEntityDescription
|
||||
|
||||
_type: DPType | None = None
|
||||
_type_data: IntegerTypeData | EnumTypeData | None = None
|
||||
_dpcode_wrapper: DPCodeWrapper
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: TuyaSensorEntityDescription,
|
||||
dpcode_wrapper: DPCodeWrapper,
|
||||
) -> None:
|
||||
"""Init Tuya sensor."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = (
|
||||
f"{super().unique_id}{description.key}{description.subkey or ''}"
|
||||
)
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
|
||||
if int_type := find_dpcode(self.device, description.key, dptype=DPType.INTEGER):
|
||||
self._type_data = int_type
|
||||
self._type = DPType.INTEGER
|
||||
if description.native_unit_of_measurement is None:
|
||||
self._attr_native_unit_of_measurement = int_type.unit
|
||||
elif enum_type := find_dpcode(
|
||||
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
|
||||
):
|
||||
self._type_data = enum_type
|
||||
self._type = DPType.ENUM
|
||||
else:
|
||||
self._type = get_dptype(self.device, DPCode(description.key))
|
||||
if description.native_unit_of_measurement is None:
|
||||
self._attr_native_unit_of_measurement = dpcode_wrapper.native_unit
|
||||
if description.suggested_unit_of_measurement is None:
|
||||
self._attr_suggested_unit_of_measurement = dpcode_wrapper.suggested_unit
|
||||
|
||||
self._validate_device_class_unit()
|
||||
|
||||
@@ -1752,55 +1836,4 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
# Only continue if data type is known
|
||||
if self._type not in (
|
||||
DPType.INTEGER,
|
||||
DPType.STRING,
|
||||
DPType.ENUM,
|
||||
DPType.JSON,
|
||||
DPType.RAW,
|
||||
):
|
||||
return None
|
||||
|
||||
# Raw value
|
||||
value = self.device.status.get(self.entity_description.key)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
# Convert value, if required
|
||||
if (convert := self.entity_description.state_conversion) is not None:
|
||||
return convert(value)
|
||||
|
||||
# Scale integer/float value
|
||||
if isinstance(self._type_data, IntegerTypeData):
|
||||
return self._type_data.scale_value(value)
|
||||
|
||||
# Unexpected enum value
|
||||
if (
|
||||
isinstance(self._type_data, EnumTypeData)
|
||||
and value not in self._type_data.range
|
||||
):
|
||||
return None
|
||||
|
||||
# Get subkey value from Json string.
|
||||
if self._type is DPType.JSON:
|
||||
if (
|
||||
self.entity_description.complex_type is None
|
||||
or self.entity_description.subkey is None
|
||||
):
|
||||
return None
|
||||
values = self.entity_description.complex_type.from_json(value)
|
||||
return getattr(values, self.entity_description.subkey)
|
||||
|
||||
if self._type is DPType.RAW:
|
||||
if (
|
||||
self.entity_description.complex_type is None
|
||||
or self.entity_description.subkey is None
|
||||
or (raw_values := self.entity_description.complex_type.from_raw(value))
|
||||
is None
|
||||
):
|
||||
return None
|
||||
return getattr(raw_values, self.entity_description.subkey)
|
||||
|
||||
# Valid string or enum value
|
||||
return value
|
||||
return self._dpcode_wrapper.read_device_status(self.device)
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeBooleanWrapper
|
||||
|
||||
SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = {
|
||||
DeviceCategory.CO2BJ: (
|
||||
@@ -64,9 +65,13 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := SIRENS.get(device.category):
|
||||
entities.extend(
|
||||
TuyaSirenEntity(device, manager, description)
|
||||
TuyaSirenEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -89,21 +94,23 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: SirenEntityDescription,
|
||||
dpcode_wrapper: DPCodeBooleanWrapper,
|
||||
) -> None:
|
||||
"""Init Tuya Siren."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if siren is on."""
|
||||
return self.device.status.get(self.entity_description.key, False)
|
||||
return self._dpcode_wrapper.read_device_status(self.device)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the siren on."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": True}])
|
||||
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the siren off."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": False}])
|
||||
await self._async_send_dpcode_update(self._dpcode_wrapper, False)
|
||||
|
||||
@@ -42,6 +42,16 @@ def get_dpcode(
|
||||
return None
|
||||
|
||||
|
||||
def parse_dptype(dptype: str) -> DPType | None:
|
||||
"""Parse DPType from device DPCode information."""
|
||||
try:
|
||||
return DPType(dptype)
|
||||
except ValueError:
|
||||
# Sometimes, we get ill-formed DPTypes from the cloud,
|
||||
# this fixes them and maps them to the correct DPType.
|
||||
return _DPTYPE_MAPPING.get(dptype)
|
||||
|
||||
|
||||
def get_dptype(
|
||||
device: CustomerDevice, dpcode: DPCode | None, *, prefer_function: bool = False
|
||||
) -> DPType | None:
|
||||
@@ -57,13 +67,7 @@ def get_dptype(
|
||||
|
||||
for device_specs in lookup_tuple:
|
||||
if current_definition := device_specs.get(dpcode):
|
||||
current_type = current_definition.type
|
||||
try:
|
||||
return DPType(current_type)
|
||||
except ValueError:
|
||||
# Sometimes, we get ill-formed DPTypes from the cloud,
|
||||
# this fixes them and maps them to the correct DPType.
|
||||
return _DPTYPE_MAPPING.get(current_type)
|
||||
return parse_dptype(current_definition.type)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
from uasiren.client import Client
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_REGION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import UkraineAlarmDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Ukraine Alarm as config entry."""
|
||||
@@ -30,3 +40,56 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug("Migrating from version %s", config_entry.version)
|
||||
|
||||
if config_entry.version == 1:
|
||||
# Version 1 had states as first-class selections
|
||||
# Version 2 only allows states w/o districts, districts and communities
|
||||
region_id = config_entry.data[CONF_REGION]
|
||||
|
||||
websession = async_get_clientsession(hass)
|
||||
try:
|
||||
regions_data = await Client(websession).get_regions()
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
_LOGGER.warning(
|
||||
"Could not migrate config entry %s: failed to fetch current regions: %s",
|
||||
config_entry.entry_id,
|
||||
err,
|
||||
)
|
||||
return False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(regions_data, dict)
|
||||
|
||||
state_with_districts = None
|
||||
for state in regions_data["states"]:
|
||||
if state["regionId"] == region_id and state.get("regionChildIds"):
|
||||
state_with_districts = state
|
||||
break
|
||||
|
||||
if state_with_districts:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_state_region_{config_entry.entry_id}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_state_region",
|
||||
translation_placeholders={
|
||||
"region_name": config_entry.data.get(CONF_NAME, region_id),
|
||||
},
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
hass.config_entries.async_update_entry(config_entry, version=2)
|
||||
_LOGGER.info("Migration to version %s successful", 2)
|
||||
return True
|
||||
|
||||
_LOGGER.error("Unknown version %s", config_entry.version)
|
||||
return False
|
||||
|
||||
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Ukraine Alarm."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize a new UkraineAlarmConfigFlow."""
|
||||
@@ -112,7 +112,7 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return await self._async_finish_flow()
|
||||
|
||||
regions = {}
|
||||
if self.selected_region:
|
||||
if self.selected_region and step_id != "district":
|
||||
regions[self.selected_region["regionId"]] = self.selected_region[
|
||||
"regionName"
|
||||
]
|
||||
|
||||
@@ -13,19 +13,19 @@
|
||||
"data": {
|
||||
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
|
||||
},
|
||||
"description": "If you want to monitor not only state and district, choose its specific community"
|
||||
"description": "Choose the district you selected above or select a specific community within that district"
|
||||
},
|
||||
"district": {
|
||||
"data": {
|
||||
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
|
||||
},
|
||||
"description": "If you want to monitor not only state, choose its specific district"
|
||||
"description": "Choose a district to monitor within the selected state"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"region": "Region"
|
||||
},
|
||||
"description": "Choose state to monitor"
|
||||
"description": "Choose a state"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -50,5 +50,11 @@
|
||||
"name": "Urban fights"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_state_region": {
|
||||
"description": "The region `{region_name}` is a state-level region, which is no longer supported. Please remove this integration entry and add it again, selecting a district or community instead of the entire state.",
|
||||
"title": "State-level region monitoring is no longer supported"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
"""Support for VELUX KLF 200 devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyvlx import PyVLX, PyVLXException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_PASSWORD,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
|
||||
from .const import DOMAIN, LOGGER, PLATFORMS
|
||||
|
||||
type VeluxConfigEntry = ConfigEntry[PyVLX]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
|
||||
"""Set up the velux component."""
|
||||
host = entry.data[CONF_HOST]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
@@ -27,12 +35,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
entry.runtime_data = pyvlx
|
||||
|
||||
connections = None
|
||||
if (mac := entry.data.get(CONF_MAC)) is not None:
|
||||
connections = {(dr.CONNECTION_NETWORK_MAC, mac)}
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, f"gateway_{entry.entry_id}")},
|
||||
name="KLF 200 Gateway",
|
||||
manufacturer="Velux",
|
||||
model="KLF 200",
|
||||
hw_version=(
|
||||
str(pyvlx.klf200.version.hardwareversion) if pyvlx.klf200.version else None
|
||||
),
|
||||
sw_version=(
|
||||
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
|
||||
),
|
||||
connections=connections,
|
||||
)
|
||||
|
||||
async def on_hass_stop(event):
|
||||
"""Close connection when hass stops."""
|
||||
LOGGER.debug("Velux interface terminated")
|
||||
await pyvlx.disconnect()
|
||||
|
||||
async def async_reboot_gateway(service_call: ServiceCall) -> None:
|
||||
"""Reboot the gateway (deprecated - use button entity instead)."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_reboot_service",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_reboot_service",
|
||||
breaks_in_ha_version="2026.6.0",
|
||||
)
|
||||
|
||||
await pyvlx.reboot_gateway()
|
||||
|
||||
entry.async_on_unload(
|
||||
@@ -46,6 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -24,14 +24,14 @@ SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: VeluxConfigEntry,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up rain sensor(s) for Velux platform."""
|
||||
pyvlx = config.runtime_data
|
||||
pyvlx = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
VeluxRainSensor(node, config.entry_id)
|
||||
VeluxRainSensor(node, config_entry.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, Window) and node.rain_sensor
|
||||
)
|
||||
|
||||
54
homeassistant/components/velux/button.py
Normal file
54
homeassistant/components/velux/button.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Support for VELUX KLF 200 gateway button."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyvlx import PyVLX, PyVLXException
|
||||
|
||||
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import VeluxConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up button entities for the Velux integration."""
|
||||
async_add_entities(
|
||||
[VeluxGatewayRebootButton(config_entry.entry_id, config_entry.runtime_data)]
|
||||
)
|
||||
|
||||
|
||||
class VeluxGatewayRebootButton(ButtonEntity):
|
||||
"""Representation of the Velux Gateway reboot button."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_device_class = ButtonDeviceClass.RESTART
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self, config_entry_id: str, pyvlx: PyVLX) -> None:
|
||||
"""Initialize the gateway reboot button."""
|
||||
self.pyvlx = pyvlx
|
||||
self._attr_unique_id = f"{config_entry_id}_reboot-gateway"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"gateway_{config_entry_id}")},
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press - reboot the gateway."""
|
||||
try:
|
||||
await self.pyvlx.reboot_gateway()
|
||||
except PyVLXException as ex:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="reboot_failed",
|
||||
) from ex
|
||||
@@ -85,7 +85,7 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
updates={CONF_HOST: self.discovery_data[CONF_HOST]}
|
||||
)
|
||||
|
||||
# Abort if config_entry already exists without unigue_id configured.
|
||||
# Abort if config_entry already exists without unique_id configured.
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if (
|
||||
entry.data[CONF_HOST] == self.discovery_data[CONF_HOST]
|
||||
|
||||
@@ -5,5 +5,11 @@ from logging import getLogger
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "velux"
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE]
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.COVER,
|
||||
Platform.LIGHT,
|
||||
Platform.SCENE,
|
||||
]
|
||||
LOGGER = getLogger(__package__)
|
||||
|
||||
@@ -32,13 +32,13 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: VeluxConfigEntry,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up cover(s) for Velux platform."""
|
||||
pyvlx = config.runtime_data
|
||||
pyvlx = config_entry.runtime_data
|
||||
async_add_entities(
|
||||
VeluxCover(node, config.entry_id)
|
||||
VeluxCover(node, config_entry.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, OpeningDevice)
|
||||
)
|
||||
|
||||
@@ -18,22 +18,23 @@ class VeluxEntity(Entity):
|
||||
def __init__(self, node: Node, config_entry_id: str) -> None:
|
||||
"""Initialize the Velux device."""
|
||||
self.node = node
|
||||
self._attr_unique_id = (
|
||||
unique_id = (
|
||||
node.serial_number
|
||||
if node.serial_number
|
||||
else f"{config_entry_id}_{node.node_id}"
|
||||
)
|
||||
self._attr_unique_id = unique_id
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
node.serial_number
|
||||
if node.serial_number
|
||||
else f"{config_entry_id}_{node.node_id}",
|
||||
unique_id,
|
||||
)
|
||||
},
|
||||
name=node.name if node.name else f"#{node.node_id}",
|
||||
serial_number=node.serial_number,
|
||||
via_device=(DOMAIN, f"gateway_{config_entry_id}"),
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -18,13 +18,13 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config: VeluxConfigEntry,
|
||||
config_entry: VeluxConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up light(s) for Velux platform."""
|
||||
pyvlx = config.runtime_data
|
||||
pyvlx = config_entry.runtime_data
|
||||
async_add_entities(
|
||||
VeluxLight(node, config.entry_id)
|
||||
VeluxLight(node, config_entry.entry_id)
|
||||
for node in pyvlx.nodes
|
||||
if isinstance(node, LighteningDevice)
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user