Compare commits

..

37 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
9defa5248b available
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-19 09:59:18 +01:00
Daniel Hjelseth Høyer
6934737258 Merge branch 'dev' into tibber_data 2025-12-19 09:58:28 +01:00
Daniel Hjelseth Høyer
9e0fb2ca0e Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-16 06:10:37 +01:00
Daniel Hjelseth Høyer
da75c36e0e Merge branch 'dev' into tibber_data 2025-12-15 06:39:44 +01:00
Daniel Hjelseth Høyer
b604f26d16 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-14 19:37:22 +01:00
Daniel Hjelseth Høyer
80dfa73ebc Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-14 07:49:28 +01:00
Daniel Hjelseth Høyer
bc41c12fad Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:28 +01:00
Daniel Hjelseth Høyer
4ed9b54d60 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:28 +01:00
Daniel Hjelseth Høyer
072f80d847 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:28 +01:00
Daniel Hjelseth Høyer
7316b8c4e1 test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:28 +01:00
Daniel Hjelseth Høyer
2d877eea79 Update homeassistant/components/tibber/sensor.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
b99d4cdeca tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
4665cbe949 adjust available state
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
9db0ab3afe config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
60b4baf852 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
82495606fd config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
f9e7e61df0 fix strings
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:27 +01:00
Daniel Hjelseth Høyer
ef96e7f47c config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:26 +01:00
Daniel Hjelseth Høyer
84b54f1546 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:26 +01:00
Daniel Hjelseth Høyer
dd9b86734f config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:26 +01:00
Daniel Hjelseth Høyer
6b52566614 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:26 +01:00
Daniel Hjelseth Høyer
76aa60a804 test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
8d068374aa test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
1cea6285a3 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
9dac4b35b9 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
4329a10e17 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
2270f353d6 tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
fe03355717 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:25 +01:00
Daniel Hjelseth Høyer
2141181ccc Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
203d6b8e88 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
3a3cf25eca Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
772571dbff Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
f4395aaa38 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
8056cfc9f4 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
1d576dca3c Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:24 +01:00
Daniel Hjelseth Høyer
1d3cef96a9 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:23 +01:00
Daniel Hjelseth Høyer
0797ace385 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-12 13:49:23 +01:00
159 changed files with 2413 additions and 8628 deletions

View File

@@ -551,7 +551,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3.1.0
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}

2
CODEOWNERS generated
View File

@@ -794,8 +794,6 @@ build.json @home-assistant/supervisor
/tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs

View File

@@ -402,8 +402,6 @@ class AuthManager:
if user.is_owner:
raise ValueError("Unable to deactivate the owner")
await self._store.async_deactivate_user(user)
for refresh_token in list(user.refresh_tokens.values()):
self.async_remove_refresh_token(refresh_token)
async def async_remove_credentials(self, credentials: models.Credentials) -> None:
"""Remove credentials."""

View File

@@ -45,7 +45,7 @@ def make_entity_state_trigger_required_features(
"""Trigger for entity state changes."""
_domain = domain
_to_states = {to_state}
_to_state = to_state
_required_features = required_features
return CustomTrigger

View File

@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
}

View File

@@ -27,7 +27,6 @@ from homeassistant.const import (
CONF_EVENT_DATA,
CONF_ID,
CONF_MODE,
CONF_OPTIONS,
CONF_PATH,
CONF_PLATFORM,
CONF_TRIGGERS,
@@ -131,12 +130,9 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"cover",
"device_tracker",
"fan",
"humidifier",
"lawn_mower",
"light",
"lock",
"media_player",
"siren",
"switch",
"text",
"update",
@@ -1218,7 +1214,7 @@ def _trigger_extract_entities(trigger_conf: dict) -> list[str]:
return trigger_conf[CONF_ENTITY_ID] # type: ignore[no-any-return]
if trigger_conf[CONF_PLATFORM] == "calendar":
return [trigger_conf[CONF_OPTIONS][CONF_ENTITY_ID]]
return [trigger_conf[CONF_ENTITY_ID]]
if trigger_conf[CONF_PLATFORM] == "zone":
return trigger_conf[CONF_ENTITY_ID] + [trigger_conf[CONF_ZONE]] # type: ignore[no-any-return]

View File

@@ -47,7 +47,7 @@ def make_binary_sensor_trigger(
"""Trigger for entity state changes."""
_device_class = device_class
_to_states = {to_state}
_to_state = to_state
return CustomTrigger

View File

@@ -2,30 +2,29 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Coroutine
from dataclasses import dataclass
import datetime
import logging
from typing import TYPE_CHECKING, Any, cast
from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_OPTIONS
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_OFFSET, CONF_PLATFORM
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import move_top_level_schema_fields_to_options
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_time_interval,
)
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from . import CalendarEntity, CalendarEvent
from .const import DATA_COMPONENT
from .const import DATA_COMPONENT, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,17 +32,13 @@ EVENT_START = "start"
EVENT_END = "end"
UPDATE_INTERVAL = datetime.timedelta(minutes=15)
_OPTIONS_SCHEMA_DICT = {
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
}
_CONFIG_SCHEMA = vol.Schema(
TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): _OPTIONS_SCHEMA_DICT,
},
vol.Required(CONF_PLATFORM): DOMAIN,
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_EVENT, default=EVENT_START): vol.In({EVENT_START, EVENT_END}),
vol.Optional(CONF_OFFSET, default=datetime.timedelta(0)): cv.time_period,
}
)
# mypy: disallow-any-generics
@@ -174,14 +169,14 @@ class CalendarEventListener:
def __init__(
self,
hass: HomeAssistant,
action_runner: TriggerActionRunner,
trigger_payload: dict[str, Any],
job: HassJob[..., Coroutine[Any, Any, None] | Any],
trigger_data: dict[str, Any],
fetcher: QueuedEventFetcher,
) -> None:
"""Initialize CalendarEventListener."""
self._hass = hass
self._action_runner = action_runner
self._trigger_payload = trigger_payload
self._job = job
self._trigger_data = trigger_data
self._unsub_event: CALLBACK_TYPE | None = None
self._unsub_refresh: CALLBACK_TYPE | None = None
self._fetcher = fetcher
@@ -238,11 +233,15 @@ class CalendarEventListener:
while self._events and self._events[0].trigger_time <= now:
queued_event = self._events.pop(0)
_LOGGER.debug("Dispatching event: %s", queued_event.event)
payload = {
**self._trigger_payload,
"calendar_event": queued_event.event.as_dict(),
}
self._action_runner(payload, "calendar event state change")
self._hass.async_run_hass_job(
self._job,
{
"trigger": {
**self._trigger_data,
"calendar_event": queued_event.event.as_dict(),
}
},
)
async def _handle_refresh(self, now_utc: datetime.datetime) -> None:
"""Handle core config update."""
@@ -260,69 +259,31 @@ class CalendarEventListener:
self._listen_next_calendar_event()
class EventTrigger(Trigger):
"""Calendar event trigger."""
async def async_attach_trigger(
hass: HomeAssistant,
config: ConfigType,
action: TriggerActionType,
trigger_info: TriggerInfo,
) -> CALLBACK_TYPE:
"""Attach trigger for the specified calendar."""
entity_id = config[CONF_ENTITY_ID]
event_type = config[CONF_EVENT]
offset = config[CONF_OFFSET]
_options: dict[str, Any]
# Validate the entity id is valid
get_entity(hass, entity_id)
@classmethod
async def async_validate_complete_config(
cls, hass: HomeAssistant, complete_config: ConfigType
) -> ConfigType:
"""Validate complete config."""
complete_config = move_top_level_schema_fields_to_options(
complete_config, _OPTIONS_SCHEMA_DICT
)
return await super().async_validate_complete_config(hass, complete_config)
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, _CONFIG_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
self._options = config.options
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
entity_id = self._options[CONF_ENTITY_ID]
event_type = self._options[CONF_EVENT]
offset = self._options[CONF_OFFSET]
# Validate the entity id is valid
get_entity(self._hass, entity_id)
trigger_data = {
"event": event_type,
"offset": offset,
}
listener = CalendarEventListener(
self._hass,
run_action,
trigger_data,
queued_event_fetcher(
event_fetcher(self._hass, entity_id), event_type, offset
),
)
await listener.async_attach()
return listener.async_detach
TRIGGERS: dict[str, type[Trigger]] = {
"_": EventTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for calendars."""
return TRIGGERS
trigger_data = {
**trigger_info["trigger_data"],
"platform": DOMAIN,
"event": event_type,
"offset": offset,
}
listener = CalendarEventListener(
hass,
HassJob(action),
trigger_data,
queued_event_fetcher(event_fetcher(hass, entity_id), event_type, offset),
)
await listener.async_attach()
return listener.async_detach

View File

@@ -98,9 +98,6 @@
}
},
"triggers": {
"hvac_mode_changed": {
"trigger": "mdi:thermostat"
},
"started_cooling": {
"trigger": "mdi:snowflake"
},
@@ -110,12 +107,6 @@
"started_heating": {
"trigger": "mdi:fire"
},
"target_temperature_changed": {
"trigger": "mdi:thermometer"
},
"target_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"turned_off": {
"trigger": "mdi:power-off"
},

View File

@@ -192,26 +192,12 @@
"off": "[%key:common::state::off%]"
}
},
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above a value",
"below": "Below a value",
"between": "In a range",
"outside": "Outside a range"
}
}
},
"services": {
@@ -312,20 +298,6 @@
},
"title": "Climate",
"triggers": {
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"hvac_mode": {
"description": "The HVAC modes to trigger on.",
"name": "Modes"
}
},
"name": "Climate-control device mode changed"
},
"started_cooling": {
"description": "Triggers after one or more climate-control devices start cooling.",
"fields": {
@@ -356,42 +328,6 @@
},
"name": "Climate-control device started heating"
},
"target_temperature_changed": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the target temperature is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the target temperature is below this value.",
"name": "Below"
}
},
"name": "Climate-control device target temperature changed"
},
"target_temperature_crossed_threshold": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device target temperature crossed threshold"
},
"turned_off": {
"description": "Triggers after one or more climate-control devices turn off.",
"fields": {

View File

@@ -1,17 +1,8 @@
"""Provides triggers for climates."""
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
@@ -19,45 +10,13 @@ from homeassistant.helpers.trigger import (
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_HVAC_MODE): vol.All(
cv.ensure_list, vol.Length(min=1), [HVACMode]
),
},
}
)
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domain = DOMAIN
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
self._to_states = set(self._options[CONF_HVAC_MODE])
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_TEMPERATURE
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_TEMPERATURE
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,

View File

@@ -1,9 +1,9 @@
.trigger_common: &trigger_common
target: &trigger_climate_target
target:
entity:
domain: climate
fields:
behavior: &trigger_behavior
behavior:
required: true
default: any
selector:
@@ -14,67 +14,8 @@
- last
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
entity:
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
number:
selector:
number:
mode: box
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
started_cooling: *trigger_common
started_drying: *trigger_common
started_heating: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common
hvac_mode_changed:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
hvac_mode:
context:
filter_target: target
required: true
selector:
state:
hide_states:
- unavailable
- unknown
multiple: true
target_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
target_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity

View File

@@ -110,7 +110,7 @@ async def async_register_dynalite_frontend(hass: HomeAssistant):
frontend_url_path=DOMAIN,
config_panel_domain=DOMAIN,
webcomponent_name="dynalite-panel",
module_url=f"{URL_BASE}/entrypoint.{build_id}.js",
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
embed_iframe=True,
require_admin=True,
)

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251203.3"]
"requirements": ["home-assistant-frontend==20251203.2"]
}

View File

@@ -101,15 +101,6 @@ def _is_location_already_configured(
return False
def _is_location_name_already_configured(hass: HomeAssistant, new_data: str) -> bool:
"""Check if the location name is already configured."""
for entry in hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if subentry.title.lower() == new_data.lower():
return True
return False
class GoogleAirQualityConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google AirQuality."""
@@ -187,19 +178,8 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow):
description_placeholders: dict[str, str] = {}
if user_input is not None:
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
errors["base"] = "location_already_configured"
if _is_location_name_already_configured(self.hass, user_input[CONF_NAME]):
errors["base"] = "location_name_already_configured"
return self.async_abort(reason="already_configured")
api: GoogleAirQualityApi = self._get_entry().runtime_data.api
if errors:
return self.async_show_form(
step_id="location",
data_schema=self.add_suggested_values_to_schema(
_get_location_schema(self.hass), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title=user_input[CONF_NAME],

View File

@@ -47,12 +47,12 @@
"config_subentries": {
"location": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"unable_to_fetch": "[%key:component::google_air_quality::common::unable_to_fetch%]"
},
"entry_type": "Air quality location",
"error": {
"location_already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"location_name_already_configured": "Location name already configured.",
"no_data_for_location": "Information is unavailable for this location. Please try a different location.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {

View File

@@ -16,7 +16,6 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import callback
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import (
ABORT_NO_PLANTS,
@@ -24,13 +23,12 @@ from .const import (
AUTH_PASSWORD,
CONF_AUTH_TYPE,
CONF_PLANT_ID,
CONF_REGION,
DEFAULT_URL,
DOMAIN,
ERROR_CANNOT_CONNECT,
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS_NAMES,
SERVER_URLS,
)
_LOGGER = logging.getLogger(__name__)
@@ -69,13 +67,10 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
self.auth_type = AUTH_PASSWORD
# Traditional username/password authentication
# Convert region name to URL - guaranteed to exist since vol.In validates it
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
self.api = growattServer.GrowattApi(
add_random_user_id=True, agent_identifier=user_input[CONF_USERNAME]
)
self.api.server_url = server_url
self.api.server_url = user_input[CONF_URL]
try:
login_response = await self.hass.async_add_executor_job(
@@ -96,8 +91,6 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
self.user_id = login_response["user"]["id"]
self.data = user_input
# Store the actual URL, not the region name
self.data[CONF_URL] = server_url
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
@@ -111,11 +104,8 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
self.auth_type = AUTH_API_TOKEN
# Using token authentication
# Convert region name to URL - guaranteed to exist since vol.In validates it
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
self.api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
self.api.server_url = server_url
token = user_input[CONF_TOKEN]
self.api = growattServer.OpenApiV1(token=token)
# Verify token by fetching plant list
try:
@@ -137,8 +127,6 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
self.data = user_input
# Store the actual URL, not the region name
self.data[CONF_URL] = server_url
self.data[CONF_AUTH_TYPE] = self.auth_type
return await self.async_step_plant()
@@ -151,12 +139,7 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION, default=DEFAULT_URL): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
vol.Required(CONF_URL, default=DEFAULT_URL): vol.In(SERVER_URLS),
}
)
@@ -172,12 +155,6 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_REGION, default=DEFAULT_URL): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)

View File

@@ -3,7 +3,6 @@
from homeassistant.const import Platform
CONF_PLANT_ID = "plant_id"
CONF_REGION = "region"
# API key support
@@ -19,14 +18,13 @@ DEFAULT_PLANT_ID = "0"
DEFAULT_NAME = "Growatt"
SERVER_URLS_NAMES = {
"north_america": "https://openapi-us.growatt.com/",
"australia_new_zealand": "https://openapi-au.growatt.com/",
"china": "https://openapi-cn.growatt.com/",
"other_regions": "https://openapi.growatt.com/",
"smten_server": "http://server.smten.com/",
"era_server": "http://ess-server.atesspower.com/",
}
SERVER_URLS = [
"https://openapi.growatt.com/", # Other regional server
"https://openapi-cn.growatt.com/", # Chinese server
"https://openapi-us.growatt.com/", # North American server
"https://openapi-au.growatt.com/", # Australia Server
"http://server.smten.com/", # smten server
]
DEPRECATED_URLS = [
"https://server.growatt.com/",
@@ -34,7 +32,7 @@ DEPRECATED_URLS = [
"https://server-us.growatt.com/",
]
DEFAULT_URL = "other_regions"
DEFAULT_URL = SERVER_URLS[0]
DOMAIN = "growatt_server"

View File

@@ -24,7 +24,9 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-configuration-parameters:
status: todo
comment: Update server URL dropdown to show regional descriptions (e.g., 'China', 'United States') instead of raw URLs.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done

View File

@@ -13,7 +13,7 @@
"password_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"url": "Server region",
"url": "[%key:common::config_flow::data::url%]",
"username": "[%key:common::config_flow::data::username%]"
},
"title": "Enter your Growatt login credentials"
@@ -26,8 +26,7 @@
},
"token_auth": {
"data": {
"token": "API Token",
"url": "Server region"
"token": "API Token"
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
@@ -531,16 +530,6 @@
"grid_first": "Grid first",
"load_first": "Load first"
}
},
"region": {
"options": {
"australia_new_zealand": "Australia and New Zealand",
"china": "China",
"era_server": "Era server (Atess Power)",
"north_america": "North America",
"other_regions": "Other regions",
"smten_server": "SMTEN server"
}
}
},
"services": {

View File

@@ -70,16 +70,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
device_type=device_type,
)
# For NVRs or devices with no detected events, try to fetch events from ISAPI
if device_type == "NVR" or not camera.current_event_states:
def fetch_and_inject_nvr_events() -> None:
"""Fetch and inject NVR events in a single executor job."""
if nvr_events := camera.get_event_triggers(None):
camera.inject_events(nvr_events)
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
# Start the event stream
await hass.async_add_executor_job(camera.start_stream)

View File

@@ -65,11 +65,6 @@ BINARY_SENSORS = (
},
translation_key="charging_connection",
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.BSH_COMMON_INTERIOR_ILLUMINATION_ACTIVE,
translation_key="interior_illumination_active",
device_class=BinarySensorDeviceClass.LIGHT,
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED,
translation_key="dust_box_inserted",

View File

@@ -270,10 +270,6 @@ WARMING_LEVEL_OPTIONS = {
)
}
RINSE_PLUS_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in ("LaundryCare.Washer.EnumType.RinsePlus.Off",)
}
TEMPERATURE_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
@@ -313,12 +309,6 @@ SPIN_SPEED_OPTIONS = {
)
}
STAINS_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in ("LaundryCare.Washer.EnumType.Stains.Off",)
}
VARIO_PERFECT_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
@@ -373,10 +363,8 @@ PROGRAM_ENUM_OPTIONS = {
(OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS),
(OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS),
(OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_RINSE_PLUS, RINSE_PLUS_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS),
(OptionKey.LAUNDRY_CARE_WASHER_STAINS, STAINS_OPTIONS),
(OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS),
)
}

View File

@@ -4,12 +4,6 @@
"dust_box_inserted": {
"default": "mdi:download"
},
"interior_illumination_active": {
"default": "mdi:lightbulb-on",
"state": {
"off": "mdi:lightbulb-off"
}
},
"lifted": {
"default": "mdi:arrow-up-right-bold"
},

View File

@@ -29,9 +29,7 @@ from .const import (
HOT_WATER_TEMPERATURE_OPTIONS,
INTENSIVE_LEVEL_OPTIONS,
PROGRAMS_TRANSLATION_KEYS_MAP,
RINSE_PLUS_OPTIONS,
SPIN_SPEED_OPTIONS,
STAINS_OPTIONS,
SUCTION_POWER_OPTIONS,
TEMPERATURE_OPTIONS,
TRANSLATION_KEYS_PROGRAMS_MAP,
@@ -281,16 +279,6 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
for translation_key, value in WARMING_LEVEL_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_RINSE_PLUS,
translation_key="rinse_plus",
options=list(RINSE_PLUS_OPTIONS),
translation_key_values=RINSE_PLUS_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in RINSE_PLUS_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE,
translation_key="washer_temperature",
@@ -311,15 +299,6 @@ PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
for translation_key, value in SPIN_SPEED_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_STAINS,
translation_key="auto_stain",
options=list(STAINS_OPTIONS),
translation_key_values=STAINS_OPTIONS,
values_translation_key={
value: translation_key for translation_key, value in STAINS_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT,
translation_key="vario_perfect",

View File

@@ -524,15 +524,6 @@ set_program_and_options:
washer_options:
collapsed: true
fields:
laundry_care_washer_option_rinse_plus:
example: laundry_care_washer_enum_type_rinse_plus_off
required: false
selector:
select:
mode: dropdown
translation_key: rinse_plus
options:
- laundry_care_washer_enum_type_rinse_plus_off
laundry_care_washer_option_temperature:
example: laundry_care_washer_enum_type_temperature_g_c_40
required: false
@@ -576,15 +567,6 @@ set_program_and_options:
- laundry_care_washer_enum_type_spin_speed_ul_low
- laundry_care_washer_enum_type_spin_speed_ul_medium
- laundry_care_washer_enum_type_spin_speed_ul_high
laundry_care_washer_option_stains:
example: laundry_care_washer_enum_type_stains_off
required: false
selector:
select:
mode: dropdown
translation_key: stains
options:
- laundry_care_washer_enum_type_stains_off
b_s_h_common_option_finish_in_relative:
example: 3600
required: false
@@ -594,11 +576,6 @@ set_program_and_options:
step: 1
mode: box
unit_of_measurement: s
laundry_care_common_option_silent_mode:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_i_dos1_active:
example: false
required: false
@@ -609,41 +586,6 @@ set_program_and_options:
required: false
selector:
boolean:
laundry_care_washer_option_intensive_plus:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_less_ironing:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_mini_load:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_prewash:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_rinse_hold:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_soak:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_water_plus:
example: false
required: false
selector:
boolean:
laundry_care_washer_option_vario_perfect:
example: laundry_care_common_enum_type_vario_perfect_eco_perfect
required: false

View File

@@ -70,9 +70,6 @@
"freezer_door": {
"name": "Freezer door"
},
"interior_illumination_active": {
"name": "Interior illumination active"
},
"left_chiller_door": {
"name": "Left chiller door"
},
@@ -362,12 +359,6 @@
"b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom"
}
},
"auto_stain": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_stains::name%]",
"state": {
"laundry_care_washer_enum_type_stains_off": "[%key:common::state::off%]"
}
},
"bean_amount": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]",
"state": {
@@ -532,12 +523,6 @@
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
}
},
"rinse_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_rinse_plus::name%]",
"state": {
"laundry_care_washer_enum_type_rinse_plus_off": "[%key:common::state::off%]"
}
},
"selected_program": {
"name": "Selected program",
"state": {
@@ -1227,51 +1212,27 @@
"intensiv_zone": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
},
"intensive_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_intensive_plus::name%]"
},
"less_ironing": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_less_ironing::name%]"
},
"mini_load": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_mini_load::name%]"
},
"multiple_beverages": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]"
},
"power": {
"name": "Power"
},
"prewash": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_prewash::name%]"
},
"refrigerator_super_mode": {
"name": "Refrigerator super mode"
},
"rinse_hold": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_rinse_hold::name%]"
},
"sabbath_mode": {
"name": "Sabbath mode"
},
"silence_on_demand": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]"
},
"silent_mode": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_silent_mode::name%]"
},
"soaking": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_soak::name%]"
},
"vacation_mode": {
"name": "Vacation mode"
},
"vario_speed_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]"
},
"water_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_water_plus::name%]"
},
"zeolite_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]"
}
@@ -1693,11 +1654,6 @@
"laundry_care_washer_program_wool": "Wool"
}
},
"rinse_plus": {
"options": {
"laundry_care_washer_enum_type_rinse_plus_off": "[%key:common::state::off%]"
}
},
"spin_speed": {
"options": {
"laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
@@ -1716,11 +1672,6 @@
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]"
}
},
"stains": {
"options": {
"laundry_care_washer_enum_type_stains_off": "[%key:common::state::off%]"
}
},
"suction_power": {
"options": {
"consumer_products_cleaning_robot_enum_type_suction_power_max": "Max",
@@ -1914,10 +1865,6 @@
"description": "Setting to adjust the venting level of the air conditioner as a percentage.",
"name": "Fan speed percentage"
},
"laundry_care_common_option_silent_mode": {
"description": "Defines if the silent mode is activated.",
"name": "Silent mode"
},
"laundry_care_dryer_option_drying_target": {
"description": "Describes the drying target for a dryer program.",
"name": "Drying target"
@@ -1930,42 +1877,10 @@
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)",
"name": "i-Dos 2 Active"
},
"laundry_care_washer_option_intensive_plus": {
"description": "Defines if the intensive washing is enabled for heavily soiled laundry.",
"name": "Intensive +"
},
"laundry_care_washer_option_less_ironing": {
"description": "Defines if the laundry is treated gently to reduce creasing and make ironing easier.",
"name": "Less ironing"
},
"laundry_care_washer_option_mini_load": {
"description": "Defines if the mini load option is activated.",
"name": "Mini load"
},
"laundry_care_washer_option_prewash": {
"description": "Defines if an additional prewash cycle is added to the program.",
"name": "Prewash"
},
"laundry_care_washer_option_rinse_hold": {
"description": "Defines if the rinse hold option is activated.",
"name": "Rinse hold"
},
"laundry_care_washer_option_rinse_plus": {
"description": "Defines if an additional rinse cycle is added to the program.",
"name": "Extra rinse"
},
"laundry_care_washer_option_soak": {
"description": "Defines if the soaking is activated.",
"name": "Soaking"
},
"laundry_care_washer_option_spin_speed": {
"description": "Defines the spin speed of a washer program.",
"name": "Spin speed"
},
"laundry_care_washer_option_stains": {
"description": "Defines the type of stains to be treated.",
"name": "Auto stain"
},
"laundry_care_washer_option_temperature": {
"description": "Defines the temperature of the washing program.",
"name": "Temperature"
@@ -1974,10 +1889,6 @@
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
"name": "Vario perfect"
},
"laundry_care_washer_option_water_plus": {
"description": "Defines if the water plus option is activated.",
"name": "Water +"
},
"program": {
"description": "Program to select",
"name": "Program"

View File

@@ -124,10 +124,6 @@ SWITCH_OPTIONS = (
key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT,
translation_key="fast_pre_heat",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE,
translation_key="silent_mode",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE,
translation_key="i_dos1_active",
@@ -136,34 +132,6 @@ SWITCH_OPTIONS = (
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE,
translation_key="i_dos2_active",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS,
translation_key="intensive_plus",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING,
translation_key="less_ironing",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD,
translation_key="mini_load",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_PREWASH,
translation_key="prewash",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD,
translation_key="rinse_hold",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_SOAK,
translation_key="soaking",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS,
translation_key="water_plus",
),
)

View File

@@ -48,19 +48,5 @@
"turn_on": {
"service": "mdi:air-humidifier"
}
},
"triggers": {
"started_drying": {
"trigger": "mdi:arrow-down-bold"
},
"started_humidifying": {
"trigger": "mdi:arrow-up-bold"
},
"turned_off": {
"trigger": "mdi:air-humidifier-off"
},
"turned_on": {
"trigger": "mdi:air-humidifier-on"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"set_humidity": "Set humidity for {entity_name}",
@@ -90,15 +86,6 @@
"message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"set_humidity": {
"description": "Sets the target humidity.",
@@ -133,47 +120,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Humidifier",
"triggers": {
"started_drying": {
"description": "Triggers after one or more humidifiers start drying.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
"name": "Humidifier started drying"
},
"started_humidifying": {
"description": "Triggers after one or more humidifiers start humidifying.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
"name": "Humidifier started humidifying"
},
"turned_off": {
"description": "Triggers after one or more humidifiers turn off.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
"name": "Humidifier turned off"
},
"turned_on": {
"description": "Triggers after one or more humidifiers turn on.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
"name": "Humidifier turned on"
}
}
"title": "Humidifier"
}

View File

@@ -1,27 +0,0 @@
"""Provides triggers for humidifiers."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
)
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
),
"started_humidifying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.HUMIDIFYING
),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for humidifiers."""
return TRIGGERS

View File

@@ -1,20 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: humidifier
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
started_drying: *trigger_common
started_humidifying: *trigger_common
turned_on: *trigger_common
turned_off: *trigger_common

View File

@@ -8,7 +8,6 @@
{ "registered_devices": true }
],
"documentation": "https://www.home-assistant.io/integrations/incomfort",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["incomfortclient"],
"quality_scale": "platinum",

View File

@@ -107,7 +107,7 @@ async def async_register_insteon_frontend(hass: HomeAssistant):
frontend_url_path=DOMAIN,
webcomponent_name="insteon-frontend",
config_panel_domain=DOMAIN,
module_url=f"{URL_BASE}/entrypoint.{build_id}.js",
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
embed_iframe=True,
require_admin=True,
)

View File

@@ -282,8 +282,6 @@ async def websocket_reset_properties(
notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND)
return
for prop in device.configuration.values():
prop.new_value = None
for prop in device.operating_flags:
device.operating_flags[prop].new_value = None
for prop in device.properties:

View File

@@ -19,7 +19,7 @@
"loggers": ["pyinsteon", "pypubsub"],
"requirements": [
"pyinsteon==1.6.4",
"insteon-frontend-home-assistant==0.6.0"
"insteon-frontend-home-assistant==0.5.0"
],
"single_config_entry": true,
"usb": [

View File

@@ -1,7 +1,7 @@
{
"domain": "intent_script",
"name": "Intent Script",
"codeowners": ["@arturpragacz"],
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/intent_script",
"quality_scale": "internal"
}

View File

@@ -168,9 +168,7 @@ SUPPORTED_PLATFORMS_UI: Final = {
Platform.FAN,
Platform.DATETIME,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TEXT,
Platform.TIME,
}

View File

@@ -1,146 +0,0 @@
"""KNX DPT serializer."""
from collections.abc import Mapping
from functools import cache
from typing import Literal, TypedDict
from xknx.dpt import DPTBase, DPTComplex, DPTEnum, DPTNumeric
from xknx.dpt.dpt_16 import DPTString
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
HaDptClass = Literal["numeric", "enum", "complex", "string"]
class DPTInfo(TypedDict):
"""DPT information."""
dpt_class: HaDptClass
main: int
sub: int | None
name: str | None
unit: str | None
sensor_device_class: SensorDeviceClass | None
sensor_state_class: SensorStateClass | None
@cache
def get_supported_dpts() -> Mapping[str, DPTInfo]:
"""Return a mapping of supported DPTs with HA specific attributes."""
dpts = {}
for dpt_class in DPTBase.dpt_class_tree():
dpt_number_str = dpt_class.dpt_number_str()
ha_dpt_class = _ha_dpt_class(dpt_class)
dpts[dpt_number_str] = DPTInfo(
dpt_class=ha_dpt_class,
main=dpt_class.dpt_main_number, # type: ignore[typeddict-item] # checked in xknx unit tests
sub=dpt_class.dpt_sub_number,
name=dpt_class.value_type,
unit=dpt_class.unit,
sensor_device_class=_sensor_device_classes.get(dpt_number_str),
sensor_state_class=_get_sensor_state_class(ha_dpt_class, dpt_number_str),
)
return dpts
def _ha_dpt_class(dpt_cls: type[DPTBase]) -> HaDptClass:
"""Return the DPT class identifier string."""
if issubclass(dpt_cls, DPTNumeric):
return "numeric"
if issubclass(dpt_cls, DPTEnum):
return "enum"
if issubclass(dpt_cls, DPTComplex):
return "complex"
if issubclass(dpt_cls, DPTString):
return "string"
raise ValueError("Unsupported DPT class")
_sensor_device_classes: Mapping[str, SensorDeviceClass] = {
"7.011": SensorDeviceClass.DISTANCE,
"7.012": SensorDeviceClass.CURRENT,
"7.013": SensorDeviceClass.ILLUMINANCE,
"8.012": SensorDeviceClass.DISTANCE,
"9.001": SensorDeviceClass.TEMPERATURE,
"9.002": SensorDeviceClass.TEMPERATURE_DELTA,
"9.004": SensorDeviceClass.ILLUMINANCE,
"9.005": SensorDeviceClass.WIND_SPEED,
"9.006": SensorDeviceClass.PRESSURE,
"9.007": SensorDeviceClass.HUMIDITY,
"9.020": SensorDeviceClass.VOLTAGE,
"9.021": SensorDeviceClass.CURRENT,
"9.024": SensorDeviceClass.POWER,
"9.025": SensorDeviceClass.VOLUME_FLOW_RATE,
"9.027": SensorDeviceClass.TEMPERATURE,
"9.028": SensorDeviceClass.WIND_SPEED,
"9.029": SensorDeviceClass.ABSOLUTE_HUMIDITY,
"12.1200": SensorDeviceClass.VOLUME,
"12.1201": SensorDeviceClass.VOLUME,
"13.002": SensorDeviceClass.VOLUME_FLOW_RATE,
"13.010": SensorDeviceClass.ENERGY,
"13.012": SensorDeviceClass.REACTIVE_ENERGY,
"13.013": SensorDeviceClass.ENERGY,
"13.015": SensorDeviceClass.REACTIVE_ENERGY,
"13.016": SensorDeviceClass.ENERGY,
"13.1200": SensorDeviceClass.VOLUME,
"13.1201": SensorDeviceClass.VOLUME,
"14.010": SensorDeviceClass.AREA,
"14.019": SensorDeviceClass.CURRENT,
"14.027": SensorDeviceClass.VOLTAGE,
"14.028": SensorDeviceClass.VOLTAGE,
"14.030": SensorDeviceClass.VOLTAGE,
"14.031": SensorDeviceClass.ENERGY,
"14.033": SensorDeviceClass.FREQUENCY,
"14.037": SensorDeviceClass.ENERGY_STORAGE,
"14.039": SensorDeviceClass.DISTANCE,
"14.051": SensorDeviceClass.WEIGHT,
"14.056": SensorDeviceClass.POWER,
"14.057": SensorDeviceClass.POWER_FACTOR,
"14.058": SensorDeviceClass.PRESSURE,
"14.065": SensorDeviceClass.SPEED,
"14.068": SensorDeviceClass.TEMPERATURE,
"14.069": SensorDeviceClass.TEMPERATURE,
"14.070": SensorDeviceClass.TEMPERATURE_DELTA,
"14.076": SensorDeviceClass.VOLUME,
"14.077": SensorDeviceClass.VOLUME_FLOW_RATE,
"14.080": SensorDeviceClass.APPARENT_POWER,
"14.1200": SensorDeviceClass.VOLUME_FLOW_RATE,
"14.1201": SensorDeviceClass.VOLUME_FLOW_RATE,
"29.010": SensorDeviceClass.ENERGY,
"29.012": SensorDeviceClass.REACTIVE_ENERGY,
}
_sensor_state_class_overrides: Mapping[str, SensorStateClass | None] = {
"5.003": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngle
"5.006": None, # DPTTariff
"7.010": None, # DPTPropDataType
"8.011": SensorStateClass.MEASUREMENT_ANGLE, # DPTRotationAngle
"9.026": SensorStateClass.TOTAL_INCREASING, # DPTRainAmount
"12.1200": SensorStateClass.TOTAL, # DPTVolumeLiquidLitre
"12.1201": SensorStateClass.TOTAL, # DPTVolumeM3
"13.010": SensorStateClass.TOTAL, # DPTActiveEnergy
"13.011": SensorStateClass.TOTAL, # DPTApparantEnergy
"13.012": SensorStateClass.TOTAL, # DPTReactiveEnergy
"14.007": SensorStateClass.MEASUREMENT_ANGLE, # DPTAngleDeg
"14.037": SensorStateClass.TOTAL, # DPTHeatQuantity
"14.051": SensorStateClass.TOTAL, # DPTMass
"14.055": SensorStateClass.MEASUREMENT_ANGLE, # DPTPhaseAngleDeg
"14.031": SensorStateClass.TOTAL_INCREASING, # DPTEnergy
"17.001": None, # DPTSceneNumber
"29.010": SensorStateClass.TOTAL, # DPTActiveEnergy8Byte
"29.011": SensorStateClass.TOTAL, # DPTApparantEnergy8Byte
"29.012": SensorStateClass.TOTAL, # DPTReactiveEnergy8Byte
}
def _get_sensor_state_class(
ha_dpt_class: HaDptClass, dpt_number_str: str
) -> SensorStateClass | None:
"""Return the SensorStateClass for a given DPT."""
if ha_dpt_class != "numeric":
return None
return _sensor_state_class_overrides.get(
dpt_number_str,
SensorStateClass.MEASUREMENT,
)

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import math
from typing import Any
from typing import Any, Final
from propcache.api import cached_property
from xknx.devices import Fan as XknxFan
@@ -32,11 +32,12 @@ from .storage.const import (
CONF_GA_OSCILLATION,
CONF_GA_SPEED,
CONF_GA_STEP,
CONF_GA_SWITCH,
CONF_SPEED,
)
from .storage.util import ConfigExtractor
DEFAULT_PERCENTAGE: Final = 50
async def async_setup_entry(
hass: HomeAssistant,
@@ -76,24 +77,26 @@ class _KnxFan(FanEntity):
_device: XknxFan
_step_range: tuple[int, int] | None
def _get_knx_speed(self, percentage: int) -> int:
"""Convert percentage to KNX speed value."""
if self._step_range is not None:
return math.ceil(percentage_to_ranged_value(self._step_range, percentage))
return percentage
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self._device.set_speed(self._get_knx_speed(percentage))
if self._step_range:
step = math.ceil(percentage_to_ranged_value(self._step_range, percentage))
await self._device.set_speed(step)
else:
await self._device.set_speed(percentage)
@cached_property
def supported_features(self) -> FanEntityFeature:
"""Flag supported features."""
flags = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
if self._device.speed.initialized:
flags |= FanEntityFeature.SET_SPEED
flags = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
if self._device.supports_oscillation:
flags |= FanEntityFeature.OSCILLATE
return flags
@property
@@ -115,11 +118,6 @@ class _KnxFan(FanEntity):
return super().speed_count
return int_states_in_range(self._step_range)
@property
def is_on(self) -> bool:
"""Return the current fan state of the device."""
return self._device.is_on
async def async_turn_on(
self,
percentage: int | None = None,
@@ -127,12 +125,14 @@ class _KnxFan(FanEntity):
**kwargs: Any,
) -> None:
"""Turn on the fan."""
speed = self._get_knx_speed(percentage) if percentage is not None else None
await self._device.turn_on(speed)
if percentage is None:
await self.async_set_percentage(DEFAULT_PERCENTAGE)
else:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self._device.turn_off()
await self.async_set_percentage(0)
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
@@ -165,12 +165,7 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity):
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(
FanSchema.CONF_SWITCH_STATE_ADDRESS
),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
),
)
# FanSpeedMode.STEP if max_step is set
@@ -215,8 +210,6 @@ class KnxUiFan(_KnxFan, KnxUiEntity):
group_address_oscillation_state=knx_conf.get_state_and_passive(
CONF_GA_OSCILLATION
),
group_address_switch=knx_conf.get_write(CONF_GA_SWITCH),
group_address_switch_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH),
max_step=max_step,
sync_state=knx_conf.get(CONF_SYNC_STATE),
)

View File

@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.13.0",
"xknxproject==3.8.2",
"knx-frontend==2025.12.19.150946"
"knx-frontend==2025.10.31.195356"
],
"single_config_entry": true
}

View File

@@ -576,40 +576,19 @@ class FanSchema(KNXPlatformSchema):
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
CONF_OSCILLATION_ADDRESS = "oscillation_address"
CONF_OSCILLATION_STATE_ADDRESS = "oscillation_state_address"
CONF_SWITCH_ADDRESS = "switch_address"
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
DEFAULT_NAME = "KNX Fan"
ENTITY_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
}
),
vol.Any(
vol.Schema(
{vol.Required(KNX_ADDRESS): object},
extra=vol.ALLOW_EXTRA,
),
vol.Schema(
{vol.Required(CONF_SWITCH_ADDRESS): object},
extra=vol.ALLOW_EXTRA,
),
msg=(
f"At least one of '{KNX_ADDRESS}' or"
f" '{CONF_SWITCH_ADDRESS}' is required."
),
),
ENTITY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)

View File

@@ -6,8 +6,8 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from functools import partial
from typing import Any
from xknx import XKNX
from xknx.core.connection_state import XknxConnectionState, XknxConnectionType
from xknx.devices import Device as XknxDevice, Sensor as XknxSensor
@@ -25,32 +25,20 @@ from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_NAME,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
EntityCategory,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, StateType
from homeassistant.util.enum import try_parse_enum
from .const import ATTR_SOURCE, CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY
from .dpt import get_supported_dpts
from .entity import (
KnxUiEntity,
KnxUiEntityPlatformController,
KnxYamlEntity,
_KnxEntityBase,
)
from .const import ATTR_SOURCE, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .knx_module import KNXModule
from .schema import SensorSchema
from .storage.const import CONF_ALWAYS_CALLBACK, CONF_ENTITY, CONF_GA_SENSOR
from .storage.util import ConfigExtractor
SCAN_INTERVAL = timedelta(seconds=10)
@@ -134,41 +122,58 @@ async def async_setup_entry(
config_entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for KNX platform."""
"""Set up sensor(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.SENSOR,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiSensor,
),
)
entities: list[SensorEntity] = []
entities.extend(
KNXSystemSensor(knx_module, description)
for description in SYSTEM_ENTITY_DESCRIPTIONS
)
if yaml_platform_config := knx_module.config_yaml.get(Platform.SENSOR):
config: list[ConfigType] | None = knx_module.config_yaml.get(Platform.SENSOR)
if config:
entities.extend(
KnxYamlSensor(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.SENSOR):
entities.extend(
KnxUiSensor(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
KNXSensor(knx_module, entity_config) for entity_config in config
)
async_add_entities(entities)
class _KnxSensor(RestoreSensor, _KnxEntityBase):
def _create_sensor(xknx: XKNX, config: ConfigType) -> XknxSensor:
"""Return a KNX sensor to be used within XKNX."""
return XknxSensor(
xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[SensorSchema.CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
)
class KNXSensor(KnxYamlEntity, RestoreSensor):
"""Representation of a KNX sensor."""
_device: XknxSensor
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
super().__init__(
knx_module=knx_module,
device=_create_sensor(knx_module.xknx, config),
)
if device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = device_class
else:
self._attr_device_class = try_parse_enum(
SensorDeviceClass, self._device.ha_device_class()
)
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
self._attr_state_class = config.get(CONF_STATE_CLASS)
self._attr_extra_state_attributes = {}
async def async_added_to_hass(self) -> None:
"""Restore last state."""
if (
@@ -193,89 +198,6 @@ class _KnxSensor(RestoreSensor, _KnxEntityBase):
super().after_update_callback(device)
class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
"""Representation of a KNX sensor configured from YAML."""
_device: XknxSensor
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
super().__init__(
knx_module=knx_module,
device=XknxSensor(
knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
),
)
if device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = device_class
else:
self._attr_device_class = try_parse_enum(
SensorDeviceClass, self._device.ha_device_class()
)
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
self._attr_native_unit_of_measurement = self._device.unit_of_measurement()
self._attr_state_class = config.get(CONF_STATE_CLASS)
self._attr_extra_state_attributes = {}
class KnxUiSensor(_KnxSensor, KnxUiEntity):
"""Representation of a KNX sensor configured from the UI."""
_device: XknxSensor
def __init__(
self, knx_module: KNXModule, unique_id: str, config: dict[str, Any]
) -> None:
"""Initialize KNX sensor."""
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
dpt_string = knx_conf.get_dpt(CONF_GA_SENSOR)
assert dpt_string is not None # required for sensor
dpt_info = get_supported_dpts()[dpt_string]
self._device = XknxSensor(
knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR),
sync_state=knx_conf.get(CONF_SYNC_STATE),
always_callback=True,
value_type=dpt_string,
)
if device_class_override := knx_conf.get(CONF_DEVICE_CLASS):
self._attr_device_class = try_parse_enum(
SensorDeviceClass, device_class_override
)
else:
self._attr_device_class = dpt_info["sensor_device_class"]
if state_class_override := knx_conf.get(CONF_STATE_CLASS):
self._attr_state_class = try_parse_enum(
SensorStateClass, state_class_override
)
else:
self._attr_state_class = dpt_info["sensor_state_class"]
self._attr_native_unit_of_measurement = (
knx_conf.get(CONF_UNIT_OF_MEASUREMENT) or dpt_info["unit"]
)
self._attr_force_update = knx_conf.get(CONF_ALWAYS_CALLBACK, default=False)
self._attr_extra_state_attributes = {}
class KNXSystemSensor(SensorEntity):
"""Representation of a KNX system sensor."""

View File

@@ -71,9 +71,3 @@ CONF_GA_WHITE_BRIGHTNESS: Final = "ga_white_brightness"
CONF_GA_WHITE_SWITCH: Final = "ga_white_switch"
CONF_GA_HUE: Final = "ga_hue"
CONF_GA_SATURATION: Final = "ga_saturation"
# Sensor
CONF_ALWAYS_CALLBACK: Final = "always_callback"
# Text
CONF_GA_TEXT: Final = "ga_text"

View File

@@ -5,23 +5,11 @@ from enum import StrEnum, unique
import voluptuous as vol
from homeassistant.components.climate import HVACMode
from homeassistant.components.sensor import (
CONF_STATE_CLASS as CONF_SENSOR_STATE_CLASS,
DEVICE_CLASS_STATE_CLASSES,
DEVICE_CLASS_UNITS,
STATE_CLASS_UNITS,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.components.text import TextMode
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_ENTITY_CATEGORY,
CONF_ENTITY_ID,
CONF_MODE,
CONF_NAME,
CONF_PLATFORM,
CONF_UNIT_OF_MEASUREMENT,
Platform,
)
from homeassistant.helpers import config_validation as cv, selector
@@ -43,15 +31,12 @@ from ..const import (
FanConf,
FanZeroMode,
)
from ..dpt import get_supported_dpts
from .const import (
CONF_ALWAYS_CALLBACK,
CONF_COLOR,
CONF_COLOR_TEMP_MAX,
CONF_COLOR_TEMP_MIN,
CONF_DATA,
CONF_DEVICE_INFO,
CONF_DPT,
CONF_ENTITY,
CONF_GA_ACTIVE,
CONF_GA_ANGLE,
@@ -92,7 +77,6 @@ from .const import (
CONF_GA_SWITCH,
CONF_GA_TEMPERATURE_CURRENT,
CONF_GA_TEMPERATURE_TARGET,
CONF_GA_TEXT,
CONF_GA_TIME,
CONF_GA_UP_DOWN,
CONF_GA_VALVE,
@@ -240,58 +224,40 @@ DATETIME_KNX_SCHEMA = vol.Schema(
}
)
FAN_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
vol.Optional(CONF_GA_SWITCH): GASelector(
write_required=True, valid_dpt="1"
FAN_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_SPEED): GroupSelect(
GroupSelectOption(
translation_key="percentage_mode",
schema={
vol.Required(CONF_GA_SPEED): GASelector(
write_required=True, valid_dpt="5.001"
),
},
),
vol.Optional(CONF_SPEED): GroupSelect(
GroupSelectOption(
translation_key="percentage_mode",
schema={
vol.Required(CONF_GA_SPEED): GASelector(
write_required=True, valid_dpt="5.001"
),
},
),
GroupSelectOption(
translation_key="step_mode",
schema={
vol.Required(CONF_GA_STEP): GASelector(
write_required=True, valid_dpt="5.010"
),
vol.Required(
FanConf.MAX_STEP, default=3
): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=100,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
},
),
collapsible=False,
GroupSelectOption(
translation_key="step_mode",
schema={
vol.Required(CONF_GA_STEP): GASelector(
write_required=True, valid_dpt="5.010"
),
vol.Required(FanConf.MAX_STEP, default=3): selector.NumberSelector(
selector.NumberSelectorConfig(
min=1,
max=100,
step=1,
mode=selector.NumberSelectorMode.BOX,
)
),
},
),
vol.Optional(CONF_GA_OSCILLATION): GASelector(
write_required=True, valid_dpt="1"
),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
}
),
vol.Any(
vol.Schema(
{vol.Required(CONF_GA_SWITCH): object},
extra=vol.ALLOW_EXTRA,
collapsible=False,
),
vol.Schema(
{vol.Required(CONF_SPEED): object},
extra=vol.ALLOW_EXTRA,
vol.Optional(CONF_GA_OSCILLATION): GASelector(
write_required=True, valid_dpt="1"
),
msg=("At least one of 'Switch' or 'Fan speed' is required."),
),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
}
)
@@ -431,20 +397,6 @@ SWITCH_KNX_SCHEMA = vol.Schema(
},
)
TEXT_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_GA_TEXT): GASelector(write_required=True, dpt=["string"]),
vol.Required(CONF_MODE, default=TextMode.TEXT): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(TextMode),
translation_key="component.knx.config_panel.entities.create.text.knx.mode",
),
),
vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(),
vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(),
},
)
TIME_KNX_SCHEMA = vol.Schema(
{
vol.Required(CONF_GA_TIME): GASelector(write_required=True, valid_dpt="10.001"),
@@ -595,114 +547,6 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
},
)
def _validate_sensor_attributes(config: dict) -> dict:
"""Validate that state_class is compatible with device_class and unit_of_measurement."""
dpt = config[CONF_GA_SENSOR][CONF_DPT]
dpt_metadata = get_supported_dpts()[dpt]
state_class = config.get(
CONF_SENSOR_STATE_CLASS,
dpt_metadata["sensor_state_class"],
)
device_class = config.get(
CONF_DEVICE_CLASS,
dpt_metadata["sensor_device_class"],
)
unit_of_measurement = config.get(
CONF_UNIT_OF_MEASUREMENT,
dpt_metadata["unit"],
)
if (
state_class
and device_class
and (state_classes := DEVICE_CLASS_STATE_CLASSES.get(device_class)) is not None
and state_class not in state_classes
):
raise vol.Invalid(
f"State class '{state_class}' is not valid for device class '{device_class}'. "
f"Valid options are: {', '.join(sorted(map(str, state_classes), key=str.casefold))}",
path=[CONF_SENSOR_STATE_CLASS],
)
if (
device_class
and (d_c_units := DEVICE_CLASS_UNITS.get(device_class)) is not None
and unit_of_measurement not in d_c_units
):
raise vol.Invalid(
f"Unit of measurement '{unit_of_measurement}' is not valid for device class '{device_class}'. "
f"Valid options are: {', '.join(sorted(map(str, d_c_units), key=str.casefold))}",
path=(
[CONF_DEVICE_CLASS]
if CONF_DEVICE_CLASS in config
else [CONF_UNIT_OF_MEASUREMENT]
),
)
if (
state_class
and (s_c_units := STATE_CLASS_UNITS.get(state_class)) is not None
and unit_of_measurement not in s_c_units
):
raise vol.Invalid(
f"Unit of measurement '{unit_of_measurement}' is not valid for state class '{state_class}'. "
f"Valid options are: {', '.join(sorted(map(str, s_c_units), key=str.casefold))}",
path=(
[CONF_SENSOR_STATE_CLASS]
if CONF_SENSOR_STATE_CLASS in config
else [CONF_UNIT_OF_MEASUREMENT]
),
)
return config
SENSOR_KNX_SCHEMA = AllSerializeFirst(
vol.Schema(
{
vol.Required(CONF_GA_SENSOR): GASelector(
write=False, state_required=True, dpt=["numeric", "string"]
),
"section_advanced_options": KNXSectionFlat(collapsible=True),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector(
selector.SelectSelectorConfig(
options=sorted(
{
str(unit)
for units in DEVICE_CLASS_UNITS.values()
for unit in units
if unit is not None
}
),
mode=selector.SelectSelectorMode.DROPDOWN,
translation_key="component.knx.selector.sensor_unit_of_measurement",
custom_value=True,
),
),
vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[
cls.value
for cls in SensorDeviceClass
if cls != SensorDeviceClass.ENUM
],
translation_key="component.knx.selector.sensor_device_class",
sort=True,
)
),
vol.Optional(CONF_SENSOR_STATE_CLASS): selector.SelectSelector(
selector.SelectSelectorConfig(
options=list(SensorStateClass),
translation_key="component.knx.selector.sensor_state_class",
mode=selector.SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_ALWAYS_CALLBACK): selector.BooleanSelector(),
vol.Required(CONF_SYNC_STATE, default=True): SyncStateSelector(
allow_false=True
),
},
),
_validate_sensor_attributes,
)
KNX_SCHEMA_FOR_PLATFORM = {
Platform.BINARY_SENSOR: BINARY_SENSOR_KNX_SCHEMA,
Platform.CLIMATE: CLIMATE_KNX_SCHEMA,
@@ -711,9 +555,7 @@ KNX_SCHEMA_FOR_PLATFORM = {
Platform.DATETIME: DATETIME_KNX_SCHEMA,
Platform.FAN: FAN_KNX_SCHEMA,
Platform.LIGHT: LIGHT_KNX_SCHEMA,
Platform.SENSOR: SENSOR_KNX_SCHEMA,
Platform.SWITCH: SWITCH_KNX_SCHEMA,
Platform.TEXT: TEXT_KNX_SCHEMA,
Platform.TIME: TIME_KNX_SCHEMA,
}

View File

@@ -6,7 +6,6 @@ from typing import Any
import voluptuous as vol
from ..dpt import HaDptClass, get_supported_dpts
from ..validation import ga_validator, maybe_ga_validator, sync_state_validator
from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE
from .util import dpt_string_to_dict
@@ -163,7 +162,7 @@ class GASelector(KNXSelectorBase):
passive: bool = True,
write_required: bool = False,
state_required: bool = False,
dpt: type[Enum] | list[HaDptClass] | None = None,
dpt: type[Enum] | None = None,
valid_dpt: str | Iterable[str] | None = None,
) -> None:
"""Initialize the group address selector."""
@@ -187,17 +186,14 @@ class GASelector(KNXSelectorBase):
"passive": self.passive,
}
if self.dpt is not None:
if isinstance(self.dpt, list):
options["dptClasses"] = self.dpt
else:
options["dptSelect"] = [
{
"value": item.value,
"translation_key": item.value.replace(".", "_"),
"dpt": dpt_string_to_dict(item.value), # used for filtering GAs
}
for item in self.dpt
]
options["dptSelect"] = [
{
"value": item.value,
"translation_key": item.value.replace(".", "_"),
"dpt": dpt_string_to_dict(item.value), # used for filtering GAs
}
for item in self.dpt
]
if self.valid_dpt is not None:
options["validDPTs"] = [dpt_string_to_dict(dpt) for dpt in self.valid_dpt]
@@ -258,12 +254,7 @@ class GASelector(KNXSelectorBase):
def _add_dpt(self, schema: dict[vol.Marker, Any]) -> None:
"""Add DPT validator to the schema."""
if self.dpt is not None:
if isinstance(self.dpt, list):
schema[vol.Required(CONF_DPT)] = vol.In(get_supported_dpts())
else:
schema[vol.Required(CONF_DPT)] = vol.In(
{item.value for item in self.dpt}
)
schema[vol.Required(CONF_DPT)] = vol.In({item.value for item in self.dpt})
else:
schema[vol.Remove(CONF_DPT)] = object

View File

@@ -154,183 +154,6 @@
}
},
"config_panel": {
"dpt": {
"options": {
"5": "Generic 1-byte unsigned integer",
"5_001": "Percent (0 … 100)",
"5_003": "Angle",
"5_004": "Percent (0 … 255)",
"5_005": "Decimal factor",
"5_006": "Tariff",
"5_010": "Counter (0 … 255)",
"6": "Generic 1-byte signed integer",
"6_001": "Percent (-128 … 127)",
"6_010": "Counter (-128 … 127)",
"7": "Generic 2-byte unsigned integer",
"7_001": "Counter (0 … 65535)",
"7_002": "Time period",
"7_003": "Time period (10 ms)",
"7_004": "Time period (100 ms)",
"7_005": "[%key:component::knx::config_panel::dpt::options::7_002%]",
"7_006": "[%key:component::knx::config_panel::dpt::options::7_002%]",
"7_007": "[%key:component::knx::config_panel::dpt::options::7_002%]",
"7_010": "Interface Object Property",
"7_011": "Length",
"7_012": "Electrical current",
"7_013": "Brightness",
"7_600": "Color temperature",
"8": "Generic 2-byte signed integer",
"8_001": "Counter (-32 768 … 32 767)",
"8_002": "Delta time",
"8_003": "Delta time (10 ms)",
"8_004": "Delta time (100 ms)",
"8_005": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_006": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_007": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_010": "Percent (-327.68 … 327.67)",
"8_011": "Rotation angle",
"8_012": "Length (Altitude)",
"9": "Generic 2-byte floating point",
"9_001": "Temperature",
"9_002": "Temperature difference",
"9_003": "Temperature change",
"9_004": "Illuminance",
"9_005": "Wind speed",
"9_006": "Pressure (2-byte)",
"9_007": "Humidity",
"9_008": "Air quality",
"9_009": "Air flow",
"9_010": "Time",
"9_011": "[%key:component::knx::config_panel::dpt::options::9_010%]",
"9_020": "Voltage",
"9_021": "Current",
"9_022": "Power density",
"9_023": "Temperature sensitivity",
"9_024": "Power (2-byte)",
"9_025": "Volume flow",
"9_026": "Rain amount",
"9_027": "[%key:component::knx::config_panel::dpt::options::9_001%]",
"9_028": "[%key:component::knx::config_panel::dpt::options::9_005%]",
"9_029": "Absolute humidity",
"9_030": "Concentration",
"9_60000": "Enthalpy",
"12": "Generic 4-byte unsigned integer",
"12_001": "Counter (0 … 4 294 967 295)",
"12_100": "Time period (4-byte)",
"12_101": "[%key:component::knx::config_panel::dpt::options::12_100%]",
"12_102": "[%key:component::knx::config_panel::dpt::options::12_100%]",
"12_1200": "Liquid volume",
"12_1201": "Volume",
"13": "Generic 4-byte signed integer",
"13_001": "Counter (-2 147 483 648 … 2 147 483 647)",
"13_002": "Flow rate",
"13_010": "Active energy",
"13_011": "Apparent energy",
"13_012": "Reactive energy",
"13_013": "[%key:component::knx::config_panel::dpt::options::13_010%]",
"13_014": "[%key:component::knx::config_panel::dpt::options::13_011%]",
"13_015": "[%key:component::knx::config_panel::dpt::options::13_012%]",
"13_016": "[%key:component::knx::config_panel::dpt::options::13_010%]",
"13_100": "Operating hours",
"13_1200": "Delta liquid volume",
"13_1201": "Delta volume",
"14": "Generic 4-byte floating point",
"14_000": "Acceleration",
"14_001": "Angular acceleration",
"14_002": "Activation energy",
"14_003": "Activity (radioactive)",
"14_004": "Amount of substance",
"14_005": "Amplitude",
"14_006": "Angle",
"14_007": "[%key:component::knx::config_panel::dpt::options::14_006%]",
"14_008": "Angular momentum",
"14_009": "Angular velocity",
"14_010": "Area",
"14_011": "Capacitance",
"14_012": "Charge density (surface)",
"14_013": "Charge density (volume)",
"14_014": "Compressibility",
"14_015": "Conductance",
"14_016": "Electrical conductivity",
"14_017": "Density",
"14_018": "Electric charge",
"14_019": "Electric current",
"14_020": "Electric current density",
"14_021": "Electric dipole moment",
"14_022": "Electric displacement",
"14_023": "Electric field strength",
"14_024": "Electric flux",
"14_025": "Electric flux density",
"14_026": "Electric polarization",
"14_027": "Electric potential",
"14_028": "Potential difference",
"14_029": "Electromagnetic moment",
"14_030": "Electromotive force",
"14_031": "Energy",
"14_032": "Force",
"14_033": "Frequency",
"14_034": "Angular frequency",
"14_035": "Heat capacity",
"14_036": "Heat flow rate",
"14_037": "Heat quantity",
"14_038": "Impedance",
"14_039": "Length",
"14_040": "Light quantity",
"14_041": "Luminance",
"14_042": "Luminous flux",
"14_043": "Luminous intensity",
"14_044": "Magnetic field strength",
"14_045": "Magnetic flux",
"14_046": "Magnetic flux density",
"14_047": "Magnetic moment",
"14_048": "Magnetic polarization",
"14_049": "Magnetization",
"14_050": "Magnetomotive force",
"14_051": "Mass",
"14_052": "Mass flux",
"14_053": "Momentum",
"14_054": "Phase angle",
"14_055": "[%key:component::knx::config_panel::dpt::options::14_054%]",
"14_056": "Power (4-byte)",
"14_057": "Power factor",
"14_058": "Pressure (4-byte)",
"14_059": "Reactance",
"14_060": "Resistance",
"14_061": "Resistivity",
"14_062": "Self inductance",
"14_063": "Solid angle",
"14_064": "Sound intensity",
"14_065": "Speed",
"14_066": "Stress",
"14_067": "Surface tension",
"14_068": "Common temperature",
"14_069": "Absolute temperature",
"14_070": "[%key:component::knx::config_panel::dpt::options::9_002%]",
"14_071": "Thermal capacity",
"14_072": "Thermal conductivity",
"14_073": "Thermoelectric power",
"14_074": "[%key:component::knx::config_panel::dpt::options::9_010%]",
"14_075": "Torque",
"14_076": "[%key:component::knx::config_panel::dpt::options::12_1201%]",
"14_077": "Volume flux",
"14_078": "Weight",
"14_079": "Work",
"14_080": "Apparent power",
"14_1200": "Meter flow",
"14_1201": "[%key:component::knx::config_panel::dpt::options::14_1200%]",
"16_000": "String (ASCII)",
"16_001": "String (Latin-1)",
"17_001": "Scene number",
"29": "Generic 8-byte signed integer",
"29_010": "Active energy (8-byte)",
"29_011": "Apparent energy (8-byte)",
"29_012": "Reactive energy (8-byte)"
},
"selector": {
"label": "Select a datapoint type",
"no_selection": "No DPT selected"
}
},
"entities": {
"create": {
"_": {
@@ -644,10 +467,6 @@
"description": "Toggle oscillation of the fan.",
"label": "Oscillation"
},
"ga_switch": {
"description": "Group address to turn the fan on/off.",
"label": "Switch"
},
"speed": {
"description": "Control the speed of the fan.",
"ga_speed": {
@@ -774,35 +593,6 @@
}
}
},
"sensor": {
"description": "Read-only entity for numeric or string datapoints. Temperature, percent etc.",
"knx": {
"always_callback": {
"description": "Write each update to the state machine, even if the data is the same.",
"label": "Force update"
},
"device_class": {
"description": "Override the DPTs default device class.",
"label": "Device class"
},
"ga_sensor": {
"description": "Group address representing state.",
"label": "State"
},
"section_advanced_options": {
"description": "Override default DPT-based sensor attributes.",
"title": "Overrides"
},
"state_class": {
"description": "Override the DPTs default state class.",
"label": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]"
},
"unit_of_measurement": {
"description": "Override the DPTs default unit of measurement.",
"label": "Unit of measurement"
}
}
},
"switch": {
"description": "The KNX switch platform is used as an interface to switching actuators.",
"knx": {
@@ -816,23 +606,6 @@
}
}
},
"text": {
"description": "The KNX text platform is used as an interface to text objects.",
"knx": {
"ga_text": {
"description": "The group address of the text object.",
"label": "Text"
},
"mode": {
"description": "Select how the entity is displayed in Home Assistant.",
"label": "[%common::config_flow::data::mode%]",
"options": {
"password": "[%common::config_flow::data::password%]",
"text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]"
}
}
}
},
"time": {
"description": "The KNX time platform is used as an interface to time objects.",
"knx": {
@@ -978,79 +751,6 @@
}
}
},
"selector": {
"sensor_device_class": {
"options": {
"absolute_humidity": "[%key:component::sensor::entity_component::absolute_humidity::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"date": "[%key:component::sensor::entity_component::date::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"ph": "[%key:component::sensor::entity_component::ph::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"pm4": "[%key:component::sensor::entity_component::pm4::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_energy": "[%key:component::sensor::entity_component::reactive_energy::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"temperature_delta": "[%key:component::sensor::entity_component::temperature_delta::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"sensor_state_class": {
"options": {
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"measurement_angle": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement_angle%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
}
},
"services": {
"event_register": {
"description": "Adds or removes group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this action can be removed.",
@@ -1078,7 +778,7 @@
"name": "[%key:component::knx::services::send::fields::address::name%]"
},
"attribute": {
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set, the state will be sent. Eg. for a light the state is either “on” or “off” - with attribute you can expose its “brightness”.",
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set the state will be sent. Eg. for a light the state is eigther “on” or “off” - with attribute you can expose its “brightness”.",
"name": "Entity attribute"
},
"default": {

View File

@@ -2,12 +2,12 @@
from __future__ import annotations
from propcache.api import cached_property
from xknx import XKNX
from xknx.devices import Notification as XknxNotification
from xknx.dpt import DPTLatin1
from homeassistant import config_entries
from homeassistant.components.text import TextEntity, TextMode
from homeassistant.components.text import TextEntity
from homeassistant.const import (
CONF_ENTITY_CATEGORY,
CONF_MODE,
@@ -18,25 +18,13 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_RESPOND_TO_READ,
CONF_STATE_ADDRESS,
CONF_SYNC_STATE,
DOMAIN,
KNX_ADDRESS,
KNX_MODULE_KEY,
)
from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity
from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY
from .entity import KnxYamlEntity
from .knx_module import KNXModule
from .storage.const import CONF_ENTITY, CONF_GA_TEXT
from .storage.util import ConfigExtractor
async def async_setup_entry(
@@ -44,39 +32,46 @@ async def async_setup_entry(
config_entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up text(s) for KNX platform."""
"""Set up sensor(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
platform = async_get_current_platform()
knx_module.config_store.add_platform(
platform=Platform.TEXT,
controller=KnxUiEntityPlatformController(
knx_module=knx_module,
entity_platform=platform,
entity_class=KnxUiText,
),
config: list[ConfigType] = knx_module.config_yaml[Platform.TEXT]
async_add_entities(KNXText(knx_module, entity_config) for entity_config in config)
def _create_notification(xknx: XKNX, config: ConfigType) -> XknxNotification:
"""Return a KNX Notification to be used within XKNX."""
return XknxNotification(
xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
)
entities: list[KnxYamlEntity | KnxUiEntity] = []
if yaml_platform_config := knx_module.config_yaml.get(Platform.TEXT):
entities.extend(
KnxYamlText(knx_module, entity_config)
for entity_config in yaml_platform_config
)
if ui_config := knx_module.config_store.data["entities"].get(Platform.TEXT):
entities.extend(
KnxUiText(knx_module, unique_id, config)
for unique_id, config in ui_config.items()
)
if entities:
async_add_entities(entities)
class _KnxText(TextEntity, RestoreEntity):
class KNXText(KnxYamlEntity, TextEntity, RestoreEntity):
"""Representation of a KNX text."""
_device: XknxNotification
_attr_native_max = 14
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX text."""
super().__init__(
knx_module=knx_module,
device=_create_notification(knx_module.xknx, config),
)
self._attr_mode = config[CONF_MODE]
self._attr_pattern = (
r"[\u0000-\u00ff]*" # Latin-1
if issubclass(self._device.remote_value.dpt_class, DPTLatin1)
else r"[\u0000-\u007f]*" # ASCII
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
@@ -86,15 +81,6 @@ class _KnxText(TextEntity, RestoreEntity):
if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
self._device.remote_value.value = last_state.state
@cached_property
def pattern(self) -> str | None:
"""Return the regex pattern that the value must match."""
return (
r"[\u0000-\u00ff]*" # Latin-1
if issubclass(self._device.remote_value.dpt_class, DPTLatin1)
else r"[\u0000-\u007f]*" # ASCII
)
@property
def native_value(self) -> str | None:
"""Return the value reported by the text."""
@@ -103,56 +89,3 @@ class _KnxText(TextEntity, RestoreEntity):
async def async_set_value(self, value: str) -> None:
"""Change the value."""
await self._device.set(value)
class KnxYamlText(_KnxText, KnxYamlEntity):
"""Representation of a KNX text configured from YAML."""
_device: XknxNotification
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX text."""
super().__init__(
knx_module=knx_module,
device=XknxNotification(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
),
)
self._attr_mode = config[CONF_MODE]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiText(_KnxText, KnxUiEntity):
"""Representation of a KNX text configured from UI."""
_device: XknxNotification
def __init__(
self,
knx_module: KNXModule,
unique_id: str,
config: ConfigType,
) -> None:
"""Initialize a KNX text."""
super().__init__(
knx_module=knx_module,
unique_id=unique_id,
entity_config=config[CONF_ENTITY],
)
knx_conf = ConfigExtractor(config[DOMAIN])
self._device = XknxNotification(
knx_module.xknx,
name=config[CONF_ENTITY][CONF_NAME],
group_address=knx_conf.get_write(CONF_GA_TEXT),
group_address_state=knx_conf.get_state_and_passive(CONF_GA_TEXT),
respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ),
sync_state=knx_conf.get(CONF_SYNC_STATE),
value_type=knx_conf.get_dpt(CONF_GA_TEXT),
)
self._attr_mode = TextMode(knx_conf.get(CONF_MODE))

View File

@@ -23,7 +23,6 @@ from homeassistant.helpers.typing import UNDEFINED
from homeassistant.util.ulid import ulid_now
from .const import DOMAIN, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI
from .dpt import get_supported_dpts
from .storage.config_store import ConfigStoreException
from .storage.const import CONF_DATA
from .storage.entity_store_schema import (
@@ -192,7 +191,6 @@ def ws_get_base_data(
msg["id"],
{
"connection_info": connection_info,
"dpt_metadata": get_supported_dpts(),
"project_info": _project_info,
"supported_platforms": sorted(SUPPORTED_PLATFORMS_UI),
},

View File

@@ -39,6 +39,19 @@ class LaMarzoccoSwitchEntityDescription(
ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
LaMarzoccoSwitchEntityDescription(
key="main",
translation_key="main",
name=None,
control_fn=lambda machine, state: machine.set_power(state),
is_on_fn=(
lambda machine: cast(
MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS]
).mode
is MachineMode.BREWING_MODE
),
bt_offline_mode=True,
),
LaMarzoccoSwitchEntityDescription(
key="steam_boiler_enable",
translation_key="steam_boiler",
@@ -85,20 +98,6 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
),
)
MAIN_SWITCH_ENTITY = LaMarzoccoSwitchEntityDescription(
key="main",
translation_key="main",
name=None,
control_fn=lambda machine, state: machine.set_power(state),
is_on_fn=(
lambda machine: cast(
MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS]
).mode
is MachineMode.BREWING_MODE
),
bt_offline_mode=True,
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -108,11 +107,12 @@ async def async_setup_entry(
"""Set up switch entities and services."""
coordinator = entry.runtime_data.config_coordinator
bluetooth_coordinator = entry.runtime_data.bluetooth_coordinator
entities: list[SwitchEntity] = []
entities.extend(
LaMarzoccoSwitchEntity(coordinator, description, bluetooth_coordinator)
LaMarzoccoSwitchEntity(
coordinator, description, entry.runtime_data.bluetooth_coordinator
)
for description in ENTITIES
if description.supported_fn(coordinator)
)
@@ -122,12 +122,6 @@ async def async_setup_entry(
for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules
)
entities.append(
LaMarzoccoMainSwitchEntity(
coordinator, MAIN_SWITCH_ENTITY, bluetooth_coordinator
)
)
async_add_entities(entities)
@@ -166,17 +160,6 @@ class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity):
return self.entity_description.is_on_fn(self.coordinator.device)
class LaMarzoccoMainSwitchEntity(LaMarzoccoSwitchEntity):
"""Switch representing espresso machine main power."""
@property
def entity_picture(self) -> str | None:
"""Return the entity picture."""
image_url = self.coordinator.device.dashboard.image_url
return image_url if image_url else None # image URL can be empty string
class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
"""Switch representing espresso machine auto on/off."""

View File

@@ -22,19 +22,5 @@
"unlock": {
"service": "mdi:lock-open-variant"
}
},
"triggers": {
"jammed": {
"trigger": "mdi:lock-alert"
},
"locked": {
"trigger": "mdi:lock"
},
"opened": {
"trigger": "mdi:lock-open-variant"
},
"unlocked": {
"trigger": "mdi:lock-open-variant"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted locks to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"lock": "Lock {entity_name}",
@@ -54,15 +50,6 @@
"message": "The code for {entity_id} doesn't match pattern {code_format}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"lock": {
"description": "Locks a lock.",
@@ -95,47 +82,5 @@
"name": "Unlock"
}
},
"title": "Lock",
"triggers": {
"jammed": {
"description": "Triggers after one or more locks jam.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock jammed"
},
"locked": {
"description": "Triggers after one or more locks lock.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock locked"
},
"opened": {
"description": "Triggers after one or more locks open.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock opened"
},
"unlocked": {
"description": "Triggers after one or more locks unlock.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock unlocked"
}
}
"title": "Lock"
}

View File

@@ -1,18 +0,0 @@
"""Provides triggers for locks."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN, LockState
TRIGGERS: dict[str, type[Trigger]] = {
"jammed": make_entity_target_state_trigger(DOMAIN, LockState.JAMMED),
"locked": make_entity_target_state_trigger(DOMAIN, LockState.LOCKED),
"opened": make_entity_target_state_trigger(DOMAIN, LockState.OPEN),
"unlocked": make_entity_target_state_trigger(DOMAIN, LockState.UNLOCKED),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for locks."""
return TRIGGERS

View File

@@ -1,20 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: lock
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
jammed: *trigger_common
locked: *trigger_common
opened: *trigger_common
unlocked: *trigger_common

View File

@@ -2,7 +2,7 @@
from typing import Final
from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info
from lunatone_rest_api_client import Auth, Devices, Info
from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant
@@ -42,27 +42,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
name=info_api.name,
manufacturer="Lunatone",
sw_version=info_api.version,
hw_version=coordinator_info.data.device.pcb,
hw_version=info_api.data.device.pcb,
configuration_url=entry.data[CONF_URL],
serial_number=str(info_api.serial_number),
model=info_api.product_name,
model_id=(
f"{coordinator_info.data.device.article_number}{coordinator_info.data.device.article_info}"
f"{info_api.data.device.article_number}{info_api.data.device.article_info}"
),
)
coordinator_devices = LunatoneDevicesDataUpdateCoordinator(hass, entry, devices_api)
await coordinator_devices.async_config_entry_first_refresh()
dali_line_broadcasts = [
DALIBroadcast(auth_api, int(line)) for line in coordinator_info.data.lines
]
entry.runtime_data = LunatoneData(
coordinator_info,
coordinator_devices,
dali_line_broadcasts,
)
entry.runtime_data = LunatoneData(coordinator_info, coordinator_devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -7,7 +7,7 @@ from datetime import timedelta
import logging
import aiohttp
from lunatone_rest_api_client import DALIBroadcast, Device, Devices, Info
from lunatone_rest_api_client import Device, Devices, Info
from lunatone_rest_api_client.models import InfoData
from homeassistant.config_entries import ConfigEntry
@@ -18,7 +18,6 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DEFAULT_INFO_SCAN_INTERVAL = timedelta(seconds=60)
DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10)
@@ -28,7 +27,6 @@ class LunatoneData:
coordinator_info: LunatoneInfoDataUpdateCoordinator
coordinator_devices: LunatoneDevicesDataUpdateCoordinator
dali_line_broadcasts: list[DALIBroadcast]
type LunatoneConfigEntry = ConfigEntry[LunatoneData]
@@ -49,7 +47,6 @@ class LunatoneInfoDataUpdateCoordinator(DataUpdateCoordinator[InfoData]):
config_entry=config_entry,
name=f"{DOMAIN}-info",
always_update=False,
update_interval=DEFAULT_INFO_SCAN_INTERVAL,
)
self.info_api = info_api

View File

@@ -5,9 +5,6 @@ from __future__ import annotations
import asyncio
from typing import Any
from lunatone_rest_api_client import DALIBroadcast
from lunatone_rest_api_client.models import LineStatus
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ColorMode,
@@ -21,11 +18,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .const import DOMAIN
from .coordinator import (
LunatoneConfigEntry,
LunatoneDevicesDataUpdateCoordinator,
LunatoneInfoDataUpdateCoordinator,
)
from .coordinator import LunatoneConfigEntry, LunatoneDevicesDataUpdateCoordinator
PARALLEL_UPDATES = 0
STATUS_UPDATE_DELAY = 0.04
@@ -39,15 +32,8 @@ async def async_setup_entry(
"""Set up the Lunatone Light platform."""
coordinator_info = config_entry.runtime_data.coordinator_info
coordinator_devices = config_entry.runtime_data.coordinator_devices
dali_line_broadcasts = config_entry.runtime_data.dali_line_broadcasts
entities: list[LightEntity] = [
LunatoneLineBroadcastLight(
coordinator_info, coordinator_devices, dali_line_broadcast
)
for dali_line_broadcast in dali_line_broadcasts
]
entities.extend(
async_add_entities(
[
LunatoneLight(
coordinator_devices, device_id, coordinator_info.data.device.serial
@@ -56,8 +42,6 @@ async def async_setup_entry(
]
)
async_add_entities(entities)
class LunatoneLight(
CoordinatorEntity[LunatoneDevicesDataUpdateCoordinator], LightEntity
@@ -78,24 +62,22 @@ class LunatoneLight(
device_id: int,
interface_serial_number: int,
) -> None:
"""Initialize a Lunatone light."""
super().__init__(coordinator)
"""Initialize a LunatoneLight."""
super().__init__(coordinator=coordinator)
self._device_id = device_id
self._interface_serial_number = interface_serial_number
self._device = self.coordinator.data[self._device_id]
self._device = self.coordinator.data.get(self._device_id)
self._attr_unique_id = f"{interface_serial_number}-device{device_id}"
@property
def device_info(self) -> DeviceInfo:
"""Return the device info."""
assert self.unique_id
name = self._device.name if self._device is not None else None
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=self._device.name,
via_device=(
DOMAIN,
f"{self._interface_serial_number}-line{self._device.data.line}",
),
name=name,
via_device=(DOMAIN, str(self._interface_serial_number)),
)
@property
@@ -111,6 +93,8 @@ class LunatoneLight(
@property
def brightness(self) -> int:
"""Return the brightness of this light between 0..255."""
if self._device is None:
return 0
return value_to_brightness(self.BRIGHTNESS_SCALE, self._device.brightness)
@property
@@ -128,17 +112,17 @@ class LunatoneLight(
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._device = self.coordinator.data[self._device_id]
self._device = self.coordinator.data.get(self._device_id)
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
assert self._device
if brightness_supported(self.supported_color_modes):
brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness)
await self._device.fade_to_brightness(
brightness_to_value(
self.BRIGHTNESS_SCALE,
kwargs.get(ATTR_BRIGHTNESS, self._last_brightness),
)
brightness_to_value(self.BRIGHTNESS_SCALE, brightness)
)
else:
await self._device.switch_on()
@@ -148,6 +132,8 @@ class LunatoneLight(
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""
assert self._device
if brightness_supported(self.supported_color_modes):
self._last_brightness = self.brightness
await self._device.fade_to_brightness(0)
@@ -156,69 +142,3 @@ class LunatoneLight(
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self.coordinator.async_refresh()
class LunatoneLineBroadcastLight(
CoordinatorEntity[LunatoneInfoDataUpdateCoordinator], LightEntity
):
"""Representation of a Lunatone line broadcast light."""
BRIGHTNESS_SCALE = (1, 100)
_attr_assumed_state = True
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__(
self,
coordinator_info: LunatoneInfoDataUpdateCoordinator,
coordinator_devices: LunatoneDevicesDataUpdateCoordinator,
broadcast: DALIBroadcast,
) -> None:
"""Initialize a Lunatone line broadcast light."""
super().__init__(coordinator_info)
self._coordinator_devices = coordinator_devices
self._broadcast = broadcast
line = broadcast.line
self._attr_unique_id = f"{coordinator_info.data.device.serial}-line{line}"
line_device = self.coordinator.data.lines[str(line)].device
extra_info: dict = {}
if line_device.serial != coordinator_info.data.device.serial:
extra_info.update(
serial_number=str(line_device.serial),
hw_version=line_device.pcb,
model_id=f"{line_device.article_number}{line_device.article_info}",
)
assert self.unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=f"DALI Line {line}",
via_device=(DOMAIN, str(coordinator_info.data.device.serial)),
**extra_info,
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
line_status = self.coordinator.data.lines[str(self._broadcast.line)].line_status
return super().available and line_status == LineStatus.OK
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the line to turn on."""
await self._broadcast.fade_to_brightness(
brightness_to_value(self.BRIGHTNESS_SCALE, kwargs.get(ATTR_BRIGHTNESS, 255))
)
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self._coordinator_devices.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the line to turn off."""
await self._broadcast.fade_to_brightness(0)
await asyncio.sleep(STATUS_UPDATE_DELAY)
await self._coordinator_devices.async_refresh()

View File

@@ -42,9 +42,6 @@
"number": {
"cook_time": {
"default": "mdi:microwave"
},
"speaker_setpoint": {
"default": "mdi:speaker"
}
},
"select": {

View File

@@ -365,31 +365,6 @@ DISCOVERY_SCHEMAS = [
clusters.MicrowaveOvenControl.Attributes.MaxCookTime,
),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterRangeNumberEntityDescription(
key="speaker_setpoint",
translation_key="speaker_setpoint",
native_unit_of_measurement=PERCENTAGE,
command=lambda value: clusters.LevelControl.Commands.MoveToLevel(
level=int(value)
),
native_min_value=0,
native_max_value=100,
native_step=1,
device_to_ha=lambda x: None if x is None else x,
min_attribute=clusters.LevelControl.Attributes.MinLevel,
max_attribute=clusters.LevelControl.Attributes.MaxLevel,
mode=NumberMode.SLIDER,
),
entity_class=MatterRangeNumber,
required_attributes=(
clusters.LevelControl.Attributes.CurrentLevel,
clusters.LevelControl.Attributes.MinLevel,
clusters.LevelControl.Attributes.MaxLevel,
),
device_type=(device_types.Speaker,),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(

View File

@@ -232,9 +232,6 @@
"pump_setpoint": {
"name": "Setpoint"
},
"speaker_setpoint": {
"name": "Volume"
},
"temperature_offset": {
"name": "Temperature offset"
},

View File

@@ -20,11 +20,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_CAMERA_LIGHT_MODE,
ATTR_EVENT_TYPE,
ATTR_PERSON,
ATTR_PERSONS,
CAMERA_LIGHT_MODES,
CAMERA_TRIGGERS,
CONF_URL_SECURITY,
DATA_CAMERAS,
DATA_EVENTS,
@@ -39,6 +37,8 @@ from .const import (
SERVICE_SET_CAMERA_LIGHT,
SERVICE_SET_PERSON_AWAY,
SERVICE_SET_PERSONS_HOME,
WEBHOOK_LIGHT_MODE,
WEBHOOK_NACAMERA_CONNECTION,
WEBHOOK_PUSH_TYPE,
)
from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoDevice
@@ -125,7 +125,13 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
"""Entity created."""
await super().async_added_to_hass()
for event_type in CAMERA_TRIGGERS:
for event_type in (
EVENT_TYPE_LIGHT_MODE,
EVENT_TYPE_OFF,
EVENT_TYPE_ON,
EVENT_TYPE_CONNECTION,
EVENT_TYPE_DISCONNECTION,
):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
@@ -140,63 +146,34 @@ class NetatmoCamera(NetatmoModuleEntity, Camera):
def handle_event(self, event: dict) -> None:
"""Handle webhook events."""
data = event["data"]
event_type = data.get(ATTR_EVENT_TYPE)
push_type = data.get(WEBHOOK_PUSH_TYPE)
if not push_type:
_LOGGER.debug("Event has no push_type, returning")
return
if not data.get("camera_id"):
_LOGGER.debug("Event %s has no camera ID, returning", event_type)
return
if (
data["home_id"] == self.home.entity_id
and data["camera_id"] == self.device.entity_id
):
# device_type to be stripped "DeviceType."
device_push_type = f"{self.device_type.name}-{event_type}"
if push_type != device_push_type:
_LOGGER.debug(
"Event push_type %s does not match device push_type %s, returning",
push_type,
device_push_type,
)
return
if event_type in [EVENT_TYPE_DISCONNECTION, EVENT_TYPE_OFF]:
_LOGGER.debug(
"Camera %s has received %s event, turning off and idleing streaming",
data["camera_id"],
event_type,
)
if data[WEBHOOK_PUSH_TYPE] in (
"NACamera-off",
"NOCamera-off",
"NACamera-disconnection",
"NOCamera-disconnection",
):
self._attr_is_streaming = False
self._monitoring = False
elif event_type in [EVENT_TYPE_CONNECTION, EVENT_TYPE_ON]:
_LOGGER.debug(
"Camera %s has received %s event, turning on and enabling streaming",
data["camera_id"],
event_type,
)
elif data[WEBHOOK_PUSH_TYPE] in (
"NACamera-on",
"NOCamera-on",
WEBHOOK_NACAMERA_CONNECTION,
"NOCamera-connection",
):
self._attr_is_streaming = True
self._monitoring = True
elif event_type == EVENT_TYPE_LIGHT_MODE:
if data.get("sub_type"):
self._light_state = data["sub_type"]
self._attr_extra_state_attributes.update(
{"light_state": self._light_state}
)
else:
_LOGGER.debug(
"Camera %s has received light mode event without sub_type",
data["camera_id"],
)
else:
_LOGGER.debug(
"Camera %s has received unexpected event as type %s",
data["camera_id"],
event_type,
elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE:
self._light_state = data["sub_type"]
self._attr_extra_state_attributes.update(
{"light_state": self._light_state}
)
self.async_write_ha_state()

View File

@@ -114,51 +114,41 @@ EVENT_TYPE_SCHEDULE = "schedule"
EVENT_TYPE_SET_POINT = "set_point"
EVENT_TYPE_THERM_MODE = "therm_mode"
# Camera events
EVENT_TYPE_ANIMAL = "animal"
EVENT_TYPE_HUMAN = "human"
EVENT_TYPE_MOVEMENT = "movement"
EVENT_TYPE_OUTDOOR = "outdoor"
EVENT_TYPE_PERSON = "person"
EVENT_TYPE_PERSON_AWAY = "person_away"
EVENT_TYPE_VEHICLE = "vehicle"
EVENT_TYPE_CAMERA_ANIMAL = "animal"
EVENT_TYPE_CAMERA_HUMAN = "human"
EVENT_TYPE_CAMERA_MOVEMENT = "movement"
EVENT_TYPE_CAMERA_OUTDOOR = "outdoor"
EVENT_TYPE_CAMERA_PERSON = "person"
EVENT_TYPE_CAMERA_PERSON_AWAY = "person_away"
EVENT_TYPE_CAMERA_VEHICLE = "vehicle"
EVENT_TYPE_LIGHT_MODE = "light_mode"
# Door tags
EVENT_TYPE_ALARM_STARTED = "alarm_started"
EVENT_TYPE_TAG_BIG_MOVE = "tag_big_move"
EVENT_TYPE_TAG_OPEN = "tag_open"
EVENT_TYPE_TAG_SMALL_MOVE = "tag_small_move"
EVENT_TYPE_DOOR_TAG_BIG_MOVE = "tag_big_move"
EVENT_TYPE_DOOR_TAG_OPEN = "tag_open"
EVENT_TYPE_DOOR_TAG_SMALL_MOVE = "tag_small_move"
# Generic events
EVENT_TYPE_CONNECTION = "connection"
EVENT_TYPE_DISCONNECTION = "disconnection"
EVENT_TYPE_MODULE_CONNECT = "module_connect"
EVENT_TYPE_MODULE_DISCONNECT = "module_disconnect"
EVENT_TYPE_OFF = "off"
EVENT_TYPE_ON = "on"
CAMERA_TRIGGERS = [
EVENT_TYPE_CONNECTION,
EVENT_TYPE_DISCONNECTION,
EVENT_TYPE_LIGHT_MODE,
EVENT_TYPE_OFF,
EVENT_TYPE_ON,
]
OUTDOOR_CAMERA_TRIGGERS = [
EVENT_TYPE_ANIMAL,
EVENT_TYPE_HUMAN,
EVENT_TYPE_OUTDOOR,
EVENT_TYPE_VEHICLE,
EVENT_TYPE_CAMERA_ANIMAL,
EVENT_TYPE_CAMERA_HUMAN,
EVENT_TYPE_CAMERA_OUTDOOR,
EVENT_TYPE_CAMERA_VEHICLE,
]
INDOOR_CAMERA_TRIGGERS = [
EVENT_TYPE_ALARM_STARTED,
EVENT_TYPE_MOVEMENT,
EVENT_TYPE_PERSON_AWAY,
EVENT_TYPE_PERSON,
EVENT_TYPE_CAMERA_MOVEMENT,
EVENT_TYPE_CAMERA_PERSON_AWAY,
EVENT_TYPE_CAMERA_PERSON,
]
DOOR_TAG_TRIGGERS = [
EVENT_TYPE_TAG_BIG_MOVE,
EVENT_TYPE_TAG_OPEN,
EVENT_TYPE_TAG_SMALL_MOVE,
EVENT_TYPE_DOOR_TAG_BIG_MOVE,
EVENT_TYPE_DOOR_TAG_OPEN,
EVENT_TYPE_DOOR_TAG_SMALL_MOVE,
]
CLIMATE_TRIGGERS = [
EVENT_TYPE_CANCEL_SET_POINT,
@@ -167,20 +157,18 @@ CLIMATE_TRIGGERS = [
]
EVENT_ID_MAP = {
EVENT_TYPE_ALARM_STARTED: "device_id",
EVENT_TYPE_ANIMAL: "device_id",
EVENT_TYPE_HUMAN: "device_id",
EVENT_TYPE_MOVEMENT: "device_id",
EVENT_TYPE_OUTDOOR: "device_id",
EVENT_TYPE_PERSON_AWAY: "device_id",
EVENT_TYPE_PERSON: "device_id",
EVENT_TYPE_VEHICLE: "device_id",
EVENT_TYPE_CAMERA_ANIMAL: "device_id",
EVENT_TYPE_CAMERA_HUMAN: "device_id",
EVENT_TYPE_CAMERA_MOVEMENT: "device_id",
EVENT_TYPE_CAMERA_OUTDOOR: "device_id",
EVENT_TYPE_CAMERA_PERSON_AWAY: "device_id",
EVENT_TYPE_CAMERA_PERSON: "device_id",
EVENT_TYPE_CAMERA_VEHICLE: "device_id",
EVENT_TYPE_CANCEL_SET_POINT: "room_id",
EVENT_TYPE_TAG_BIG_MOVE: "device_id",
EVENT_TYPE_TAG_OPEN: "device_id",
EVENT_TYPE_TAG_SMALL_MOVE: "device_id",
EVENT_TYPE_DOOR_TAG_BIG_MOVE: "device_id",
EVENT_TYPE_DOOR_TAG_OPEN: "device_id",
EVENT_TYPE_DOOR_TAG_SMALL_MOVE: "device_id",
EVENT_TYPE_LIGHT_MODE: "device_id",
EVENT_TYPE_MODULE_CONNECT: "module_id",
EVENT_TYPE_MODULE_DISCONNECT: "module_id",
EVENT_TYPE_SET_POINT: "room_id",
EVENT_TYPE_THERM_MODE: "home_id",
}
@@ -190,12 +178,8 @@ MODE_LIGHT_OFF = "off"
MODE_LIGHT_ON = "on"
CAMERA_LIGHT_MODES = [MODE_LIGHT_ON, MODE_LIGHT_OFF, MODE_LIGHT_AUTO]
# Webhook push_types MUST follow exactly Netatmo's naming on products!
# See https://dev.netatmo.com/apidocumentation
# e.g. cameras: NACamera, NOC, etc.
WEBHOOK_ACTIVATION = "webhook_activation"
WEBHOOK_DEACTIVATION = "webhook_deactivation"
WEBHOOK_LIGHT_MODE = "NOC-light_mode"
WEBHOOK_NACAMERA_CONNECTION = "NACamera-connection"
WEBHOOK_NOCAMERA_CONNECTION = "NOC-connection"
WEBHOOK_PUSH_TYPE = "push_type"
CAMERA_CONNECTION_WEBHOOKS = [WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NOCAMERA_CONNECTION]

View File

@@ -28,7 +28,6 @@ from homeassistant.helpers.event import async_track_time_interval
from .const import (
AUTH,
CAMERA_CONNECTION_WEBHOOKS,
DATA_PERSONS,
DATA_SCHEDULES,
DOMAIN,
@@ -49,6 +48,7 @@ from .const import (
PLATFORMS,
WEBHOOK_ACTIVATION,
WEBHOOK_DEACTIVATION,
WEBHOOK_NACAMERA_CONNECTION,
WEBHOOK_PUSH_TYPE,
)
@@ -223,7 +223,7 @@ class NetatmoDataHandler:
_LOGGER.debug("%s webhook unregistered", MANUFACTURER)
self._webhook = False
elif event["data"][WEBHOOK_PUSH_TYPE] in CAMERA_CONNECTION_WEBHOOKS:
elif event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_NACAMERA_CONNECTION:
_LOGGER.debug("%s camera reconnected", MANUFACTURER)
self.async_force_update(ACCOUNT)

View File

@@ -14,13 +14,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_EVENT_TYPE,
CONF_URL_CONTROL,
CONF_URL_SECURITY,
DOMAIN,
EVENT_TYPE_LIGHT_MODE,
NETATMO_CREATE_CAMERA_LIGHT,
NETATMO_CREATE_LIGHT,
WEBHOOK_LIGHT_MODE,
WEBHOOK_PUSH_TYPE,
)
from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice
from .entity import NetatmoModuleEntity
@@ -113,7 +114,7 @@ class NetatmoCameraLight(NetatmoModuleEntity, LightEntity):
if (
data["home_id"] == self.home.entity_id
and data["camera_id"] == self.device.entity_id
and data[ATTR_EVENT_TYPE] == EVENT_TYPE_LIGHT_MODE
and data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE
):
self._attr_is_on = bool(data["sub_type"] == "on")

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.2"]
"requirements": ["renault-api==0.5.1"]
}

View File

@@ -14,13 +14,5 @@
"turn_on": {
"service": "mdi:bullhorn"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:bullhorn-outline"
},
"turned_on": {
"trigger": "mdi:bullhorn"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted sirens to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::siren::title%]",
@@ -17,15 +13,6 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"toggle": {
"description": "Toggles the siren on/off.",
@@ -54,27 +41,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Siren",
"triggers": {
"turned_off": {
"description": "Triggers after one or more sirens turn off.",
"fields": {
"behavior": {
"description": "[%key:component::siren::common::trigger_behavior_description%]",
"name": "[%key:component::siren::common::trigger_behavior_name%]"
}
},
"name": "Siren turned off"
},
"turned_on": {
"description": "Triggers after one or more sirens turn on.",
"fields": {
"behavior": {
"description": "[%key:component::siren::common::trigger_behavior_description%]",
"name": "[%key:component::siren::common::trigger_behavior_name%]"
}
},
"name": "Siren turned on"
}
}
"title": "Siren"
}

View File

@@ -1,17 +0,0 @@
"""Provides triggers for sirens."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for sirens."""
return TRIGGERS

View File

@@ -1,18 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: siren
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Generator, Sequence
from enum import Enum
import logging
from typing import TYPE_CHECKING, Any
@@ -28,7 +29,8 @@ from homeassistant.const import (
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@@ -37,7 +39,6 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import validators as tcv
from .const import DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
@@ -55,6 +56,19 @@ from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
_VALID_STATES = [
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
STATE_UNAVAILABLE,
]
CONF_ALARM_CONTROL_PANELS = "panels"
CONF_ARM_AWAY_ACTION = "arm_away"
@@ -198,22 +212,22 @@ class AbstractTemplateAlarmControlPanel(
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
# The super init is not called because TemplateEntity calls AbstractTemplateEntity.__init__.
def __init__(self, name: str) -> None: # pylint: disable=super-init-not-called
"""Setup the templates and scripts."""
self._attr_code_arm_required: bool = self._config[CONF_CODE_ARM_REQUIRED]
self._attr_code_format = self._config[CONF_CODE_FORMAT].value
self.setup_state_template(
CONF_STATE,
"_attr_alarm_state",
validator=tcv.strenum(self, CONF_STATE, AlarmControlPanelState),
)
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
"""Initialize the features."""
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
self._attr_code_format = config[CONF_CODE_FORMAT].value
self._attr_supported_features: AlarmControlPanelEntityFeature = (
AlarmControlPanelEntityFeature(0)
)
def _iterate_scripts(
self, config: dict[str, Any]
) -> Generator[
tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int]
]:
for action_id, supported_feature in (
(CONF_DISARM_ACTION, 0),
(CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY),
@@ -226,21 +240,35 @@ class AbstractTemplateAlarmControlPanel(
),
(CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER),
):
if (action_config := self._config.get(action_id)) is not None:
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
if (action_config := config.get(action_id)) is not None:
yield (action_id, action_config, supported_feature)
async def _async_handle_restored_state(self) -> None:
if (
(last_state := await self.async_get_last_state()) is not None
and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE)
and last_state.state in AlarmControlPanelState
and last_state.state in _VALID_STATES
# The trigger might have fired already while we waited for stored data,
# then we should not restore state
and self._attr_alarm_state is None
):
self._attr_alarm_state = AlarmControlPanelState(last_state.state)
def _handle_state(self, result: Any) -> None:
# Validate state
if result in _VALID_STATES:
self._attr_alarm_state = result
_LOGGER.debug("Valid state - %s", result)
return
_LOGGER.error(
"Received invalid alarm panel state: %s for entity %s. Expected: %s",
result,
self.entity_id,
", ".join(_VALID_STATES),
)
self._attr_alarm_state = None
async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any):
"""Arm the panel to specified state with supplied script."""
@@ -323,17 +351,39 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
) -> None:
"""Initialize the panel."""
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateAlarmControlPanel.__init__(self, config)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None
AbstractTemplateAlarmControlPanel.__init__(self, name)
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
await self._async_handle_restored_state()
@callback
def _update_state(self, result):
if isinstance(result, TemplateError):
self._attr_alarm_state = None
return
self._handle_state(result)
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
if self._template:
self.add_template_attribute(
"_attr_alarm_state", self._template, None, self._update_state
)
super()._async_setup_templates()
class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControlPanel):
"""Alarm Control Panel entity based on trigger data."""
@@ -348,8 +398,19 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateAlarmControlPanel.__init__(self, config)
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
AbstractTemplateAlarmControlPanel.__init__(self, name)
if isinstance(config.get(CONF_STATE), template.Template):
self._to_render_simple.append(CONF_STATE)
self._parse_result.add(CONF_STATE)
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
async def async_added_to_hass(self) -> None:
"""Restore last state."""
@@ -365,6 +426,7 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
self.async_write_ha_state()
return
if self.handle_rendered_result(CONF_STATE):
if (rendered := self._rendered.get(CONF_STATE)) is not None:
self._handle_state(rendered)
self.async_set_context(self.coordinator.data["context"])
self.async_write_ha_state()

View File

@@ -31,7 +31,6 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL,
Platform,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import section
@@ -133,15 +132,6 @@ from .vacuum import (
SERVICE_STOP,
async_create_preview_vacuum,
)
from .weather import (
CONF_CONDITION,
CONF_FORECAST_DAILY,
CONF_FORECAST_HOURLY,
CONF_HUMIDITY,
CONF_TEMPERATURE as CONF_WEATHER_TEMPERATURE,
CONF_TEMPERATURE_UNIT,
async_create_preview_weather,
)
_SCHEMA_STATE: dict[vol.Marker, Any] = {
vol.Required(CONF_STATE): selector.TemplateSelector(),
@@ -404,22 +394,6 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
vol.Optional(SERVICE_LOCATE): selector.ActionSelector(),
}
if domain == Platform.WEATHER:
schema |= {
vol.Required(CONF_CONDITION): selector.TemplateSelector(),
vol.Required(CONF_HUMIDITY): selector.TemplateSelector(),
vol.Required(CONF_WEATHER_TEMPERATURE): selector.TemplateSelector(),
vol.Optional(CONF_TEMPERATURE_UNIT): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in UnitOfTemperature],
mode=selector.SelectSelectorMode.DROPDOWN,
sort=True,
),
),
vol.Optional(CONF_FORECAST_DAILY): selector.TemplateSelector(),
vol.Optional(CONF_FORECAST_HOURLY): selector.TemplateSelector(),
}
schema |= {
vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
vol.Optional(CONF_ADVANCED_OPTIONS): section(
@@ -440,15 +414,6 @@ options_schema = partial(generate_schema, flow_type="options")
config_schema = partial(generate_schema, flow_type="config")
async def _get_forecast_description_place_holders(
handler: SchemaCommonFlowHandler,
) -> dict[str, str]:
return {
"daily_link": "https://www.home-assistant.io/integrations/template/#daily-weather-forecast",
"hourly_link": "https://www.home-assistant.io/integrations/template/#hourly-weather-forecast",
}
async def choose_options_step(options: dict[str, Any]) -> str:
"""Return next step_id for options flow according to template_type."""
return cast(str, options["template_type"])
@@ -546,7 +511,6 @@ TEMPLATE_TYPES = [
Platform.SWITCH,
Platform.UPDATE,
Platform.VACUUM,
Platform.WEATHER,
]
CONFIG_FLOW = {
@@ -625,12 +589,6 @@ CONFIG_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.VACUUM),
),
Platform.WEATHER: SchemaFlowFormStep(
config_schema(Platform.WEATHER),
preview="template",
validate_user_input=validate_user_input(Platform.WEATHER),
description_placeholders=_get_forecast_description_place_holders,
),
}
@@ -710,12 +668,6 @@ OPTIONS_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.VACUUM),
),
Platform.WEATHER: SchemaFlowFormStep(
options_schema(Platform.WEATHER),
preview="template",
validate_user_input=validate_user_input(Platform.WEATHER),
description_placeholders=_get_forecast_description_place_holders,
),
}
CREATE_PREVIEW_ENTITY: dict[
@@ -735,7 +687,6 @@ CREATE_PREVIEW_ENTITY: dict[
Platform.SWITCH: async_create_preview_switch,
Platform.UPDATE: async_create_preview_update,
Platform.VACUUM: async_create_preview_vacuum,
Platform.WEATHER: async_create_preview_weather,
}

View File

@@ -1,8 +1,7 @@
"""Template entity base class."""
from abc import abstractmethod
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from collections.abc import Sequence
import logging
from typing import Any
@@ -19,17 +18,6 @@ from .const import CONF_DEFAULT_ENTITY_ID
_LOGGER = logging.getLogger(__name__)
@dataclass
class EntityTemplate:
"""Information class for properly handling template results."""
attribute: str
template: Template
validator: Callable[[Any], Any] | None
on_update: Callable[[Any], None] | None
none_on_template_error: bool
class AbstractTemplateEntity(Entity):
"""Actions linked to a template entity."""
@@ -46,8 +34,6 @@ class AbstractTemplateEntity(Entity):
"""Initialize the entity."""
self.hass = hass
self._config = config
self._templates: dict[str, EntityTemplate] = {}
self._action_scripts: dict[str, Script] = {}
if self._optimistic_entity:
@@ -86,35 +72,6 @@ class AbstractTemplateEntity(Entity):
def _render_script_variables(self) -> dict:
"""Render configured variables."""
@abstractmethod
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
def add_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
none_on_template_error: bool = False,
add_if_static: bool = True,
) -> Template | None:
"""Add a template."""
if (template := self._config.get(option)) and isinstance(template, Template):
if add_if_static or (not template.is_static):
self._templates[option] = EntityTemplate(
attribute, template, validator, on_update, none_on_template_error
)
return template
return None
def add_script(
self,
script_id: str,

View File

@@ -463,8 +463,7 @@
"sensor": "[%key:component::sensor::title%]",
"switch": "[%key:component::switch::title%]",
"update": "[%key:component::update::title%]",
"vacuum": "[%key:component::vacuum::title%]",
"weather": "[%key:component::weather::title%]"
"vacuum": "[%key:component::vacuum::title%]"
},
"title": "Template helper"
},
@@ -508,36 +507,6 @@
}
},
"title": "Template vacuum"
},
"weather": {
"data": {
"condition": "Condition",
"device_id": "[%key:common::config_flow::data::device%]",
"forecast_daily": "Forecast daily",
"forecast_hourly": "Forecast hourly",
"humidity": "Humidity",
"name": "[%key:common::config_flow::data::name%]",
"temperature": "Temperature",
"temperature_unit": "Temperature unit"
},
"data_description": {
"condition": "Defines a template to get the current weather condition",
"device_id": "[%key:component::template::common::device_id_description%]",
"forecast_daily": "Defines a template to get the [daily forecast data]({daily_link})",
"forecast_hourly": "Defines a template to get the [hourly forecast data]({hourly_link})",
"humidity": "Defines a template to get the current humidity",
"temperature": "Defines a template to get the current temperature",
"temperature_unit": "The temperature unit"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "Template weather"
}
}
},
@@ -1026,36 +995,6 @@
}
},
"title": "[%key:component::template::config::step::vacuum::title%]"
},
"weather": {
"data": {
"condition": "[%key:component::template::config::step::weather::data::condition%]",
"device_id": "[%key:common::config_flow::data::device%]",
"forecast_daily": "[%key:component::template::config::step::weather::data::forecast_daily%]",
"forecast_hourly": "[%key:component::template::config::step::weather::data::forecast_hourly%]",
"humidity": "[%key:component::template::config::step::weather::data::humidity%]",
"name": "[%key:common::config_flow::data::name%]",
"temperature": "[%key:component::template::config::step::weather::data::temperature%]",
"temperature_unit": "[%key:component::template::config::step::weather::data::temperature_unit%]"
},
"data_description": {
"condition": "[%key:component::template::config::step::weather::data_description::condition%]",
"device_id": "[%key:component::template::common::device_id_description%]",
"forecast_daily": "[%key:component::template::config::step::weather::data_description::forecast_daily%]",
"forecast_hourly": "[%key:component::template::config::step::weather::data_description::forecast_hourly%]",
"humidity": "[%key:component::template::config::step::weather::data_description::humidity%]",
"temperature": "[%key:component::template::config::step::weather::data_description::temperature%]",
"temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "[%key:component::template::config::step::weather::title%]"
}
}
},

View File

@@ -181,6 +181,9 @@ class TemplateEntity(AbstractTemplateEntity):
self._run_variables: ScriptVariables | dict
self._attribute_templates = config.get(CONF_ATTRIBUTES)
self._availability_template = config.get(CONF_AVAILABILITY)
self._icon_template = config.get(CONF_ICON)
self._entity_picture_template = config.get(CONF_PICTURE)
self._friendly_name_template = config.get(CONF_NAME)
self._run_variables = config.get(CONF_VARIABLES, {})
self._blueprint_inputs = config.get("raw_blueprint_inputs")
@@ -205,28 +208,27 @@ class TemplateEntity(AbstractTemplateEntity):
)
variables = {"this": DummyState(), **variables}
self.add_template(
CONF_AVAILABILITY, "_attr_available", on_update=self._update_available
)
# Render name, icon, and picture early. name is rendered early because it influences
# the entity_id. icon and picture are rendered early to ensure they are populated even
# if the entity renders unavailable.
# Try to render the name as it can influence the entity ID
self._attr_name = None
for option, attribute, validator in (
(CONF_ICON, "_attr_icon", vol.Or(cv.whitespace, cv.icon)),
(CONF_PICTURE, "_attr_entity_picture", cv.string),
(CONF_NAME, "_attr_name", cv.string),
):
if template := self.add_template(
option, attribute, validator, add_if_static=option != CONF_NAME
):
with contextlib.suppress(TemplateError):
setattr(
self,
attribute,
template.async_render(variables=variables, parse_result=False),
)
if self._friendly_name_template:
with contextlib.suppress(TemplateError):
self._attr_name = self._friendly_name_template.async_render(
variables=variables, parse_result=False
)
# Templates will not render while the entity is unavailable, try to render the
# icon and picture templates.
if self._entity_picture_template:
with contextlib.suppress(TemplateError):
self._attr_entity_picture = self._entity_picture_template.async_render(
variables=variables, parse_result=False
)
if self._icon_template:
with contextlib.suppress(TemplateError):
self._attr_icon = self._icon_template.async_render(
variables=variables, parse_result=False
)
@callback
def _update_available(self, result: str | TemplateError) -> None:
@@ -276,33 +278,6 @@ class TemplateEntity(AbstractTemplateEntity):
},
)
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
@callback
def _update_state(result: Any) -> None:
if isinstance(result, TemplateError):
setattr(self, attribute, None)
if self._availability_template:
return
self._attr_available = False
return
state = validator(result) if validator else result
if on_update:
on_update(state)
else:
setattr(self, attribute, state)
self.add_template(option, attribute, on_update=_update_state)
def add_template_attribute(
self,
attribute: str,
@@ -442,20 +417,30 @@ class TemplateEntity(AbstractTemplateEntity):
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""
# Handle attributes as a dictionary.
if self._availability_template is not None:
self.add_template_attribute(
"_attr_available",
self._availability_template,
None,
self._update_available,
)
if self._attribute_templates is not None:
for key, value in self._attribute_templates.items():
self._add_attribute_template(key, value)
# Iterate all dynamic templates and add listeners.
for entity_template in self._templates.values():
if self._icon_template is not None:
self.add_template_attribute(
entity_template.attribute,
entity_template.template,
entity_template.validator,
entity_template.on_update,
entity_template.none_on_template_error,
"_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon)
)
if self._entity_picture_template is not None:
self.add_template_attribute(
"_attr_entity_picture", self._entity_picture_template, cv.string
)
if (
self._friendly_name_template is not None
and not self._friendly_name_template.is_static
):
self.add_template_attribute(
"_attr_name", self._friendly_name_template, cv.string
)
@callback

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from homeassistant.const import CONF_STATE, CONF_VARIABLES
@@ -51,19 +50,6 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
else:
self._unique_id = unique_id
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
if self._config.get(option):
self._to_render_simple.append(CONF_STATE)
self._parse_result.add(CONF_STATE)
self.add_template(option, attribute, validator, on_update)
@property
def referenced_blueprint(self) -> str | None:
"""Return referenced blueprint or None."""
@@ -103,23 +89,6 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
self._render_attributes(rendered, variables)
self._rendered = rendered
def handle_rendered_result(self, key: str) -> bool:
"""Get a rendered result and return the value."""
if (rendered := self._rendered.get(key)) is not None:
if (entity_template := self._templates.get(key)) is not None:
value = rendered
if entity_template.validator:
value = entity_template.validator(rendered)
if entity_template.on_update:
entity_template.on_update(value)
else:
setattr(self, entity_template.attribute, value)
return True
return False
@callback
def _process_data(self) -> None:
"""Process new data."""

View File

@@ -1,305 +0,0 @@
"""Template config validation methods."""
from collections.abc import Callable
from enum import StrEnum
import logging
from typing import Any
import voluptuous as vol
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import Entity
_LOGGER = logging.getLogger(__name__)
# Valid on/off values for booleans. These tuples are pulled
# from cv.boolean and are used to produce logger errors for the user.
RESULT_ON = ("1", "true", "yes", "on", "enable")
RESULT_OFF = ("0", "false", "no", "off", "disable")
def _log_validation_result_error(
entity: Entity,
attribute: str,
value: Any,
expected: tuple[str, ...] | str,
) -> None:
"""Log a template result error."""
# in some cases, like `preview` entities, the entity_id does not exist.
if entity.entity_id is None:
message = f"Received invalid {attribute}: {value} for entity {entity.name}, %s"
else:
message = (
f"Received invalid {entity.entity_id.split('.')[0]} {attribute}"
f": {value} for entity {entity.entity_id}, %s"
)
_LOGGER.error(
message,
expected
if isinstance(expected, str)
else "expected one of " + ", ".join(expected),
)
def _check_result_for_none(result: Any, **kwargs: Any) -> bool:
"""Checks the result for none, unknown, unavailable."""
if result is None:
return True
if kwargs.get("none_on_unknown_unavailable") and isinstance(result, str):
return result.lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN)
return False
def strenum[T: StrEnum](
entity: Entity,
attribute: str,
state_enum: type[T],
state_on: T | None = None,
state_off: T | None = None,
**kwargs: Any,
) -> Callable[[Any], T | None]:
"""Converts the template result to an StrEnum.
All strings will attempt to convert to the StrEnum
If state_on or state_off are provided, boolean values will return the
enum that represents each boolean value.
Anything that cannot convert will result in None.
none_on_unknown_unavailable
"""
def convert(result: Any) -> T | None:
if _check_result_for_none(result, **kwargs):
return None
if isinstance(result, str):
value = result.lower().strip()
try:
return state_enum(value)
except ValueError:
pass
if state_on or state_off:
try:
bool_value = cv.boolean(result)
if state_on and bool_value:
return state_on
if state_off and not bool_value:
return state_off
except vol.Invalid:
pass
expected = tuple(s.value for s in state_enum)
if state_on:
expected += RESULT_ON
if state_off:
expected += RESULT_OFF
_log_validation_result_error(
entity,
attribute,
result,
expected,
)
return None
return convert
def boolean(
entity: Entity,
attribute: str,
as_true: tuple[str, ...] | None = None,
as_false: tuple[str, ...] | None = None,
**kwargs: Any,
) -> Callable[[Any], bool | None]:
"""Convert the result to a boolean.
True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy
False/0/'0'/'false'/'no'/'off'/'disable' are considered falsy
Additional values provided by as_true are considered truthy
Additional values provided by as_false are considered truthy
All other values are None
"""
def convert(result: Any) -> bool | None:
if _check_result_for_none(result, **kwargs):
return None
if isinstance(result, bool):
return result
if isinstance(result, str) and (as_true or as_false):
value = result.lower().strip()
if as_true and value in as_true:
return True
if as_false and value in as_false:
return False
try:
return cv.boolean(result)
except vol.Invalid:
pass
items: tuple[str, ...] = RESULT_ON + RESULT_OFF
if as_true:
items += as_true
if as_false:
items += as_false
_log_validation_result_error(entity, attribute, result, items)
return None
return convert
def number(
entity: Entity,
attribute: str,
minimum: float | None = None,
maximum: float | None = None,
return_type: type[float] | type[int] = float,
**kwargs: Any,
) -> Callable[[Any], float | int | None]:
"""Convert the result to a number (float or int).
Any value in the range is converted to a float or int
All other values are None
"""
message = "expected a number"
if minimum is not None and maximum is not None:
message = f"{message} between {minimum:0.1f} and {maximum:0.1f}"
elif minimum is not None and maximum is None:
message = f"{message} greater than or equal to {minimum:0.1f}"
elif minimum is None and maximum is not None:
message = f"{message} less than or equal to {maximum:0.1f}"
def convert(result: Any) -> float | int | None:
if _check_result_for_none(result, **kwargs):
return None
if (result_type := type(result)) is bool:
_log_validation_result_error(entity, attribute, result, message)
return None
if isinstance(result, (float, int)):
value = result
if return_type is int and result_type is float:
value = int(value)
elif return_type is float and result_type is int:
value = float(value)
else:
try:
value = vol.Coerce(float)(result)
if return_type is int:
value = int(value)
except vol.Invalid:
_log_validation_result_error(entity, attribute, result, message)
return None
if minimum is None and maximum is None:
return value
if (
(
minimum is not None
and maximum is not None
and minimum <= value <= maximum
)
or (minimum is not None and maximum is None and value >= minimum)
or (minimum is None and maximum is not None and value <= maximum)
):
return value
_log_validation_result_error(entity, attribute, result, message)
return None
return convert
def list_of_strings(
entity: Entity,
attribute: str,
none_on_empty: bool = False,
**kwargs: Any,
) -> Callable[[Any], list[str] | None]:
"""Convert the result to a list of strings.
This ensures the result is a list of strings.
All other values that are not lists will result in None.
none_on_empty will cause the converter to return None when the list is empty.
"""
def convert(result: Any) -> list[str] | None:
if _check_result_for_none(result, **kwargs):
return None
if not isinstance(result, list):
_log_validation_result_error(
entity,
attribute,
result,
"expected a list of strings",
)
return None
if none_on_empty and len(result) == 0:
return None
# Ensure the result are strings.
return [str(v) for v in result]
return convert
def item_in_list[T](
entity: Entity,
attribute: str,
items: list[Any] | None,
items_attribute: str | None = None,
**kwargs: Any,
) -> Callable[[Any], Any | None]:
"""Assert the result of the template is an item inside a list.
Returns the result if the result is inside the list.
All results that are not inside the list will return None.
"""
def convert(result: Any) -> Any | None:
if _check_result_for_none(result, **kwargs):
return None
# items may be mutable based on another template field. Always
# perform this check when the items come from an configured
# attribute.
if items is None or (len(items) == 0):
if items_attribute:
_log_validation_result_error(
entity,
attribute,
result,
f"{items_attribute} is empty",
)
return None
if result not in items:
_log_validation_result_error(
entity,
attribute,
result,
tuple(str(v) for v in items),
)
return None
return result
return convert

File diff suppressed because it is too large Load Diff

View File

@@ -8,5 +8,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.3.2"]
"requirements": ["tesla-fleet-api==1.3.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.3.2", "teslemetry-stream==0.8.2"]
"requirements": ["tesla-fleet-api==1.3.0", "teslemetry-stream==0.8.2"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.3.2"]
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.3.0"]
}

View File

@@ -1,20 +1,36 @@
"""Support for Tibber."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber
from tibber import data_api as tibber_data_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import DATA_HASS_CONFIG, DOMAIN
from .const import (
AUTH_IMPLEMENTATION,
CONF_LEGACY_ACCESS_TOKEN,
DATA_HASS_CONFIG,
DOMAIN,
TibberConfigEntry,
)
from .coordinator import TibberDataAPICoordinator
from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
@@ -24,6 +40,33 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@dataclass
class TibberRuntimeData:
"""Runtime data for Tibber API entries."""
tibber_connection: tibber.Tibber
session: OAuth2Session
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
_client: tibber_data_api.TibberDataAPI | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(hass),
)
self._client.set_access_token(access_token)
return self._client
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Tibber component."""
@@ -34,16 +77,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bool:
"""Set up a config entry."""
# Added in 2026.1 to migrate existing users to OAuth2 (Tibber Data API).
# Can be removed after 2026.5
if AUTH_IMPLEMENTATION not in entry.data:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="data_api_reauth_required",
)
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_ACCESS_TOKEN],
access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN],
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
hass.data[DOMAIN] = tibber_connection
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
@@ -52,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
@@ -65,17 +114,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except tibber.FatalHttpExceptionError:
return False
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
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)
try:
await session.async_ensure_token_valid()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauthentication required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
entry.runtime_data = TibberRuntimeData(
tibber_connection=tibber_connection,
session=session,
)
coordinator = TibberDataAPICoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_api_coordinator = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, config_entry: TibberConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
if unload_ok := await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
if unload_ok:
tibber_connection = hass.data[DOMAIN]
await tibber_connection.rt_disconnect()
):
await config_entry.runtime_data.tibber_connection.rt_disconnect()
return unload_ok

View File

@@ -0,0 +1,15 @@
"""Application credentials platform for Tibber."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize"
TOKEN_URL = "https://thewall.tibber.com/connect/token"
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server for Tibber Data API."""
return AuthorizationServer(
authorize_url=AUTHORIZE_URL,
token_url=TOKEN_URL,
)

View File

@@ -2,80 +2,164 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import aiohttp
import tibber
from tibber import data_api as tibber_data_api
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import DOMAIN
from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
_LOGGER = logging.getLogger(__name__)
class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a config flow for Tibber integration."""
VERSION = 1
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._access_token: str | None = None
self._title = ""
@property
def logger(self) -> logging.Logger:
"""Return the logger."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data appended to the authorize URL."""
return {
**super().extra_authorize_data,
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self._async_abort_entries_match()
if user_input is not None:
access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(self.hass),
if user_input is None:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
errors = {}
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors={},
)
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_ACCESS_TOKEN] = ERR_CLIENT
self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=self._access_token,
websession=async_get_clientsession(self.hass),
)
self._title = tibber_connection.name or "Tibber"
if errors:
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
errors: dict[str, str] = {}
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT
unique_id = tibber_connection.user_id
await self.async_set_unique_id(unique_id)
if errors:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
await self.async_set_unique_id(tibber_connection.user_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
else:
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=tibber_connection.name,
data={CONF_ACCESS_TOKEN: access_token},
return await self.async_step_pick_implementation()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a reauth flow."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication by reusing the user step."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
if self._access_token is None:
return self.async_abort(reason="missing_configuration")
data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
data_api_client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(self.hass),
)
try:
await data_api_client.get_userinfo()
except (aiohttp.ClientError, TimeoutError):
return self.async_abort(reason="cannot_connect")
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry,
data=data,
title=self._title,
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors={},
)
return self.async_create_entry(title=self._title, data=data)

View File

@@ -1,5 +1,34 @@
"""Constants for Tibber integration."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
if TYPE_CHECKING:
from . import TibberRuntimeData
type TibberConfigEntry = ConfigEntry[TibberRuntimeData]
CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN
AUTH_IMPLEMENTATION = "auth_implementation"
DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber"
MANUFACTURER = "Tibber"
DATA_API_DEFAULT_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"data-api-user-read",
"data-api-chargers-read",
"data-api-energy-systems-read",
"data-api-homes-read",
"data-api-thermostats-read",
"data-api-vehicles-read",
"data-api-inverters-read",
]

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import cast
from typing import TYPE_CHECKING, cast
from aiohttp.client_exceptions import ClientError
import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -19,15 +21,18 @@ from homeassistant.components.recorder.statistics import (
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN
if TYPE_CHECKING:
from .const import TibberConfigEntry
FIVE_YEARS = 5 * 365 * 24
_LOGGER = logging.getLogger(__name__)
@@ -36,12 +41,12 @@ _LOGGER = logging.getLogger(__name__)
class TibberDataCoordinator(DataUpdateCoordinator[None]):
"""Handle Tibber data and insert statistics."""
config_entry: ConfigEntry
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: TibberConfigEntry,
tibber_connection: tibber.Tibber,
) -> None:
"""Initialize the data handler."""
@@ -187,3 +192,64 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
unit_of_measurement=unit,
)
async_add_external_statistics(self.hass, metadata, statistics)
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: TibberConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = entry.runtime_data
self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {}
def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None:
"""Build sensor lookup dict for efficient access."""
self.sensors_by_device = {
device_id: {sensor.id: sensor for sensor in device.sensors}
for device_id, device in devices.items()
}
def get_sensor(
self, device_id: str, sensor_id: str
) -> tibber.data_api.Sensor | None:
"""Get a sensor by device and sensor ID."""
if device_sensors := self.sensors_by_device.get(device_id):
return device_sensors.get(sensor_id)
return None
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
devices = await client.get_all_devices()
self._build_sensor_lookup(devices)
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
devices: dict[str, TibberDevice] = await client.update_devices()
self._build_sensor_lookup(devices)
return devices

View File

@@ -4,21 +4,22 @@ from __future__ import annotations
from typing import Any
import aiohttp
import tibber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN
from .const import TibberConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
hass: HomeAssistant, config_entry: TibberConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
return {
runtime = config_entry.runtime_data
result: dict[str, Any] = {
"homes": [
{
"last_data_timestamp": home.last_data_timestamp,
@@ -27,6 +28,38 @@ async def async_get_config_entry_diagnostics(
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for home in tibber_connection.get_homes(only_active=False)
for home in runtime.tibber_connection.get_homes(only_active=False)
]
}
devices: dict[str, Any] = {}
error: str | None = None
try:
coordinator = runtime.data_api_coordinator
if coordinator is not None:
devices = coordinator.data
except ConfigEntryAuthFailed:
error = "Authentication failed"
except TimeoutError:
error = "Timeout error"
except aiohttp.ClientError:
error = "Client error"
except tibber.InvalidLoginError:
error = "Invalid login"
except tibber.RetryableHttpExceptionError as err:
error = f"Retryable HTTP error ({err.status})"
except tibber.FatalHttpExceptionError as err:
error = f"Fatal HTTP error ({err.status})"
result["error"] = error
result["devices"] = [
{
"id": device.id,
"name": device.name,
"brand": device.brand,
"model": device.model,
}
for device in devices.values()
]
return result

View File

@@ -3,9 +3,9 @@
"name": "Tibber",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"dependencies": ["recorder"],
"dependencies": ["application_credentials", "recorder"],
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.32.2"]
"requirements": ["pyTibber==0.33.1"]
}

View File

@@ -2,28 +2,25 @@
from __future__ import annotations
from tibber import Tibber
from homeassistant.components.notify import (
ATTR_TITLE_DEFAULT,
NotifyEntity,
NotifyEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .const import DOMAIN, TibberConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber notification entity."""
async_add_entities([TibberNotificationEntity(entry.entry_id)])
async_add_entities([TibberNotificationEntity(entry)])
class TibberNotificationEntity(NotifyEntity):
@@ -33,13 +30,14 @@ class TibberNotificationEntity(NotifyEntity):
_attr_name = DOMAIN
_attr_icon = "mdi:message-flash"
def __init__(self, unique_id: str) -> None:
def __init__(self, entry: TibberConfigEntry) -> None:
"""Initialize Tibber notify entity."""
self._attr_unique_id = unique_id
self._attr_unique_id = entry.entry_id
self._entry = entry
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices."""
tibber_connection: Tibber = self.hass.data[DOMAIN]
tibber_connection = self._entry.runtime_data.tibber_connection
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -10,7 +10,8 @@ from random import randrange
from typing import Any
import aiohttp
import tibber
from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
from tibber.data_api import TibberDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -27,6 +28,7 @@ from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
)
from homeassistant.core import Event, HomeAssistant, callback
@@ -41,8 +43,8 @@ from homeassistant.helpers.update_coordinator import (
)
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN, MANUFACTURER
from .coordinator import TibberDataCoordinator
from .const import DOMAIN, MANUFACTURER, TibberConfigEntry
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -260,14 +262,65 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
)
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="storage.stateOfCharge",
translation_key="storage_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="storage.targetStateOfCharge",
translation_key="storage_target_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="range.remaining",
translation_key="range_remaining",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
SensorEntityDescription(
key="charging.current.max",
translation_key="charging_current_max",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charging.current.offlineFallback",
translation_key="charging_current_offline_fallback",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = hass.data[DOMAIN]
_setup_data_api_sensors(entry, async_add_entities)
await _async_setup_graphql_sensors(hass, entry, async_add_entities)
async def _async_setup_graphql_sensors(
hass: HomeAssistant,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = entry.runtime_data.tibber_connection
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -280,7 +333,11 @@ async def async_setup_entry(
except TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
except (
RetryableHttpExceptionError,
FatalHttpExceptionError,
aiohttp.ClientError,
) as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
@@ -325,7 +382,72 @@ async def async_setup_entry(
device_entry.id, new_identifiers={(DOMAIN, home.home_id)}
)
async_add_entities(entities, True)
async_add_entities(entities)
def _setup_data_api_sensors(
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors backed by the Tibber Data API."""
coordinator = entry.runtime_data.data_api_coordinator
if coordinator is None:
return
entities: list[TibberDataAPISensor] = []
api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS}
for device in coordinator.data.values():
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
_LOGGER.debug(
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
)
continue
entities.append(
TibberDataAPISensor(
coordinator, device, description, sensor.description
)
)
async_add_entities(entities)
class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity):
"""Representation of a Tibber Data API capability sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TibberDataAPICoordinator,
device: TibberDevice,
entity_description: SensorEntityDescription,
name: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._device_id: str = device.id
self.entity_description = entity_description
self._attr_name = name
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.external_id)},
name=device.name,
manufacturer=device.brand,
model=device.model,
)
@property
def native_value(self) -> StateType:
"""Return the value reported by the device."""
sensors = self.coordinator.sensors_by_device.get(self._device_id, {})
sensor = sensors.get(self.entity_description.key)
return sensor.value if sensor else None
class TibberSensor(SensorEntity):
@@ -333,9 +455,7 @@ class TibberSensor(SensorEntity):
_attr_has_entity_name = True
def __init__(
self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any
) -> None:
def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
"""Initialize the sensor."""
super().__init__(*args, **kwargs)
self._tibber_home = tibber_home
@@ -366,7 +486,7 @@ class TibberSensorElPrice(TibberSensor):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "electricity_price"
def __init__(self, tibber_home: tibber.TibberHome) -> None:
def __init__(self, tibber_home: TibberHome) -> None:
"""Initialize the sensor."""
super().__init__(tibber_home=tibber_home)
self._last_updated: datetime.datetime | None = None
@@ -443,7 +563,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
) -> None:
@@ -470,7 +590,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"])
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
description: SensorEntityDescription,
initial_state: float,
coordinator: TibberRtDataCoordinator,
@@ -532,7 +652,7 @@ class TibberRtEntityCreator:
def __init__(
self,
async_add_entities: AddConfigEntryEntitiesCallback,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
entity_registry: er.EntityRegistry,
) -> None:
"""Initialize the data handler."""
@@ -618,7 +738,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en
hass: HomeAssistant,
config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
) -> None:
"""Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import datetime as dt
from datetime import datetime
from typing import Any, Final
from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol
@@ -20,6 +20,9 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN
if TYPE_CHECKING:
from .const import TibberConfigEntry
PRICE_SERVICE_NAME = "get_prices"
ATTR_START: Final = "start"
ATTR_END: Final = "end"
@@ -33,7 +36,13 @@ SERVICE_SCHEMA: Final = vol.Schema(
async def __get_prices(call: ServiceCall) -> ServiceResponse:
tibber_connection = call.hass.data[DOMAIN]
entries: list[TibberConfigEntry] = call.hass.config_entries.async_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
tibber_connection = entries[0].runtime_data.tibber_connection
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")
@@ -57,7 +66,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
selected_data = [
price
for price in price_data
if start <= dt.datetime.fromisoformat(price["start_time"]) < end
if start <= dt.datetime.fromisoformat(str(price["start_time"])) < end
]
tibber_prices[home_nickname] = selected_data

View File

@@ -1,7 +1,11 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +13,10 @@
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
@@ -40,6 +48,12 @@
"average_power": {
"name": "Average power"
},
"charging_current_max": {
"name": "Maximum charging current"
},
"charging_current_offline_fallback": {
"name": "Offline fallback charging current"
},
"current_l1": {
"name": "Current L1"
},
@@ -88,9 +102,18 @@
"power_production": {
"name": "Power production"
},
"range_remaining": {
"name": "Remaining range"
},
"signal_strength": {
"name": "Signal strength"
},
"storage_state_of_charge": {
"name": "Storage state of charge"
},
"storage_target_state_of_charge": {
"name": "Storage target state of charge"
},
"voltage_phase1": {
"name": "Voltage phase1"
},
@@ -103,9 +126,18 @@
}
},
"exceptions": {
"data_api_reauth_required": {
"message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features."
},
"invalid_date": {
"message": "Invalid datetime provided {date}"
},
"no_config_entry": {
"message": "No Tibber integration configured"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}

View File

@@ -31,7 +31,6 @@ from .entity import (
T,
async_all_device_entities,
)
from .utils import async_ufp_instance_command
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -160,7 +159,6 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity):
entity_description: ProtectButtonEntityDescription
@async_ufp_instance_command
async def async_press(self) -> None:
"""Press the button."""
if self.entity_description.ufp_press is not None:

View File

@@ -29,7 +29,7 @@ from .const import (
)
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
from .entity import ProtectDeviceEntity
from .utils import async_ufp_instance_command, get_camera_base_name
from .utils import get_camera_base_name
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -260,12 +260,10 @@ class ProtectCamera(ProtectDeviceEntity, Camera):
"""Return the Stream Source."""
return self._stream_source
@async_ufp_instance_command
async def async_enable_motion_detection(self) -> None:
"""Call the job and enable motion detection."""
await self.device.set_motion_detection(True)
@async_ufp_instance_command
async def async_disable_motion_detection(self) -> None:
"""Call the job and disable motion detection."""
await self.device.set_motion_detection(False)

View File

@@ -14,7 +14,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectDeviceType, UFPConfigEntry
from .entity import ProtectDeviceEntity
from .utils import async_ufp_instance_command
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -72,7 +71,6 @@ class ProtectLight(ProtectDeviceEntity, LightEntity):
updated_device.light_device_settings.led_level
)
@async_ufp_instance_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
brightness = kwargs.get(ATTR_BRIGHTNESS)
@@ -102,7 +100,6 @@ class ProtectLight(ProtectDeviceEntity, LightEntity):
),
)
@async_ufp_instance_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
_LOGGER.debug("Turning off light")

View File

@@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectDeviceType, UFPConfigEntry
from .entity import ProtectDeviceEntity
from .utils import async_ufp_instance_command
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@@ -86,14 +85,12 @@ class ProtectLock(ProtectDeviceEntity, LockEntity):
elif lock_status != LockStatusType.OPEN:
self._attr_available = False
@async_ufp_instance_command
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock."""
_LOGGER.debug("Unlocking %s", self.device.display_name)
await self.device.open_lock()
return await self.device.open_lock()
@async_ufp_instance_command
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock."""
_LOGGER.debug("Locking %s", self.device.display_name)
await self.device.close_lock()
return await self.device.close_lock()

View File

@@ -40,7 +40,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==7.33.2", "unifi-discovery==1.2.0"],
"ssdp": [
{

View File

@@ -28,7 +28,6 @@ from .entity import (
T,
async_all_device_entities,
)
from .utils import async_ufp_instance_command
PARALLEL_UPDATES = 0
@@ -298,7 +297,6 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
super()._async_update_device_from_protect(device)
self._attr_native_value = self.entity_description.get_ufp_value(self.device)
@async_ufp_instance_command
async def async_set_native_value(self, value: float) -> None:
"""Set new value."""
await self.entity_description.ufp_set(self.device, value)

View File

@@ -1,68 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling:
status: exempt
comment: Integration is push-based using WebSockets (iot_class local_push).
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: done
comment: Diagnostics tests could use snapshot testing.
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class:
status: done
comment: "Planned improvement: remove doorbell occupancy binary sensor and keep the event sensor after a solution for https://github.com/home-assistant/core/issues/145941 is available."
entity-disabled-by-default: done
entity-translations:
status: done
comment: "Planned improvement: camera insecure is not translated but will be dropped soon with public api migration"
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: done
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -41,44 +41,44 @@ from .entity import (
T,
async_all_device_entities,
)
from .utils import async_get_light_motion_current, async_ufp_instance_command
from .utils import async_get_light_motion_current
_LOGGER = logging.getLogger(__name__)
_KEY_LIGHT_MOTION = "light_motion"
PARALLEL_UPDATES = 0
HDR_MODES = [
{"id": "always", "name": "always"},
{"id": "off", "name": "off"},
{"id": "auto", "name": "auto"},
{"id": "always", "name": "Always On"},
{"id": "off", "name": "Always Off"},
{"id": "auto", "name": "Auto"},
]
INFRARED_MODES = [
{"id": IRLEDMode.AUTO.value, "name": "auto"},
{"id": IRLEDMode.ON.value, "name": "on"},
{"id": IRLEDMode.AUTO_NO_LED.value, "name": "auto_filter_only"},
{"id": IRLEDMode.CUSTOM.value, "name": "custom"},
{"id": IRLEDMode.OFF.value, "name": "off"},
{"id": IRLEDMode.AUTO.value, "name": "Auto"},
{"id": IRLEDMode.ON.value, "name": "Always Enable"},
{"id": IRLEDMode.AUTO_NO_LED.value, "name": "Auto (Filter Only, no LED's)"},
{"id": IRLEDMode.CUSTOM.value, "name": "Auto (Custom Lux)"},
{"id": IRLEDMode.OFF.value, "name": "Always Disable"},
]
CHIME_TYPES = [
{"id": ChimeType.NONE.value, "name": "none"},
{"id": ChimeType.MECHANICAL.value, "name": "mechanical"},
{"id": ChimeType.DIGITAL.value, "name": "digital"},
{"id": ChimeType.NONE.value, "name": "None"},
{"id": ChimeType.MECHANICAL.value, "name": "Mechanical"},
{"id": ChimeType.DIGITAL.value, "name": "Digital"},
]
MOUNT_TYPES = [
{"id": MountType.NONE.value, "name": MountType.NONE.value},
{"id": MountType.DOOR.value, "name": MountType.DOOR.value},
{"id": MountType.WINDOW.value, "name": MountType.WINDOW.value},
{"id": MountType.GARAGE.value, "name": MountType.GARAGE.value},
{"id": MountType.LEAK.value, "name": MountType.LEAK.value},
{"id": MountType.NONE.value, "name": "None"},
{"id": MountType.DOOR.value, "name": "Door"},
{"id": MountType.WINDOW.value, "name": "Window"},
{"id": MountType.GARAGE.value, "name": "Garage"},
{"id": MountType.LEAK.value, "name": "Leak"},
]
LIGHT_MODE_MOTION = "motion"
LIGHT_MODE_MOTION_DARK = "motion_dark"
LIGHT_MODE_DARK = "when_dark"
LIGHT_MODE_OFF = "manual"
LIGHT_MODE_MOTION = "On Motion - Always"
LIGHT_MODE_MOTION_DARK = "On Motion - When Dark"
LIGHT_MODE_DARK = "When Dark"
LIGHT_MODE_OFF = "Manual"
LIGHT_MODES = [LIGHT_MODE_MOTION, LIGHT_MODE_DARK, LIGHT_MODE_OFF]
LIGHT_MODE_TO_SETTINGS = {
@@ -93,13 +93,13 @@ LIGHT_MODE_TO_SETTINGS = {
MOTION_MODE_TO_LIGHT_MODE = [
{"id": LightModeType.MOTION.value, "name": LIGHT_MODE_MOTION},
{"id": f"{LightModeType.MOTION.value}_dark", "name": LIGHT_MODE_MOTION_DARK},
{"id": f"{LightModeType.MOTION.value}Dark", "name": LIGHT_MODE_MOTION_DARK},
{"id": LightModeType.WHEN_DARK.value, "name": LIGHT_MODE_DARK},
{"id": LightModeType.MANUAL.value, "name": LIGHT_MODE_OFF},
]
DEVICE_RECORDING_MODES = [
{"id": mode.value, "name": mode.value} for mode in list(RecordingMode)
{"id": mode.value, "name": mode.value.title()} for mode in list(RecordingMode)
]
@@ -397,7 +397,6 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity):
self._hass_to_unifi_options = {item["name"]: item["id"] for item in options}
self._unifi_to_hass_options = {item["id"]: item["name"] for item in options}
@async_ufp_instance_command
async def async_select_option(self, option: str) -> None:
"""Change the Select Entity Option."""

View File

@@ -350,68 +350,31 @@
},
"select": {
"chime_type": {
"name": "Chime type",
"state": {
"digital": "Digital",
"mechanical": "Mechanical",
"none": "[%key:common::state::off%]"
}
"name": "Chime type"
},
"doorbell_text": {
"name": "Doorbell text"
},
"hdr_mode": {
"name": "[%key:component::unifiprotect::entity::binary_sensor::hdr_mode::name%]",
"state": {
"always": "Always on",
"auto": "Auto",
"off": "Always off"
}
"name": "[%key:component::unifiprotect::entity::binary_sensor::hdr_mode::name%]"
},
"infrared_mode": {
"name": "Infrared mode",
"state": {
"auto": "Auto",
"auto_filter_only": "Auto (filter only, no LEDs)",
"custom": "Auto (custom lux)",
"off": "Always disable",
"on": "Always enable"
}
"name": "Infrared mode"
},
"light_mode": {
"name": "Light mode",
"state": {
"manual": "Manual",
"motion": "On motion - always",
"motion_dark": "On motion - when dark",
"when_dark": "When dark"
}
"name": "Light mode"
},
"liveview": {
"name": "Liveview"
},
"mount_type": {
"name": "Mount type",
"state": {
"door": "Door",
"garage": "Garage",
"leak": "Leak",
"none": "[%key:common::state::off%]",
"window": "Window"
}
"name": "Mount type"
},
"paired_camera": {
"name": "Paired camera"
},
"recording_mode": {
"name": "Recording mode",
"state": {
"adaptive": "Adaptive",
"always": "Always",
"detections": "Detections",
"never": "Never",
"schedule": "Schedule"
}
"name": "Recording mode"
}
},
"sensor": {
@@ -653,18 +616,12 @@
"api_key_required": {
"message": "API key is required. Please reauthenticate this integration to provide an API key."
},
"command_error": {
"message": "Error communicating with UniFi Protect while sending command: {error}"
},
"device_not_found": {
"message": "No device found for device id: {device_id}"
},
"no_users_found": {
"message": "No users found, please check Protect permissions"
},
"not_authorized": {
"message": "Not authorized to perform this action on the UniFi Protect controller"
},
"only_music_supported": {
"message": "Only music media type is supported"
},

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