Compare commits

..

38 Commits

Author SHA1 Message Date
Wendelin
39d970347e Refactor input_weekday configuration validation and update test cases to use unique_id 2025-10-09 12:00:35 +02:00
Wendelin
9cccc96f63 Fix test 2025-10-09 10:35:03 +02:00
Wendelin
a32ada3155 Use input_weekday in automations 2025-10-09 08:48:41 +02:00
Wendelin
77f078e57d Add weekdays to build-in helpers 2025-10-08 17:23:19 +02:00
Wendelin
8657bfd0bf Add input helper weekdays 2025-10-08 17:08:48 +02:00
Joost Lekkerkerker
fe4eb8766d Don't mark ZHA coordinator as via_device with itself (#154004) 2025-10-08 16:05:54 +02:00
Mark Adkins
2d9f14c401 Add 3rd maintainer to sharkiq (#153961) 2025-10-08 15:17:52 +02:00
dependabot[bot]
7b6ccb07fd Bump github/codeql-action from 3.30.6 to 4.30.7 (#153979)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-08 13:42:25 +02:00
Shay Levy
2ba5728060 Enable Shelly binary input sensors by default (#154001) 2025-10-08 14:41:53 +03:00
epenet
b5f163cc85 Update Tuya fixture for product ID IAYz2WK1th0cMLmL (#154000) 2025-10-08 13:28:11 +02:00
Marc Mueller
65540a3e0b Update mypy dev to 1.19.0a4 (#153995) 2025-10-08 13:24:54 +02:00
Erwin Douna
cbf1b39edb Portainer add sensor platform (#153059)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
2025-10-08 11:02:20 +02:00
G Johansson
142daf5e49 Call async_track_template_result with template without hass now fails (#153473)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-10-08 10:14:51 +02:00
Erik Montnemery
8bd0ff7cca Replace has_mean with mean_type in mill external statistics (#153985) 2025-10-08 09:52:07 +02:00
Erik Montnemery
ac676e12f6 Remove has_mean from suez_water external statistics (#153986) 2025-10-08 09:51:44 +02:00
Glenn Vandeuren (aka Iondependent)
c0ac3292cd FIx brightness always 100% when toggling the light (#153765)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-10-08 09:48:41 +02:00
Denis Shulyaka
80fd07c128 Add GPT-5 Pro and GPT-5 Codex support (#153936) 2025-10-08 09:48:07 +02:00
Michael Davie
3701d8859a Bump env-canada to 0.11.3 (#153967) 2025-10-08 09:40:55 +02:00
Jesse Hills
6dd26bae88 Bump aioesphomeapi to 41.13.0 (#153974) 2025-10-07 18:28:56 -10:00
Dave T
1a0abe296c Remove deprecated conductivity constants (#153942) 2025-10-07 23:20:36 +01:00
G Johansson
de6c61a4ab Bump psutil 7.1.0 (#153954) 2025-10-07 23:16:49 +01:00
Glenn Vandeuren (aka Iondependent)
33c677596e Update nhc to 0.6.1 (#153962) 2025-10-07 23:16:04 +01:00
peetersch
e9b4b8e99b Modbus Fix message_wait_milliseconds is no longer applied (#153709) 2025-10-07 23:38:05 +02:00
Maciej Bieniek
0525c04c42 Fix update interval for AccuWeather hourly forecast (#153957) 2025-10-07 23:25:04 +02:00
Shay Levy
d57b502551 Migrate Shelly virtual button platfrom unique IDs to include roles (#153865) 2025-10-07 23:01:30 +03:00
G Johansson
9fb708baf4 Bump holidays to 0.82 (#153952) 2025-10-07 23:00:38 +03:00
Josef Zweck
abdf24b7a0 Bump pylamarzocco to 2.1.2 (#153950) 2025-10-07 22:07:39 +03:00
TheJulianJES
29bfbd27bb Do not auto-set up ZHA zeroconf discoveries during onboarding (#153914) 2025-10-07 15:02:02 -04:00
starkillerOG
224553f8d9 Reverse Motion Blinds tilt direction (#149777)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-10-07 18:50:39 +01:00
mbo18
7c9f6a061f Add icons for SmartThings climate presets (#153929) 2025-10-07 19:15:15 +02:00
Marc Mueller
8e115d4685 Update pydantic to 2.12.0 (#153937) 2025-10-07 17:50:40 +01:00
Denis Shulyaka
00c189844f Bump openai to 2.2.0 (#153926) 2025-10-07 17:41:52 +01:00
Ståle Storø Hauknes
4587c286bb Add new sensors for Airthings Wave Enhance (#153879) 2025-10-07 17:44:30 +02:00
Artur Pragacz
b46097a7fc Move agent functionality from http (#153917) 2025-10-07 14:49:11 +02:00
mbo18
299cb6a2ff Change smart preset name to smart saver (#153916) 2025-10-07 14:11:00 +02:00
Erik Montnemery
1b7b91b328 Remove unused test fixtures from nintendo_parental (#153894) 2025-10-07 14:03:29 +02:00
Maciej Bieniek
01a1480ebd Use aioshelly methods for switches (#153746) 2025-10-07 13:28:58 +02:00
Jordan Harvey
26b8abb118 Bump pynintendoparental to 1.1.1 (#153874) 2025-10-07 13:28:08 +02:00
98 changed files with 2570 additions and 689 deletions

View File

@@ -741,7 +741,7 @@ jobs:
- name: Generate partial mypy restore key
id: generate-mypy-key
run: |
mypy_version=$(cat requirements_test.txt | grep mypy | cut -d '=' -f 3)
mypy_version=$(cat requirements_test.txt | grep 'mypy.*=' | cut -d '=' -f 3)
echo "version=$mypy_version" >> $GITHUB_OUTPUT
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-${{
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
uses: github/codeql-action/init@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
uses: github/codeql-action/analyze@e296a935590eb16afc0c0108289f68c87e2a89a5 # v4.30.7
with:
category: "/language:python"

6
CODEOWNERS generated
View File

@@ -753,6 +753,8 @@ build.json @home-assistant/supervisor
/tests/components/input_select/ @home-assistant/core
/homeassistant/components/input_text/ @home-assistant/core
/tests/components/input_text/ @home-assistant/core
/homeassistant/components/input_weekday/ @home-assistant/core
/tests/components/input_weekday/ @home-assistant/core
/homeassistant/components/insteon/ @teharris1
/tests/components/insteon/ @teharris1
/homeassistant/components/integration/ @dgomes
@@ -1413,8 +1415,8 @@ build.json @home-assistant/supervisor
/tests/components/sfr_box/ @epenet
/homeassistant/components/sftp_storage/ @maretodoric
/tests/components/sftp_storage/ @maretodoric
/homeassistant/components/sharkiq/ @JeffResc @funkybunch
/tests/components/sharkiq/ @JeffResc @funkybunch
/homeassistant/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
/tests/components/sharkiq/ @JeffResc @funkybunch @TheOneOgre
/homeassistant/components/shell_command/ @home-assistant/core
/tests/components/shell_command/ @home-assistant/core
/homeassistant/components/shelly/ @bieniu @thecode @chemelli74 @bdraco

View File

@@ -231,6 +231,7 @@ DEFAULT_INTEGRATIONS = {
"input_datetime",
"input_number",
"input_select",
"input_weekday",
"input_text",
"schedule",
"timer",

View File

@@ -71,4 +71,4 @@ POLLEN_CATEGORY_MAP = {
}
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(hours=30)
UPDATE_INTERVAL_HOURLY_FORECAST = timedelta(minutes=30)

View File

@@ -16,10 +16,12 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
EntityCategory,
Platform,
UnitOfPressure,
UnitOfSoundPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
@@ -112,6 +114,21 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"lux": SensorEntityDescription(
key="lux",
device_class=SensorDeviceClass.ILLUMINANCE,
native_unit_of_measurement=LIGHT_LUX,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
"noise": SensorEntityDescription(
key="noise",
translation_key="ambient_noise",
device_class=SensorDeviceClass.SOUND_PRESSURE,
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
),
}
PARALLEL_UPDATES = 0

View File

@@ -41,6 +41,9 @@
},
"illuminance": {
"name": "[%key:component::sensor::entity_component::illuminance::name%]"
},
"ambient_noise": {
"name": "Ambient noise"
}
}
}

View File

@@ -38,9 +38,11 @@ from home_assistant_intents import (
ErrorKey,
FuzzyConfig,
FuzzyLanguageResponses,
LanguageScores,
get_fuzzy_config,
get_fuzzy_language,
get_intents,
get_language_scores,
get_languages,
)
import yaml
@@ -59,6 +61,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
@@ -343,6 +346,81 @@ class DefaultAgent(ConversationEntity):
return result
async def async_debug_recognize(
self, user_input: ConversationInput
) -> dict[str, Any] | None:
"""Debug recognize from user input."""
result_dict: dict[str, Any] | None = None
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
result_dict = {
# Matched a user-defined sentence trigger.
# We can't provide the response here without executing the
# trigger.
"match": True,
"source": "trigger",
"sentence_template": trigger_result.sentence_template or "",
}
elif intent_result := await self.async_recognize_intent(user_input):
successful_match = not intent_result.unmatched_entities
result_dict = {
# Name of the matching intent (or the closest)
"intent": {
"name": intent_result.intent.name,
},
# Slot values that would be received by the intent
"slots": { # direct access to values
entity_key: entity.text or entity.value
for entity_key, entity in intent_result.entities.items()
},
# Extra slot details, such as the originally matched text
"details": {
entity_key: {
"name": entity.name,
"value": entity.value,
"text": entity.text,
}
for entity_key, entity in intent_result.entities.items()
},
# Entities/areas/etc. that would be targeted
"targets": {},
# True if match was successful
"match": successful_match,
# Text of the sentence template that matched (or was closest)
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(intent_result),
# True if match was not exact
"fuzzy_match": False,
}
if successful_match:
result_dict["targets"] = {
state.entity_id: {"matched": is_matched}
for state, is_matched in _get_debug_targets(
self.hass, intent_result
)
}
if intent_result.intent_sentence is not None:
result_dict["sentence_template"] = intent_result.intent_sentence.text
if intent_result.intent_metadata:
# Inspect metadata to determine if this matched a custom sentence
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
result_dict["source"] = "custom"
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
)
else:
result_dict["source"] = "builtin"
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
METADATA_FUZZY_MATCH, False
)
return result_dict
async def _async_handle_message(
self,
user_input: ConversationInput,
@@ -1529,6 +1607,10 @@ class DefaultAgent(ConversationEntity):
return None
return response
async def async_get_language_scores(self) -> dict[str, LanguageScores]:
"""Get support scores per language."""
return await self.hass.async_add_executor_job(get_language_scores)
def _make_error_result(
language: str,
@@ -1725,3 +1807,75 @@ def _collect_list_references(expression: Expression, list_names: set[str]) -> No
elif isinstance(expression, ListReference):
# {list}
list_names.add(expression.slot_name)
def _get_debug_targets(
hass: HomeAssistant,
result: RecognizeResult,
) -> Iterable[tuple[State, bool]]:
"""Yield state/is_matched pairs for a hassil recognition."""
entities = result.entities
name: str | None = None
area_name: str | None = None
domains: set[str] | None = None
device_classes: set[str] | None = None
state_names: set[str] | None = None
if "name" in entities:
name = str(entities["name"].value)
if "area" in entities:
area_name = str(entities["area"].value)
if "domain" in entities:
domains = set(cv.ensure_list(entities["domain"].value))
if "device_class" in entities:
device_classes = set(cv.ensure_list(entities["device_class"].value))
if "state" in entities:
# HassGetState only
state_names = set(cv.ensure_list(entities["state"].value))
if (
(name is None)
and (area_name is None)
and (not domains)
and (not device_classes)
and (not state_names)
):
# Avoid "matching" all entities when there is no filter
return
states = intent.async_match_states(
hass,
name=name,
area_name=area_name,
domains=domains,
device_classes=device_classes,
)
for state in states:
# For queries, a target is "matched" based on its state
is_matched = (state_names is None) or (state.state in state_names)
yield state, is_matched
def _get_unmatched_slots(
result: RecognizeResult,
) -> dict[str, str | int | float]:
"""Return a dict of unmatched text/range slot entities."""
unmatched_slots: dict[str, str | int | float] = {}
for entity in result.unmatched_entities_list:
if isinstance(entity, UnmatchedTextEntity):
if entity.text == MISSING_ENTITY:
# Don't report <missing> since these are just missing context
# slots.
continue
unmatched_slots[entity.name] = entity.text
elif isinstance(entity, UnmatchedRangeEntity):
unmatched_slots[entity.name] = entity.value
return unmatched_slots

View File

@@ -2,21 +2,16 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import asdict
from typing import Any
from aiohttp import web
from hassil.recognize import MISSING_ENTITY, RecognizeResult
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from home_assistant_intents import get_language_scores
import voluptuous as vol
from homeassistant.components import http, websocket_api
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.const import MATCH_ALL
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.core import HomeAssistant, callback
from homeassistant.util import language as language_util
from .agent_manager import (
@@ -26,11 +21,6 @@ from .agent_manager import (
get_agent_manager,
)
from .const import DATA_COMPONENT
from .default_agent import (
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
METADATA_FUZZY_MATCH,
)
from .entity import ConversationEntity
from .models import ConversationInput
@@ -206,150 +196,12 @@ async def websocket_hass_agent_debug(
language=msg.get("language", hass.config.language),
agent_id=agent.entity_id,
)
result_dict: dict[str, Any] | None = None
if trigger_result := await agent.async_recognize_sentence_trigger(user_input):
result_dict = {
# Matched a user-defined sentence trigger.
# We can't provide the response here without executing the
# trigger.
"match": True,
"source": "trigger",
"sentence_template": trigger_result.sentence_template or "",
}
elif intent_result := await agent.async_recognize_intent(user_input):
successful_match = not intent_result.unmatched_entities
result_dict = {
# Name of the matching intent (or the closest)
"intent": {
"name": intent_result.intent.name,
},
# Slot values that would be received by the intent
"slots": { # direct access to values
entity_key: entity.text or entity.value
for entity_key, entity in intent_result.entities.items()
},
# Extra slot details, such as the originally matched text
"details": {
entity_key: {
"name": entity.name,
"value": entity.value,
"text": entity.text,
}
for entity_key, entity in intent_result.entities.items()
},
# Entities/areas/etc. that would be targeted
"targets": {},
# True if match was successful
"match": successful_match,
# Text of the sentence template that matched (or was closest)
"sentence_template": "",
# When match is incomplete, this will contain the best slot guesses
"unmatched_slots": _get_unmatched_slots(intent_result),
# True if match was not exact
"fuzzy_match": False,
}
if successful_match:
result_dict["targets"] = {
state.entity_id: {"matched": is_matched}
for state, is_matched in _get_debug_targets(hass, intent_result)
}
if intent_result.intent_sentence is not None:
result_dict["sentence_template"] = intent_result.intent_sentence.text
if intent_result.intent_metadata:
# Inspect metadata to determine if this matched a custom sentence
if intent_result.intent_metadata.get(METADATA_CUSTOM_SENTENCE):
result_dict["source"] = "custom"
result_dict["file"] = intent_result.intent_metadata.get(
METADATA_CUSTOM_FILE
)
else:
result_dict["source"] = "builtin"
result_dict["fuzzy_match"] = intent_result.intent_metadata.get(
METADATA_FUZZY_MATCH, False
)
result_dict = await agent.async_debug_recognize(user_input)
result_dicts.append(result_dict)
connection.send_result(msg["id"], {"results": result_dicts})
def _get_debug_targets(
hass: HomeAssistant,
result: RecognizeResult,
) -> Iterable[tuple[State, bool]]:
"""Yield state/is_matched pairs for a hassil recognition."""
entities = result.entities
name: str | None = None
area_name: str | None = None
domains: set[str] | None = None
device_classes: set[str] | None = None
state_names: set[str] | None = None
if "name" in entities:
name = str(entities["name"].value)
if "area" in entities:
area_name = str(entities["area"].value)
if "domain" in entities:
domains = set(cv.ensure_list(entities["domain"].value))
if "device_class" in entities:
device_classes = set(cv.ensure_list(entities["device_class"].value))
if "state" in entities:
# HassGetState only
state_names = set(cv.ensure_list(entities["state"].value))
if (
(name is None)
and (area_name is None)
and (not domains)
and (not device_classes)
and (not state_names)
):
# Avoid "matching" all entities when there is no filter
return
states = intent.async_match_states(
hass,
name=name,
area_name=area_name,
domains=domains,
device_classes=device_classes,
)
for state in states:
# For queries, a target is "matched" based on its state
is_matched = (state_names is None) or (state.state in state_names)
yield state, is_matched
def _get_unmatched_slots(
result: RecognizeResult,
) -> dict[str, str | int | float]:
"""Return a dict of unmatched text/range slot entities."""
unmatched_slots: dict[str, str | int | float] = {}
for entity in result.unmatched_entities_list:
if isinstance(entity, UnmatchedTextEntity):
if entity.text == MISSING_ENTITY:
# Don't report <missing> since these are just missing context
# slots.
continue
unmatched_slots[entity.name] = entity.text
elif isinstance(entity, UnmatchedRangeEntity):
unmatched_slots[entity.name] = entity.value
return unmatched_slots
@websocket_api.websocket_command(
{
vol.Required("type"): "conversation/agent/homeassistant/language_scores",
@@ -364,10 +216,13 @@ async def websocket_hass_agent_language_scores(
msg: dict[str, Any],
) -> None:
"""Get support scores per language."""
agent = get_agent_manager(hass).default_agent
assert agent is not None
language = msg.get("language", hass.config.language)
country = msg.get("country", hass.config.country)
scores = await hass.async_add_executor_job(get_language_scores)
scores = await agent.async_get_language_scores()
matching_langs = language_util.matches(language, scores.keys(), country=country)
preferred_lang = matching_langs[0] if matching_langs else language
result = {

View File

@@ -116,10 +116,6 @@ class WaterSourceType(TypedDict):
# an EnergyCostSensor will be automatically created
stat_cost: str | None
# An optional statistic_id identifying a device
# that includes this device's consumption in its total
included_in_stat: str | None
# Used to generate costs if stat_cost is set to None
entity_energy_price: str | None # entity_id of an entity providing price ($/m³)
number_energy_price: float | None # Price for energy ($/m³)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.11.2"]
"requirements": ["env-canada==0.11.3"]
}

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==41.12.0",
"aioesphomeapi==41.13.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.81", "babel==2.15.0"]
"requirements": ["holidays==0.82", "babel==2.15.0"]
}

View File

@@ -72,15 +72,21 @@ _TIME_TRIGGER_SCHEMA = vol.Any(
),
)
_WEEKDAY_SCHEMA = vol.Any(
vol.In(WEEKDAYS),
vol.All(cv.ensure_list, [vol.In(WEEKDAYS)]),
cv.entity_domain(["input_weekday"]),
msg=(
"Expected a weekday (mon, tue, wed, thu, fri, sat, sun), "
"a list of weekdays, or an Entity ID with domain 'input_weekday'"
),
)
TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend(
{
vol.Required(CONF_PLATFORM): "time",
vol.Required(CONF_AT): vol.All(cv.ensure_list, [_TIME_TRIGGER_SCHEMA]),
vol.Optional(CONF_WEEKDAY): vol.Any(
vol.In(WEEKDAYS),
vol.All(cv.ensure_list, [vol.In(WEEKDAYS)]),
),
vol.Optional(CONF_WEEKDAY): _WEEKDAY_SCHEMA,
}
)
@@ -117,7 +123,14 @@ async def async_attach_trigger( # noqa: C901
# Check if current weekday matches the configuration
if isinstance(weekday_config, str):
if current_weekday != weekday_config:
# Could be a single weekday string or an entity_id
if weekday_config.startswith("input_weekday."):
if (weekday_state := hass.states.get(weekday_config)) is None:
return
entity_weekdays = weekday_state.attributes.get("weekdays", [])
if current_weekday not in entity_weekdays:
return
elif current_weekday != weekday_config:
return
elif current_weekday not in weekday_config:
return

View File

@@ -456,7 +456,7 @@ class HomeAccessory(Accessory): # type: ignore[misc]
return self._available
@ha_callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle accessory driver started event."""
if state := self.hass.states.get(self.entity_id):
@@ -725,7 +725,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
self._entry_title = entry_title
self.iid_storage = iid_storage
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def pair(
self, client_username_bytes: bytes, client_public: str, client_permissions: int
) -> bool:
@@ -735,7 +735,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc]
async_dismiss_setup_message(self.hass, self.entry_id)
return cast(bool, success)
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def unpair(self, client_uuid: UUID) -> None:
"""Override super function to show setup message if unpaired."""
super().unpair(client_uuid)

View File

@@ -71,7 +71,7 @@ class HomeDoorbellAccessory(HomeAccessory):
self.async_update_doorbell_state(None, state)
@ha_callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle doorbell event."""
if self._char_doorbell_detected:

View File

@@ -219,7 +219,7 @@ class AirPurifier(Fan):
return preset_mode.lower() != "auto"
@callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -229,7 +229,7 @@ class Camera(HomeDoorbellAccessory, PyhapCamera): # type: ignore[misc]
)
self._async_update_motion_state(None, state)
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
@callback
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -127,7 +127,7 @@ class GarageDoorOpener(HomeAccessory):
self.async_update_state(state)
@callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -178,7 +178,7 @@ class HumidifierDehumidifier(HomeAccessory):
self._async_update_current_humidity(humidity_state)
@callback
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
def run(self) -> None:
"""Handle accessory driver started event.

View File

@@ -108,7 +108,7 @@ class DeviceTriggerAccessory(HomeAccessory):
_LOGGER.log,
)
@pyhap_callback # type: ignore[misc]
@pyhap_callback # type: ignore[untyped-decorator]
@callback
def run(self) -> None:
"""Run the accessory."""

View File

@@ -0,0 +1,285 @@
"""Support to select weekdays for use in automation."""
from __future__ import annotations
import logging
from typing import Any, Self
import voluptuous as vol
from homeassistant.const import (
ATTR_EDITABLE,
CONF_ICON,
CONF_ID,
CONF_NAME,
SERVICE_RELOAD,
WEEKDAYS,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
import homeassistant.helpers.service
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType, VolDictType
_LOGGER = logging.getLogger(__name__)
DOMAIN = "input_weekday"
CONF_WEEKDAYS = "weekdays"
ATTR_WEEKDAYS = "weekdays"
ATTR_WEEKDAY = "weekday"
SERVICE_SET_WEEKDAYS = "set_weekdays"
SERVICE_ADD_WEEKDAY = "add_weekday"
SERVICE_REMOVE_WEEKDAY = "remove_weekday"
SERVICE_TOGGLE_WEEKDAY = "toggle_weekday"
SERVICE_CLEAR = "clear"
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_FIELDS: VolDictType = {
vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)),
vol.Optional(CONF_WEEKDAYS, default=list): vol.All(
cv.ensure_list, [vol.In(WEEKDAYS)]
),
vol.Optional(CONF_ICON): cv.icon,
}
def _cv_input_weekday(cfg: dict[str, Any]) -> dict[str, Any]:
"""Configure validation helper for input weekday (voluptuous)."""
if CONF_WEEKDAYS in cfg:
weekdays = cfg[CONF_WEEKDAYS]
# Remove duplicates while preserving order
cfg[CONF_WEEKDAYS] = list(dict.fromkeys(weekdays))
return cfg
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: cv.schema_with_slug_keys(
vol.All(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_WEEKDAYS): vol.All(
cv.ensure_list, [vol.In(WEEKDAYS)]
),
vol.Optional(CONF_ICON): cv.icon,
},
_cv_input_weekday,
)
)
},
extra=vol.ALLOW_EXTRA,
)
RELOAD_SERVICE_SCHEMA = vol.Schema({})
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up an input weekday."""
component = EntityComponent[InputWeekday](_LOGGER, DOMAIN, hass)
id_manager = collection.IDManager()
yaml_collection = collection.YamlCollection(
logging.getLogger(f"{__name__}.yaml_collection"), id_manager
)
collection.sync_entity_lifecycle(
hass, DOMAIN, DOMAIN, component, yaml_collection, InputWeekday
)
storage_collection = InputWeekdayStorageCollection(
Store(hass, STORAGE_VERSION, STORAGE_KEY),
id_manager,
)
collection.sync_entity_lifecycle(
hass, DOMAIN, DOMAIN, component, storage_collection, InputWeekday
)
await yaml_collection.async_load(
[{CONF_ID: id_, **cfg} for id_, cfg in config.get(DOMAIN, {}).items()]
)
await storage_collection.async_load()
collection.DictStorageCollectionWebsocket(
storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS
).async_setup(hass)
async def reload_service_handler(service_call: ServiceCall) -> None:
"""Reload yaml entities."""
conf = await component.async_prepare_reload(skip_reset=True)
if conf is None:
conf = {DOMAIN: {}}
await yaml_collection.async_load(
[{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()]
)
homeassistant.helpers.service.async_register_admin_service(
hass,
DOMAIN,
SERVICE_RELOAD,
reload_service_handler,
schema=RELOAD_SERVICE_SCHEMA,
)
component.async_register_entity_service(
SERVICE_SET_WEEKDAYS,
{vol.Required(ATTR_WEEKDAYS): vol.All(cv.ensure_list, [vol.In(WEEKDAYS)])},
"async_set_weekdays",
)
component.async_register_entity_service(
SERVICE_ADD_WEEKDAY,
{vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)},
"async_add_weekday",
)
component.async_register_entity_service(
SERVICE_REMOVE_WEEKDAY,
{vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)},
"async_remove_weekday",
)
component.async_register_entity_service(
SERVICE_TOGGLE_WEEKDAY,
{vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)},
"async_toggle_weekday",
)
component.async_register_entity_service(
SERVICE_CLEAR,
None,
"async_clear",
)
return True
class InputWeekdayStorageCollection(collection.DictStorageCollection):
"""Input weekday storage based collection."""
CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_weekday))
async def _process_create_data(self, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the config is valid."""
return self.CREATE_UPDATE_SCHEMA(data)
@callback
def _get_suggested_id(self, info: dict[str, Any]) -> str:
"""Suggest an ID based on the config."""
return info[CONF_NAME]
async def _update_data(
self, item: dict[str, Any], update_data: dict[str, Any]
) -> dict[str, Any]:
"""Return a new updated data object."""
update_data = self.CREATE_UPDATE_SCHEMA(update_data)
return item | update_data
# pylint: disable-next=hass-enforce-class-module
class InputWeekday(collection.CollectionEntity, RestoreEntity):
"""Representation of a weekday input."""
_unrecorded_attributes = frozenset({ATTR_EDITABLE})
_attr_should_poll = False
editable: bool
def __init__(self, config: ConfigType) -> None:
"""Initialize a weekday input."""
self._config = config
self._attr_weekdays = config.get(CONF_WEEKDAYS, [])
self._attr_unique_id = config[CONF_ID]
@classmethod
def from_storage(cls, config: ConfigType) -> Self:
"""Return entity instance initialized from storage."""
input_weekday = cls(config)
input_weekday.editable = True
return input_weekday
@classmethod
def from_yaml(cls, config: ConfigType) -> Self:
"""Return entity instance initialized from yaml."""
input_weekday = cls(config)
input_weekday.entity_id = f"{DOMAIN}.{config[CONF_ID]}"
input_weekday.editable = False
return input_weekday
@property
def name(self) -> str:
"""Return name of the weekday input."""
return self._config.get(CONF_NAME) or self._config[CONF_ID]
@property
def icon(self) -> str | None:
"""Return the icon to be used for this entity."""
return self._config.get(CONF_ICON)
@property
def state(self) -> str:
"""Return the state of the entity."""
# Return a comma-separated string of selected weekdays
return ",".join(self._attr_weekdays) if self._attr_weekdays else ""
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes of the entity."""
return {
ATTR_WEEKDAYS: self._attr_weekdays,
ATTR_EDITABLE: self.editable,
}
async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to hass."""
await super().async_added_to_hass()
# Restore previous state if no initial weekdays were provided
if self._config.get(CONF_WEEKDAYS) is not None:
return
state = await self.async_get_last_state()
if state is not None and ATTR_WEEKDAYS in state.attributes:
self._attr_weekdays = state.attributes[ATTR_WEEKDAYS]
async def async_set_weekdays(self, weekdays: list[str]) -> None:
"""Set the selected weekdays."""
# Remove duplicates while preserving order
self._attr_weekdays = list(dict.fromkeys(weekdays))
self.async_write_ha_state()
async def async_add_weekday(self, weekday: str) -> None:
"""Add a weekday to the selection."""
if weekday not in self._attr_weekdays:
self._attr_weekdays.append(weekday)
self.async_write_ha_state()
async def async_remove_weekday(self, weekday: str) -> None:
"""Remove a weekday from the selection."""
if weekday in self._attr_weekdays:
self._attr_weekdays.remove(weekday)
self.async_write_ha_state()
async def async_toggle_weekday(self, weekday: str) -> None:
"""Toggle a weekday in the selection."""
if weekday in self._attr_weekdays:
self._attr_weekdays.remove(weekday)
else:
self._attr_weekdays.append(weekday)
self.async_write_ha_state()
async def async_clear(self) -> None:
"""Clear all selected weekdays."""
self._attr_weekdays = []
self.async_write_ha_state()
async def async_update_config(self, config: ConfigType) -> None:
"""Handle when the config is updated."""
self._config = config
self._attr_weekdays = config.get(CONF_WEEKDAYS, [])
self.async_write_ha_state()

View File

@@ -0,0 +1,29 @@
{
"entity": {
"input_weekday": {
"default": {
"default": "mdi:calendar-week"
}
}
},
"services": {
"set_weekdays": {
"service": "mdi:calendar-edit"
},
"add_weekday": {
"service": "mdi:calendar-plus"
},
"remove_weekday": {
"service": "mdi:calendar-minus"
},
"toggle_weekday": {
"service": "mdi:calendar-check"
},
"clear": {
"service": "mdi:calendar-remove"
},
"reload": {
"service": "mdi:reload"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "input_weekday",
"name": "Input Weekday",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/input_weekday",
"integration_type": "helper",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,42 @@
"""Reproduce an Input Weekday state."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import Context, HomeAssistant, State
from . import ATTR_WEEKDAYS, DOMAIN, SERVICE_SET_WEEKDAYS
_LOGGER = logging.getLogger(__name__)
async def async_reproduce_states(
hass: HomeAssistant,
states: list[State],
*,
context: Context | None = None,
reproduce_options: dict[str, Any] | None = None,
) -> None:
"""Reproduce Input Weekday states."""
for state in states:
if ATTR_WEEKDAYS not in state.attributes:
_LOGGER.warning(
"Unable to reproduce state for %s: %s attribute is missing",
state.entity_id,
ATTR_WEEKDAYS,
)
continue
weekdays = state.attributes[ATTR_WEEKDAYS]
service_data = {
ATTR_ENTITY_ID: state.entity_id,
ATTR_WEEKDAYS: weekdays,
}
await hass.services.async_call(
DOMAIN, SERVICE_SET_WEEKDAYS, service_data, context=context, blocking=True
)

View File

@@ -0,0 +1,115 @@
set_weekdays:
target:
entity:
domain: input_weekday
fields:
weekdays:
required: true
example: '["mon", "wed", "fri"]'
selector:
select:
multiple: true
mode: list
options:
- value: mon
label: Monday
- value: tue
label: Tuesday
- value: wed
label: Wednesday
- value: thu
label: Thursday
- value: fri
label: Friday
- value: sat
label: Saturday
- value: sun
label: Sunday
add_weekday:
target:
entity:
domain: input_weekday
fields:
weekday:
required: true
example: mon
selector:
select:
mode: dropdown
options:
- value: mon
label: Monday
- value: tue
label: Tuesday
- value: wed
label: Wednesday
- value: thu
label: Thursday
- value: fri
label: Friday
- value: sat
label: Saturday
- value: sun
label: Sunday
remove_weekday:
target:
entity:
domain: input_weekday
fields:
weekday:
required: true
example: mon
selector:
select:
mode: dropdown
options:
- value: mon
label: Monday
- value: tue
label: Tuesday
- value: wed
label: Wednesday
- value: thu
label: Thursday
- value: fri
label: Friday
- value: sat
label: Saturday
- value: sun
label: Sunday
toggle_weekday:
target:
entity:
domain: input_weekday
fields:
weekday:
required: true
example: mon
selector:
select:
mode: dropdown
options:
- value: mon
label: Monday
- value: tue
label: Tuesday
- value: wed
label: Wednesday
- value: thu
label: Thursday
- value: fri
label: Friday
- value: sat
label: Saturday
- value: sun
label: Sunday
clear:
target:
entity:
domain: input_weekday
reload:

View File

@@ -0,0 +1,70 @@
{
"title": "Input Weekday",
"entity_component": {
"_": {
"name": "[%key:component::input_weekday::title%]",
"state_attributes": {
"weekdays": {
"name": "Weekdays"
},
"editable": {
"name": "[%key:common::generic::ui_managed%]",
"state": {
"true": "[%key:common::state::yes%]",
"false": "[%key:common::state::no%]"
}
}
}
}
},
"services": {
"set_weekdays": {
"name": "Set weekdays",
"description": "Sets the selected weekdays.",
"fields": {
"weekdays": {
"name": "Weekdays",
"description": "List of weekdays to select."
}
}
},
"add_weekday": {
"name": "Add weekday",
"description": "Adds a weekday to the selection.",
"fields": {
"weekday": {
"name": "Weekday",
"description": "Weekday to add."
}
}
},
"remove_weekday": {
"name": "Remove weekday",
"description": "Removes a weekday from the selection.",
"fields": {
"weekday": {
"name": "Weekday",
"description": "Weekday to remove."
}
}
},
"toggle_weekday": {
"name": "Toggle weekday",
"description": "Toggles a weekday in the selection.",
"fields": {
"weekday": {
"name": "Weekday",
"description": "Weekday to toggle."
}
}
},
"clear": {
"name": "Clear",
"description": "Clears all selected weekdays."
},
"reload": {
"name": "[%key:common::action::reload%]",
"description": "Reloads helpers from the YAML-configuration."
}
}
}

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.1"]
"requirements": ["pylamarzocco==2.1.2"]
}

View File

@@ -59,7 +59,7 @@ async def create_server(
# Backwards compatibility with old MCP Server config
return await llm.async_get_api(hass, llm_api_id, llm_context)
@server.list_prompts() # type: ignore[no-untyped-call, misc]
@server.list_prompts() # type: ignore[no-untyped-call,untyped-decorator]
async def handle_list_prompts() -> list[types.Prompt]:
llm_api = await get_api_instance()
return [
@@ -69,7 +69,7 @@ async def create_server(
)
]
@server.get_prompt() # type: ignore[no-untyped-call, misc]
@server.get_prompt() # type: ignore[no-untyped-call,untyped-decorator]
async def handle_get_prompt(
name: str, arguments: dict[str, str] | None
) -> types.GetPromptResult:
@@ -90,13 +90,13 @@ async def create_server(
],
)
@server.list_tools() # type: ignore[no-untyped-call, misc]
@server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
async def list_tools() -> list[types.Tool]:
"""List available time tools."""
llm_api = await get_api_instance()
return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools]
@server.call_tool() # type: ignore[misc]
@server.call_tool() # type: ignore[untyped-decorator]
async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]:
"""Handle calling tools."""
llm_api = await get_api_instance()

View File

@@ -10,7 +10,11 @@ from mill import Heater, Mill
from mill_local import Mill as MillLocal
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
from homeassistant.components.recorder.models import (
StatisticData,
StatisticMeanType,
StatisticMetaData,
)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@@ -147,7 +151,7 @@ class MillHistoricDataUpdateCoordinator(DataUpdateCoordinator):
)
)
metadata = StatisticMetaData(
has_mean=False,
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{heater.name}",
source=DOMAIN,

View File

@@ -253,6 +253,7 @@ class ModbusHub:
self._client: (
AsyncModbusSerialClient | AsyncModbusTcpClient | AsyncModbusUdpClient | None
) = None
self._lock = asyncio.Lock()
self.event_connected = asyncio.Event()
self.hass = hass
self.name = client_config[CONF_NAME]
@@ -415,7 +416,9 @@ class ModbusHub:
"""Convert async to sync pymodbus call."""
if not self._client:
return None
result = await self.low_level_pb_call(unit, address, value, use_call)
if self._msg_wait:
await asyncio.sleep(self._msg_wait)
return result
async with self._lock:
result = await self.low_level_pb_call(unit, address, value, use_call)
if self._msg_wait:
# small delay until next request/response
await asyncio.sleep(self._msg_wait)
return result

View File

@@ -174,7 +174,7 @@ class MotionBaseDevice(MotionCoordinatorEntity, CoverEntity):
_restore_tilt = False
def __init__(self, coordinator, blind, device_class):
def __init__(self, coordinator, blind, device_class) -> None:
"""Initialize the blind."""
super().__init__(coordinator, blind)
@@ -275,7 +275,7 @@ class MotionTiltDevice(MotionPositionDevice):
"""
if self._blind.angle is None:
return None
return self._blind.angle * 100 / 180
return 100 - (self._blind.angle * 100 / 180)
@property
def is_closed(self) -> bool | None:
@@ -287,14 +287,14 @@ class MotionTiltDevice(MotionPositionDevice):
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_angle, 180)
await self.hass.async_add_executor_job(self._blind.Set_angle, 0)
await self.async_request_position_till_stop()
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_angle, 0)
await self.hass.async_add_executor_job(self._blind.Set_angle, 180)
await self.async_request_position_till_stop()
@@ -302,7 +302,7 @@ class MotionTiltDevice(MotionPositionDevice):
"""Move the cover tilt to a specific position."""
angle = kwargs[ATTR_TILT_POSITION] * 180 / 100
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
await self.hass.async_add_executor_job(self._blind.Set_angle, 180 - angle)
await self.async_request_position_till_stop()
@@ -347,9 +347,9 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
if self._blind.position is None:
if self._blind.angle is None:
return None
return self._blind.angle * 100 / 180
return 100 - (self._blind.angle * 100 / 180)
return self._blind.position
return 100 - self._blind.position
@property
def is_closed(self) -> bool | None:
@@ -357,9 +357,9 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
if self._blind.position is None:
if self._blind.angle is None:
return None
return self._blind.angle == 0
return self._blind.angle == 180
return self._blind.position == 0
return self._blind.position == 100
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
@@ -381,10 +381,14 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
if self._blind.position is None:
angle = angle * 180 / 100
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
await self.hass.async_add_executor_job(
self._blind.Set_angle, 180 - angle
)
else:
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
await self.hass.async_add_executor_job(
self._blind.Set_position, 100 - angle
)
await self.async_request_position_till_stop()
@@ -397,10 +401,14 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
if self._blind.position is None:
angle = angle * 180 / 100
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_angle, angle)
await self.hass.async_add_executor_job(
self._blind.Set_angle, 180 - angle
)
else:
async with self._api_lock:
await self.hass.async_add_executor_job(self._blind.Set_position, angle)
await self.hass.async_add_executor_job(
self._blind.Set_position, 100 - angle
)
await self.async_request_position_till_stop()
@@ -408,7 +416,7 @@ class MotionTiltOnlyDevice(MotionTiltDevice):
class MotionTDBUDevice(MotionBaseDevice):
"""Representation of a Motion Top Down Bottom Up blind Device."""
def __init__(self, coordinator, blind, device_class, motor):
def __init__(self, coordinator, blind, device_class, motor) -> None:
"""Initialize the blind."""
super().__init__(coordinator, blind, device_class)
self._motor = motor

View File

@@ -53,7 +53,7 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255))
await self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS))
async def async_turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/niko_home_control",
"iot_class": "local_push",
"loggers": ["nikohomecontrol"],
"requirements": ["nhc==0.4.12"]
"requirements": ["nhc==0.6.1"]
}

View File

@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["pynintendoparental"],
"quality_scale": "bronze",
"requirements": ["pynintendoparental==1.0.1"]
"requirements": ["pynintendoparental==1.1.1"]
}

View File

@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["openai==1.99.5", "python-open-router==0.3.1"]
"requirements": ["openai==2.2.0", "python-open-router==0.3.1"]
}

View File

@@ -316,16 +316,23 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
options = self.options
errors: dict[str, str] = {}
step_schema: VolDictType = {
vol.Optional(
CONF_CODE_INTERPRETER,
default=RECOMMENDED_CODE_INTERPRETER,
): bool,
}
step_schema: VolDictType = {}
model = options[CONF_CHAT_MODEL]
if model.startswith(("o", "gpt-5")):
if not model.startswith(("gpt-5-pro", "gpt-5-codex")):
step_schema.update(
{
vol.Optional(
CONF_CODE_INTERPRETER,
default=RECOMMENDED_CODE_INTERPRETER,
): bool,
}
)
elif CONF_CODE_INTERPRETER in options:
options.pop(CONF_CODE_INTERPRETER)
if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"):
step_schema.update(
{
vol.Optional(

View File

@@ -468,7 +468,9 @@ class OpenAIBaseLLMEntity(Entity):
model_args["reasoning"] = {
"effort": options.get(
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
),
)
if not model_args["model"].startswith("gpt-5-pro")
else "high", # GPT-5 pro only supports reasoning.effort: high
"summary": "auto",
}
model_args["include"] = ["reasoning.encrypted_content"]
@@ -487,7 +489,7 @@ class OpenAIBaseLLMEntity(Entity):
if options.get(CONF_WEB_SEARCH):
web_search = WebSearchToolParam(
type="web_search_preview",
type="web_search",
search_context_size=options.get(
CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
),

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["openai==1.99.5"]
"requirements": ["openai==2.2.0"]
}

View File

@@ -18,7 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .coordinator import PortainerCoordinator
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH]
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]

View File

@@ -1,5 +1,10 @@
{
"entity": {
"sensor": {
"image": {
"default": "mdi:docker"
}
},
"switch": {
"container": {
"default": "mdi:arrow-down-box",

View File

@@ -0,0 +1,83 @@
"""Sensor platform for Portainer integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyportainer.models.docker import DockerContainer
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PortainerConfigEntry, PortainerCoordinator
from .entity import PortainerContainerEntity, PortainerCoordinatorData
@dataclass(frozen=True, kw_only=True)
class PortainerSensorEntityDescription(SensorEntityDescription):
"""Class to hold Portainer sensor description."""
value_fn: Callable[[DockerContainer], str | None]
CONTAINER_SENSORS: tuple[PortainerSensorEntityDescription, ...] = (
PortainerSensorEntityDescription(
key="image",
translation_key="image",
value_fn=lambda data: data.image,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PortainerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Portainer sensors based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
PortainerContainerSensor(
coordinator,
entity_description,
container,
endpoint,
)
for endpoint in coordinator.data.values()
for container in endpoint.containers.values()
for entity_description in CONTAINER_SENSORS
)
class PortainerContainerSensor(PortainerContainerEntity, SensorEntity):
"""Representation of a Portainer container sensor."""
entity_description: PortainerSensorEntityDescription
def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerSensorEntityDescription,
device_info: DockerContainer,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer container sensor."""
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
@property
def available(self) -> bool:
"""Return if the device is available."""
return super().available and self.endpoint_id in self.coordinator.data
@property
def native_value(self) -> str | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(
self.coordinator.data[self.endpoint_id].containers[self.device_id]
)

View File

@@ -46,6 +46,11 @@
"name": "Status"
}
},
"sensor": {
"image": {
"name": "Image"
}
},
"switch": {
"container": {
"name": "Container"

View File

@@ -1,7 +1,7 @@
{
"domain": "sharkiq",
"name": "Shark IQ",
"codeowners": ["@JeffResc", "@funkybunch"],
"codeowners": ["@JeffResc", "@funkybunch", "@TheOneOgre"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
"iot_class": "cloud_polling",

View File

@@ -157,21 +157,18 @@ SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
key="input|input",
name="Input",
device_class=BinarySensorDeviceClass.POWER,
entity_registry_enabled_default=False,
removal_condition=is_block_momentary_input,
),
("relay", "input"): BlockBinarySensorDescription(
key="relay|input",
name="Input",
device_class=BinarySensorDeviceClass.POWER,
entity_registry_enabled_default=False,
removal_condition=is_block_momentary_input,
),
("device", "input"): BlockBinarySensorDescription(
key="device|input",
name="Input",
device_class=BinarySensorDeviceClass.POWER,
entity_registry_enabled_default=False,
removal_condition=is_block_momentary_input,
),
("sensor", "extInput"): BlockBinarySensorDescription(
@@ -201,7 +198,6 @@ RPC_SENSORS: Final = {
key="input",
sub_key="state",
device_class=BinarySensorDeviceClass.POWER,
entity_registry_enabled_default=False,
removal_condition=is_rpc_momentary_input,
),
"cloud": RpcBinarySensorDescription(

View File

@@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any, Final
from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
from aioshelly.rpc_device import RpcDevice
from homeassistant.components.button import (
DOMAIN as BUTTON_PLATFORM,
@@ -24,16 +23,24 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS
from .const import DOMAIN, LOGGER, MODEL_FRANKEVER_WATER_VALVE, SHELLY_GAS_MODELS
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import get_entity_block_device_info, get_entity_rpc_device_info
from .entity import (
RpcEntityDescription,
ShellyRpcAttributeEntity,
async_setup_entry_rpc,
get_entity_block_device_info,
get_entity_rpc_device_info,
rpc_call,
)
from .utils import (
async_remove_orphaned_entities,
format_ble_addr,
get_blu_trv_device_info,
get_device_entry_gen,
get_rpc_entity_name,
get_rpc_key_ids,
get_rpc_key_instances,
get_rpc_role_by_key,
get_virtual_component_ids,
)
@@ -51,6 +58,11 @@ class ShellyButtonDescription[
supported: Callable[[_ShellyCoordinatorT], bool] = lambda _: True
@dataclass(frozen=True, kw_only=True)
class RpcButtonDescription(RpcEntityDescription, ButtonEntityDescription):
"""Class to describe a RPC button."""
BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
ShellyButtonDescription[ShellyBlockCoordinator | ShellyRpcCoordinator](
key="reboot",
@@ -96,12 +108,24 @@ BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [
),
]
VIRTUAL_BUTTONS: Final[list[ShellyButtonDescription]] = [
ShellyButtonDescription[ShellyRpcCoordinator](
RPC_VIRTUAL_BUTTONS = {
"button_generic": RpcButtonDescription(
key="button",
press_action="single_push",
)
]
role="generic",
),
"button_open": RpcButtonDescription(
key="button",
entity_registry_enabled_default=False,
role="open",
models={MODEL_FRANKEVER_WATER_VALVE},
),
"button_close": RpcButtonDescription(
key="button",
entity_registry_enabled_default=False,
role="close",
models={MODEL_FRANKEVER_WATER_VALVE},
),
}
@callback
@@ -129,8 +153,10 @@ def async_migrate_unique_ids(
)
}
if not isinstance(coordinator, ShellyRpcCoordinator):
return None
if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
assert isinstance(coordinator.device, RpcDevice)
for _id in blutrv_key_ids:
key = f"{BLU_TRV_IDENTIFIER}:{_id}"
ble_addr: str = coordinator.device.config[key]["addr"]
@@ -149,6 +175,26 @@ def async_migrate_unique_ids(
)
}
if virtual_button_keys := get_rpc_key_instances(
coordinator.device.config, "button"
):
for key in virtual_button_keys:
old_unique_id = f"{coordinator.mac}-{key}"
if entity_entry.unique_id == old_unique_id:
role = get_rpc_role_by_key(coordinator.device.config, key)
new_unique_id = f"{coordinator.mac}-{key}-button_{role}"
LOGGER.debug(
"Migrating unique_id for %s entity from [%s] to [%s]",
entity_entry.entity_id,
old_unique_id,
new_unique_id,
)
return {
"new_unique_id": entity_entry.unique_id.replace(
old_unique_id, new_unique_id
)
}
return None
@@ -172,7 +218,7 @@ async def async_setup_entry(
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
)
entities: list[ShellyButton | ShellyBluTrvButton | ShellyVirtualButton] = []
entities: list[ShellyButton | ShellyBluTrvButton] = []
entities.extend(
ShellyButton(coordinator, button)
@@ -185,12 +231,9 @@ async def async_setup_entry(
return
# add virtual buttons
if virtual_button_ids := get_rpc_key_ids(coordinator.device.status, "button"):
entities.extend(
ShellyVirtualButton(coordinator, button, id_)
for id_ in virtual_button_ids
for button in VIRTUAL_BUTTONS
)
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_VIRTUAL_BUTTONS, RpcVirtualButton
)
# add BLU TRV buttons
if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
@@ -332,30 +375,16 @@ class ShellyBluTrvButton(ShellyBaseButton):
await method(self._id)
class ShellyVirtualButton(ShellyBaseButton):
"""Defines a Shelly virtual component button."""
class RpcVirtualButton(ShellyRpcAttributeEntity, ButtonEntity):
"""Defines a Shelly RPC virtual component button."""
def __init__(
self,
coordinator: ShellyRpcCoordinator,
description: ShellyButtonDescription,
_id: int,
) -> None:
"""Initialize Shelly virtual component button."""
super().__init__(coordinator, description)
entity_description: RpcButtonDescription
_id: int
self._attr_unique_id = f"{coordinator.mac}-{description.key}:{_id}"
self._attr_device_info = get_entity_rpc_device_info(coordinator)
self._attr_name = get_rpc_entity_name(
coordinator.device, f"{description.key}:{_id}"
)
self._id = _id
async def _press_method(self) -> None:
"""Press method."""
@rpc_call
async def async_press(self) -> None:
"""Triggers the Shelly button press service."""
if TYPE_CHECKING:
assert isinstance(self.coordinator, ShellyRpcCoordinator)
await self.coordinator.device.button_trigger(
self._id, self.entity_description.press_action
)
await self.coordinator.device.button_trigger(self._id, "single_push")

View File

@@ -195,9 +195,11 @@ def async_setup_rpc_attribute_entities(
):
continue
if description.sub_key not in coordinator.device.status[
key
] and not description.supported(coordinator.device.status[key]):
if (
description.sub_key
and description.sub_key not in coordinator.device.status[key]
and not description.supported(coordinator.device.status[key])
):
continue
# Filter and remove entities that according to settings/status
@@ -309,7 +311,7 @@ class RpcEntityDescription(EntityDescription):
# restrict the type to str.
name: str = ""
sub_key: str
sub_key: str | None = None
value: Callable[[Any, Any], Any] | None = None
available: Callable[[dict], bool] | None = None

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from aioshelly.block_device import Block
from aioshelly.const import RPC_GENERATIONS
@@ -37,6 +37,7 @@ from .entity import (
ShellySleepingBlockAttributeEntity,
async_setup_entry_attribute_entities,
async_setup_entry_rpc,
rpc_call,
)
from .utils import (
async_remove_orphaned_entities,
@@ -78,7 +79,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription):
is_on: Callable[[dict[str, Any]], bool]
method_on: str
method_off: str
method_params_fn: Callable[[int | None, bool], dict]
method_params_fn: Callable[[int | None, bool], tuple]
RPC_RELAY_SWITCHES = {
@@ -87,9 +88,9 @@ RPC_RELAY_SWITCHES = {
sub_key="output",
removal_condition=is_rpc_exclude_from_relay,
is_on=lambda status: bool(status["output"]),
method_on="Switch.Set",
method_off="Switch.Set",
method_params_fn=lambda id, value: {"id": id, "on": value},
method_on="switch_set",
method_off="switch_set",
method_params_fn=lambda id, value: (id, value),
),
}
@@ -101,9 +102,9 @@ RPC_SWITCHES = {
config, key, SWITCH_PLATFORM
),
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="generic",
),
"boolean_anti_freeze": RpcSwitchDescription(
@@ -111,9 +112,9 @@ RPC_SWITCHES = {
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="anti_freeze",
models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT},
),
@@ -121,9 +122,9 @@ RPC_SWITCHES = {
key="boolean",
sub_key="value",
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="child_lock",
models={MODEL_LINKEDGO_ST1820_THERMOSTAT},
),
@@ -132,9 +133,9 @@ RPC_SWITCHES = {
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="enable",
models={MODEL_LINKEDGO_ST802_THERMOSTAT, MODEL_LINKEDGO_ST1820_THERMOSTAT},
),
@@ -142,9 +143,9 @@ RPC_SWITCHES = {
key="boolean",
sub_key="value",
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="start_charging",
models={MODEL_TOP_EV_CHARGER_EVE01},
),
@@ -153,9 +154,9 @@ RPC_SWITCHES = {
sub_key="value",
entity_registry_enabled_default=False,
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="state",
models={MODEL_NEO_WATER_VALVE},
),
@@ -163,9 +164,9 @@ RPC_SWITCHES = {
key="boolean",
sub_key="value",
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="zone0",
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
@@ -173,9 +174,9 @@ RPC_SWITCHES = {
key="boolean",
sub_key="value",
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="zone1",
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
@@ -183,9 +184,9 @@ RPC_SWITCHES = {
key="boolean",
sub_key="value",
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="zone2",
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
@@ -193,9 +194,9 @@ RPC_SWITCHES = {
key="boolean",
sub_key="value",
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="zone3",
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
@@ -203,9 +204,9 @@ RPC_SWITCHES = {
key="boolean",
sub_key="value",
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="zone4",
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
@@ -213,9 +214,9 @@ RPC_SWITCHES = {
key="boolean",
sub_key="value",
is_on=lambda status: bool(status["value"]),
method_on="Boolean.Set",
method_off="Boolean.Set",
method_params_fn=lambda id, value: {"id": id, "value": value},
method_on="boolean_set",
method_off="boolean_set",
method_params_fn=lambda id, value: (id, value),
role="zone5",
models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER},
),
@@ -223,9 +224,9 @@ RPC_SWITCHES = {
key="script",
sub_key="running",
is_on=lambda status: bool(status["running"]),
method_on="Script.Start",
method_off="Script.Stop",
method_params_fn=lambda id, _: {"id": id},
method_on="script_start",
method_off="script_stop",
method_params_fn=lambda id, _: (id,),
entity_registry_enabled_default=False,
entity_category=EntityCategory.CONFIG,
),
@@ -422,19 +423,27 @@ class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity):
"""If switch is on."""
return self.entity_description.is_on(self.status)
@rpc_call
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on relay."""
await self.call_rpc(
self.entity_description.method_on,
self.entity_description.method_params_fn(self._id, True),
)
"""Turn on switch."""
method = getattr(self.coordinator.device, self.entity_description.method_on)
if TYPE_CHECKING:
assert method is not None
params = self.entity_description.method_params_fn(self._id, True)
await method(*params)
@rpc_call
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off relay."""
await self.call_rpc(
self.entity_description.method_off,
self.entity_description.method_params_fn(self._id, False),
)
"""Turn off switch."""
method = getattr(self.coordinator.device, self.entity_description.method_off)
if TYPE_CHECKING:
assert method is not None
params = self.entity_description.method_params_fn(self._id, False)
await method(*params)
class RpcRelaySwitch(RpcSwitch):

View File

@@ -34,6 +34,17 @@
"climate": {
"air_conditioner": {
"state_attributes": {
"preset_mode": {
"state": {
"wind_free": "mdi:weather-dust",
"wind_free_sleep": "mdi:sleep",
"quiet": "mdi:volume-off",
"long_wind": "mdi:weather-windy",
"smart": "mdi:leaf",
"motion_direct": "mdi:account-arrow-left",
"motion_indirect": "mdi:account-arrow-right"
}
},
"fan_mode": {
"state": {
"turbo": "mdi:wind-power"

View File

@@ -87,7 +87,7 @@
"wind_free_sleep": "WindFree sleep",
"quiet": "Quiet",
"long_wind": "Long wind",
"smart": "Smart",
"smart": "Smart saver",
"motion_direct": "Motion direct",
"motion_indirect": "Motion indirect"
}

View File

@@ -241,7 +241,6 @@ class SuezWaterCoordinator(DataUpdateCoordinator[SuezWaterData]):
) -> StatisticMetaData:
"""Build statistics metadata for requested configuration."""
return StatisticMetaData(
has_mean=False,
mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"Suez water {name} {self._counter_id}",

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/systemmonitor",
"iot_class": "local_push",
"loggers": ["psutil"],
"requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"],
"requirements": ["psutil-home-assistant==0.0.1", "psutil==7.1.0"],
"single_config_entry": true
}

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.81"]
"requirements": ["holidays==0.82"]
}

View File

@@ -744,8 +744,11 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
# Without confirmation, discovery can automatically progress into parts of the
# config flow logic that interacts with hardware.
# Ignore Zeroconf discoveries during onboarding, as they may be in use already.
if user_input is not None or (
not onboarding.async_is_onboarded(self.hass) and not zha_config_entries
not onboarding.async_is_onboarded(self.hass)
and not zha_config_entries
and self.source != SOURCE_ZEROCONF
):
# Probe the radio type if we don't have one yet
if self._radio_mgr.radio_type is None:

View File

@@ -11,7 +11,13 @@ from typing import Any
from propcache.api import cached_property
from zha.mixins import LogMixin
from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, EntityCategory
from homeassistant.const import (
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NAME,
ATTR_VIA_DEVICE,
EntityCategory,
)
from homeassistant.core import State, callback
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -85,14 +91,19 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
ieee = zha_device_info["ieee"]
zha_gateway = self.entity_data.device_proxy.gateway_proxy.gateway
return DeviceInfo(
device_info = DeviceInfo(
connections={(CONNECTION_ZIGBEE, ieee)},
identifiers={(DOMAIN, ieee)},
manufacturer=zha_device_info[ATTR_MANUFACTURER],
model=zha_device_info[ATTR_MODEL],
name=zha_device_info[ATTR_NAME],
via_device=(DOMAIN, str(zha_gateway.state.node_info.ieee)),
)
if ieee != str(zha_gateway.state.node_info.ieee):
device_info[ATTR_VIA_DEVICE] = (
DOMAIN,
str(zha_gateway.state.node_info.ieee),
)
return device_info
@callback
def _handle_entity_events(self, event: Any) -> None:

View File

@@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Final
from .generated.entity_platforms import EntityPlatforms
from .helpers.deprecation import (
DeprecatedConstantEnum,
EnumWithDeprecatedMembers,
all_with_deprecated_constants,
check_if_deprecated_constant,
dir_with_deprecated_constants,
@@ -704,35 +703,13 @@ class UnitOfMass(StrEnum):
STONES = "st"
class UnitOfConductivity(
StrEnum,
metaclass=EnumWithDeprecatedMembers,
deprecated={
"SIEMENS": ("UnitOfConductivity.SIEMENS_PER_CM", "2025.11.0"),
"MICROSIEMENS": ("UnitOfConductivity.MICROSIEMENS_PER_CM", "2025.11.0"),
"MILLISIEMENS": ("UnitOfConductivity.MILLISIEMENS_PER_CM", "2025.11.0"),
},
):
class UnitOfConductivity(StrEnum):
"""Conductivity units."""
SIEMENS_PER_CM = "S/cm"
MICROSIEMENS_PER_CM = "μS/cm"
MILLISIEMENS_PER_CM = "mS/cm"
# Deprecated aliases
SIEMENS = "S/cm"
"""Deprecated: Please use UnitOfConductivity.SIEMENS_PER_CM"""
MICROSIEMENS = "μS/cm"
"""Deprecated: Please use UnitOfConductivity.MICROSIEMENS_PER_CM"""
MILLISIEMENS = "mS/cm"
"""Deprecated: Please use UnitOfConductivity.MILLISIEMENS_PER_CM"""
_DEPRECATED_CONDUCTIVITY: Final = DeprecatedConstantEnum(
UnitOfConductivity.MICROSIEMENS_PER_CM,
"2025.11",
)
"""Deprecated: please use UnitOfConductivity.MICROSIEMENS_PER_CM"""
# Light units
LIGHT_LUX: Final = "lx"

View File

@@ -7922,6 +7922,10 @@
"integration_type": "helper",
"config_flow": false
},
"input_weekday": {
"integration_type": "helper",
"config_flow": false
},
"integration": {
"integration_type": "helper",
"config_flow": true,
@@ -8021,6 +8025,7 @@
"input_number",
"input_select",
"input_text",
"input_weekday",
"integration",
"irm_kmi",
"islamic_prayer_times",

View File

@@ -954,11 +954,25 @@ def time(
if weekday is not None:
now_weekday = WEEKDAYS[now.weekday()]
condition_trace_update_result(weekday=weekday, now_weekday=now_weekday)
if (
isinstance(weekday, str) and weekday != now_weekday
) or now_weekday not in weekday:
return False
# Check if weekday is an entity_id
if isinstance(weekday, str) and weekday.startswith("input_weekday."):
if (weekday_state := hass.states.get(weekday)) is None:
condition_trace_update_result(weekday=weekday, now_weekday=now_weekday)
return False
entity_weekdays = weekday_state.attributes.get("weekdays", [])
condition_trace_update_result(
weekday=weekday,
now_weekday=now_weekday,
entity_weekdays=entity_weekdays,
)
if now_weekday not in entity_weekdays:
return False
else:
condition_trace_update_result(weekday=weekday, now_weekday=now_weekday)
if (
isinstance(weekday, str) and weekday != now_weekday
) or now_weekday not in weekday:
return False
return True

View File

@@ -843,7 +843,10 @@ def time_zone(value: str) -> str:
)
weekdays = vol.All(ensure_list, [vol.In(WEEKDAYS)])
weekdays = vol.Any(
vol.All(ensure_list, [vol.In(WEEKDAYS)]),
entity_domain(["input_weekday"]),
)
def socket_timeout(value: Any | None) -> object:

View File

@@ -36,7 +36,7 @@ from homeassistant.core import (
callback,
split_entity_id,
)
from homeassistant.exceptions import TemplateError
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
@@ -1004,12 +1004,9 @@ class TrackTemplateResultInfo:
if track_template_.template.hass:
continue
frame.report_usage(
"calls async_track_template_result with template without hass",
core_behavior=frame.ReportBehavior.LOG,
breaks_in_ha_version="2025.10",
raise HomeAssistantError(
"Calls async_track_template_result with template without hass"
)
track_template_.template.hass = hass
self._rate_limit = KeyedRateLimit(hass)
self._info: dict[Template, RenderInfo] = {}

View File

@@ -130,7 +130,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.11.9
pydantic==2.12.0
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1

16
requirements_all.txt generated
View File

@@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.12.0
aioesphomeapi==41.13.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -895,7 +895,7 @@ enocean==0.50
enturclient==0.2.4
# homeassistant.components.environment_canada
env-canada==0.11.2
env-canada==0.11.3
# homeassistant.components.season
ephem==4.1.6
@@ -1183,7 +1183,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.81
holidays==0.82
# homeassistant.components.frontend
home-assistant-frontend==20251001.0
@@ -1545,7 +1545,7 @@ nextcord==3.1.0
nextdns==4.1.0
# homeassistant.components.niko_home_control
nhc==0.4.12
nhc==0.6.1
# homeassistant.components.nibe_heatpump
nibe==2.19.0
@@ -1628,7 +1628,7 @@ open-meteo==0.3.2
# homeassistant.components.open_router
# homeassistant.components.openai_conversation
openai==1.99.5
openai==2.2.0
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -1755,7 +1755,7 @@ proxmoxer==2.0.1
psutil-home-assistant==0.0.1
# homeassistant.components.systemmonitor
psutil==7.0.0
psutil==7.1.0
# homeassistant.components.pulseaudio_loopback
pulsectl==23.5.2
@@ -2135,7 +2135,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
pylamarzocco==2.1.1
pylamarzocco==2.1.2
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2210,7 +2210,7 @@ pynetio==0.1.9.1
pynina==0.3.6
# homeassistant.components.nintendo_parental
pynintendoparental==1.0.1
pynintendoparental==1.1.1
# homeassistant.components.nobo_hub
pynobo==1.8.1

View File

@@ -11,11 +11,13 @@ astroid==3.3.11
coverage==7.10.6
freezegun==1.5.2
go2rtc-client==0.2.1
# librt is an internal mypy dependency
librt==0.2.1
license-expression==30.4.3
mock-open==1.4.0
mypy-dev==1.19.0a2
mypy-dev==1.19.0a4
pre-commit==4.2.0
pydantic==2.11.9
pydantic==2.12.0
pylint==3.3.8
pylint-per-file-ignores==1.4.0
pipdeptree==2.26.1

View File

@@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.12.0
aioesphomeapi==41.13.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -777,7 +777,7 @@ energyzero==2.1.1
enocean==0.50
# homeassistant.components.environment_canada
env-canada==0.11.2
env-canada==0.11.3
# homeassistant.components.season
ephem==4.1.6
@@ -1032,7 +1032,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.81
holidays==0.82
# homeassistant.components.frontend
home-assistant-frontend==20251001.0
@@ -1328,7 +1328,7 @@ nextcord==3.1.0
nextdns==4.1.0
# homeassistant.components.niko_home_control
nhc==0.4.12
nhc==0.6.1
# homeassistant.components.nibe_heatpump
nibe==2.19.0
@@ -1399,7 +1399,7 @@ open-meteo==0.3.2
# homeassistant.components.open_router
# homeassistant.components.openai_conversation
openai==1.99.5
openai==2.2.0
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -1487,7 +1487,7 @@ prowlpy==1.0.2
psutil-home-assistant==0.0.1
# homeassistant.components.systemmonitor
psutil==7.0.0
psutil==7.1.0
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
@@ -1783,7 +1783,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8
# homeassistant.components.lamarzocco
pylamarzocco==2.1.1
pylamarzocco==2.1.2
# homeassistant.components.lastfm
pylast==5.1.0
@@ -1846,7 +1846,7 @@ pynetgear==0.10.10
pynina==0.3.6
# homeassistant.components.nintendo_parental
pynintendoparental==1.0.1
pynintendoparental==1.1.1
# homeassistant.components.nobo_hub
pynobo==1.8.1

View File

@@ -155,7 +155,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.11.9
pydantic==2.12.0
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1

View File

@@ -91,6 +91,7 @@ NO_IOT_CLASS = [
"input_number",
"input_select",
"input_text",
"input_weekday",
"intent_script",
"intent",
"logbook",

View File

@@ -2214,6 +2214,7 @@ NO_QUALITY_SCALE = [
"input_number",
"input_select",
"input_text",
"input_weekday",
"intent_script",
"intent",
"logbook",

View File

@@ -9,12 +9,17 @@ from airthings_ble import (
AirthingsDevice,
AirthingsDeviceType,
)
from bleak.backends.device import BLEDevice
from homeassistant.components.airthings_ble.const import DOMAIN
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceRegistry
from homeassistant.helpers.device_registry import (
CONNECTION_BLUETOOTH,
DeviceEntry,
DeviceRegistry,
)
from tests.common import MockConfigEntry, MockEntity
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
@@ -28,7 +33,15 @@ def patch_async_setup_entry(return_value=True):
)
def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak | None):
def patch_async_discovered_service_info(return_value: list[BluetoothServiceInfoBleak]):
"""Patch async_discovered_service_info to return given list."""
return patch(
"homeassistant.components.bluetooth.async_discovered_service_info",
return_value=return_value,
)
def patch_async_ble_device_from_address(return_value: BLEDevice | None):
"""Patch async ble device from address to return a given value."""
return patch(
"homeassistant.components.bluetooth.async_ble_device_from_address",
@@ -101,6 +114,27 @@ WAVE_SERVICE_INFO = BluetoothServiceInfoBleak(
tx_power=0,
)
WAVE_ENHANCE_SERVICE_INFO = BluetoothServiceInfoBleak(
name="cc-cc-cc-cc-cc-cc",
address="cc:cc:cc:cc:cc:cc",
device=generate_ble_device(
address="cc:cc:cc:cc:cc:cc",
name="Airthings Wave Enhance",
),
rssi=-61,
manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"},
service_data={},
service_uuids=[],
source="local",
advertisement=generate_advertisement_data(
manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"},
service_uuids=[],
),
connectable=True,
time=0,
tx_power=0,
)
VIEW_PLUS_SERVICE_INFO = BluetoothServiceInfoBleak(
name="cc-cc-cc-cc-cc-cc",
address="cc:cc:cc:cc:cc:cc",
@@ -211,6 +245,26 @@ WAVE_DEVICE_INFO = AirthingsDevice(
address="cc:cc:cc:cc:cc:cc",
)
WAVE_ENHANCE_DEVICE_INFO = AirthingsDevice(
manufacturer="Airthings AS",
hw_version="REV X",
sw_version="T-SUB-2.6.2-master+0",
model=AirthingsDeviceType.WAVE_ENHANCE_EU,
name="Airthings Wave Enhance",
identifier="123456",
sensors={
"lux": 25,
"battery": 85,
"humidity": 60.0,
"temperature": 21.0,
"co2": 500.0,
"voc": 155.0,
"pressure": 1020,
"noise": 40,
},
address="cc:cc:cc:cc:cc:cc",
)
TEMPERATURE_V1 = MockEntity(
unique_id="Airthings Wave Plus 123456_temperature",
name="Airthings Wave Plus 123456 Temperature",
@@ -247,23 +301,32 @@ VOC_V3 = MockEntity(
)
def create_entry(hass: HomeAssistant) -> MockConfigEntry:
def create_entry(
hass: HomeAssistant,
service_info: BluetoothServiceInfoBleak,
device_info: AirthingsDevice,
) -> MockConfigEntry:
"""Create a config entry."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id=WAVE_SERVICE_INFO.address,
title="Airthings Wave Plus (123456)",
unique_id=service_info.address,
title=f"{device_info.name} ({device_info.identifier})",
)
entry.add_to_hass(hass)
return entry
def create_device(entry: ConfigEntry, device_registry: DeviceRegistry):
def create_device(
entry: ConfigEntry,
device_registry: DeviceRegistry,
service_info: BluetoothServiceInfoBleak,
device_info: AirthingsDevice,
) -> DeviceEntry:
"""Create a device for the given entry."""
return device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(CONNECTION_BLUETOOTH, WAVE_SERVICE_INFO.address)},
connections={(CONNECTION_BLUETOOTH, service_info.address)},
manufacturer="Airthings AS",
name="Airthings Wave Plus (123456)",
model="Wave Plus",
name=f"{device_info.name} ({device_info.identifier})",
model=device_info.model.product_name,
)

View File

@@ -2,6 +2,8 @@
import logging
import pytest
from homeassistant.components.airthings_ble.const import DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -16,10 +18,15 @@ from . import (
VOC_V2,
VOC_V3,
WAVE_DEVICE_INFO,
WAVE_ENHANCE_DEVICE_INFO,
WAVE_ENHANCE_SERVICE_INFO,
WAVE_SERVICE_INFO,
create_device,
create_entry,
patch_airthings_ble,
patch_airthings_device_update,
patch_async_ble_device_from_address,
patch_async_discovered_service_info,
)
from tests.components.bluetooth import inject_bluetooth_service_info
@@ -33,8 +40,8 @@ async def test_migration_from_v1_to_v3_unique_id(
device_registry: dr.DeviceRegistry,
) -> None:
"""Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format."""
entry = create_entry(hass)
device = create_device(entry, device_registry)
entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO)
device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO)
assert entry is not None
assert device is not None
@@ -74,8 +81,8 @@ async def test_migration_from_v2_to_v3_unique_id(
device_registry: dr.DeviceRegistry,
) -> None:
"""Verify that we can migrate from v2 (introduced in 2023.9.0) to the latest unique id format."""
entry = create_entry(hass)
device = create_device(entry, device_registry)
entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO)
device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO)
assert entry is not None
assert device is not None
@@ -115,8 +122,8 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id(
device_registry: dr.DeviceRegistry,
) -> None:
"""Test if migration works when we have both v1 (pre 2023.9.0) and v2 (introduced in 2023.9.0) unique ids."""
entry = create_entry(hass)
device = create_device(entry, device_registry)
entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO)
device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO)
assert entry is not None
assert device is not None
@@ -165,8 +172,8 @@ async def test_migration_with_all_unique_ids(
device_registry: dr.DeviceRegistry,
) -> None:
"""Test if migration works when we have all unique ids."""
entry = create_entry(hass)
device = create_device(entry, device_registry)
entry = create_entry(hass, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO)
device = create_device(entry, device_registry, WAVE_SERVICE_INFO, WAVE_DEVICE_INFO)
assert entry is not None
assert device is not None
@@ -215,3 +222,48 @@ async def test_migration_with_all_unique_ids(
assert entity_registry.async_get(v1.entity_id).unique_id == VOC_V1.unique_id
assert entity_registry.async_get(v2.entity_id).unique_id == VOC_V2.unique_id
assert entity_registry.async_get(v3.entity_id).unique_id == VOC_V3.unique_id
@pytest.mark.parametrize(
("unique_suffix", "expected_sensor_name"),
[
("lux", "Illuminance"),
("noise", "Ambient noise"),
],
)
async def test_translation_keys(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
unique_suffix: str,
expected_sensor_name: str,
) -> None:
"""Test that translated sensor names are correct."""
entry = create_entry(hass, WAVE_ENHANCE_SERVICE_INFO, WAVE_DEVICE_INFO)
device = create_device(
entry, device_registry, WAVE_ENHANCE_SERVICE_INFO, WAVE_ENHANCE_DEVICE_INFO
)
with (
patch_async_ble_device_from_address(WAVE_ENHANCE_SERVICE_INFO.device),
patch_async_discovered_service_info([WAVE_ENHANCE_SERVICE_INFO]),
patch_airthings_ble(WAVE_ENHANCE_DEVICE_INFO),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert device is not None
assert device.name == "Airthings Wave Enhance (123456)"
unique_id = f"{WAVE_ENHANCE_DEVICE_INFO.address}_{unique_suffix}"
entity_id = entity_registry.async_get_entity_id(Platform.SENSOR, DOMAIN, unique_id)
assert entity_id is not None
state = hass.states.get(entity_id)
assert state is not None
expected_value = WAVE_ENHANCE_DEVICE_INFO.sensors[unique_suffix]
assert state.state == str(expected_value)
expected_name = f"Airthings Wave Enhance (123456) {expected_sensor_name}"
assert state.attributes.get("friendly_name") == expected_name

View File

@@ -1061,6 +1061,14 @@ def test_weekday_validation() -> None:
}
time.TRIGGER_SCHEMA(valid_config)
# Valid input_weekday entity
valid_config = {
"platform": "time",
"at": "5:00:00",
"weekday": "input_weekday.workdays",
}
time.TRIGGER_SCHEMA(valid_config)
# Invalid weekday
invalid_config = {"platform": "time", "at": "5:00:00", "weekday": "invalid"}
with pytest.raises(vol.Invalid):
@@ -1074,3 +1082,176 @@ def test_weekday_validation() -> None:
}
with pytest.raises(vol.Invalid):
time.TRIGGER_SCHEMA(invalid_config)
# Invalid entity domain
invalid_config = {
"platform": "time",
"at": "5:00:00",
"weekday": "input_boolean.my_bool",
}
with pytest.raises(vol.Invalid):
time.TRIGGER_SCHEMA(invalid_config)
async def test_if_fires_using_weekday_input_weekday_entity(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_calls: list[ServiceCall],
) -> None:
"""Test for firing on weekday using input_weekday entity."""
# Setup input_weekday helper with Mon, Tue, Wed
await async_setup_component(
hass,
"input_weekday",
{
"input_weekday": {
"workdays": {
"name": "Work Days",
"weekdays": ["mon", "tue", "wed"],
}
}
},
)
await hass.async_block_till_done()
# Freeze time to Monday, January 2, 2023 at 5:00:00
monday_trigger = dt_util.as_utc(datetime(2023, 1, 2, 5, 0, 0, 0))
freezer.move_to(monday_trigger)
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time",
"at": "5:00:00",
"weekday": "input_weekday.workdays",
},
"action": {
"service": "test.automation",
"data_template": {
"some": "{{ trigger.platform }} - {{ trigger.now.strftime('%A') }}",
},
},
}
},
)
await hass.async_block_till_done()
# Fire on Monday - should trigger (Monday is in workdays)
async_fire_time_changed(hass, monday_trigger + timedelta(seconds=1))
await hass.async_block_till_done()
automation_calls = [call for call in service_calls if call.domain == "test"]
assert len(automation_calls) == 1
assert "Monday" in automation_calls[0].data["some"]
# Fire on Tuesday - should trigger (Tuesday is in workdays)
tuesday_trigger = dt_util.as_utc(datetime(2023, 1, 3, 5, 0, 0, 0))
async_fire_time_changed(hass, tuesday_trigger)
await hass.async_block_till_done()
automation_calls = [call for call in service_calls if call.domain == "test"]
assert len(automation_calls) == 2
assert "Tuesday" in automation_calls[1].data["some"]
# Fire on Thursday - should not trigger (Thursday is not in workdays)
thursday_trigger = dt_util.as_utc(datetime(2023, 1, 5, 5, 0, 0, 0))
async_fire_time_changed(hass, thursday_trigger)
await hass.async_block_till_done()
automation_calls = [call for call in service_calls if call.domain == "test"]
assert len(automation_calls) == 2
# Fire on Saturday - should not trigger (Saturday is not in workdays)
saturday_trigger = dt_util.as_utc(datetime(2023, 1, 7, 5, 0, 0, 0))
async_fire_time_changed(hass, saturday_trigger)
await hass.async_block_till_done()
automation_calls = [call for call in service_calls if call.domain == "test"]
assert len(automation_calls) == 2
async def test_if_action_weekday_input_weekday_entity(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
"""Test time condition with input_weekday entity."""
# Setup input_weekday helper with Sat, Sun
await async_setup_component(
hass,
"input_weekday",
{
"input_weekday": {
"weekend": {"name": "Weekend Days", "weekdays": ["sat", "sun"]}
}
},
)
await hass.async_block_till_done()
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {"platform": "event", "event_type": "test_event"},
"condition": {"condition": "time", "weekday": "input_weekday.weekend"},
"action": {"service": "test.automation"},
}
},
)
await hass.async_block_till_done()
days_past_monday = dt_util.now().weekday()
monday = dt_util.now() - timedelta(days=days_past_monday)
saturday = monday + timedelta(days=5)
sunday = saturday + timedelta(days=1)
# Test on Monday - should not trigger (not in weekend)
with patch("homeassistant.helpers.condition.dt_util.now", return_value=monday):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 0
# Test on Saturday - should trigger
with patch("homeassistant.helpers.condition.dt_util.now", return_value=saturday):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 1
# Test on Sunday - should trigger
with patch("homeassistant.helpers.condition.dt_util.now", return_value=sunday):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
assert len(service_calls) == 2
async def test_if_fires_weekday_entity_unavailable(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
service_calls: list[ServiceCall],
) -> None:
"""Test that trigger does not fire when input_weekday entity is unavailable."""
# Freeze time to Monday, January 2, 2023 at 5:00:00
monday_trigger = dt_util.as_utc(datetime(2023, 1, 2, 5, 0, 0, 0))
freezer.move_to(monday_trigger)
assert await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
"platform": "time",
"at": "5:00:00",
"weekday": "input_weekday.nonexistent",
},
"action": {
"service": "test.automation",
},
}
},
)
await hass.async_block_till_done()
# Fire on Monday - should not trigger (entity doesn't exist)
async_fire_time_changed(hass, monday_trigger + timedelta(seconds=1))
await hass.async_block_till_done()
automation_calls = [call for call in service_calls if call.domain == "test"]
assert len(automation_calls) == 0

View File

@@ -0,0 +1 @@
"""Tests for the Input Weekday component."""

View File

@@ -0,0 +1,518 @@
"""Tests for the Input Weekday component."""
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components.input_weekday import (
ATTR_WEEKDAY,
ATTR_WEEKDAYS,
DOMAIN,
SERVICE_ADD_WEEKDAY,
SERVICE_CLEAR,
SERVICE_REMOVE_WEEKDAY,
SERVICE_SET_WEEKDAYS,
SERVICE_TOGGLE_WEEKDAY,
STORAGE_VERSION,
)
from homeassistant.const import (
ATTR_EDITABLE,
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import mock_restore_cache
from tests.typing import WebSocketGenerator
@pytest.fixture
def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]):
"""Storage setup."""
async def _storage(items=None, config=None):
if items is None:
hass_storage[DOMAIN] = {
"key": DOMAIN,
"version": STORAGE_VERSION,
"data": {
"items": [
{
"id": "from_storage",
"name": "from storage",
"weekdays": ["mon", "wed", "fri"],
}
]
},
}
else:
hass_storage[DOMAIN] = {
"key": DOMAIN,
"version": STORAGE_VERSION,
"data": {"items": items},
}
if config is None:
config = {DOMAIN: {}}
return await async_setup_component(hass, DOMAIN, config)
return _storage
@pytest.mark.parametrize(
"invalid_config",
[
None,
{"name with space": None},
{"bad_weekdays": {"weekdays": ["invalid"]}},
],
)
async def test_config(hass: HomeAssistant, invalid_config) -> None:
"""Test config."""
assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config})
async def test_set_weekdays(hass: HomeAssistant) -> None:
"""Test set_weekdays service."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["mon", "tue"]}}},
)
entity_id = "input_weekday.test_1"
state = hass.states.get(entity_id)
assert state.state == "mon,tue"
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "tue"]
await hass.services.async_call(
DOMAIN,
SERVICE_SET_WEEKDAYS,
{ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAYS: ["wed", "thu", "fri"]},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == "wed,thu,fri"
assert state.attributes[ATTR_WEEKDAYS] == ["wed", "thu", "fri"]
async def test_set_weekdays_removes_duplicates(hass: HomeAssistant) -> None:
"""Test set_weekdays removes duplicate weekdays."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": []}}},
)
entity_id = "input_weekday.test_1"
await hass.services.async_call(
DOMAIN,
SERVICE_SET_WEEKDAYS,
{ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAYS: ["mon", "tue", "mon", "wed"]},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "tue", "wed"]
async def test_add_weekday(hass: HomeAssistant) -> None:
"""Test add_weekday service."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["mon"]}}},
)
entity_id = "input_weekday.test_1"
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon"]
await hass.services.async_call(
DOMAIN,
SERVICE_ADD_WEEKDAY,
{ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "wed"},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed"]
# Adding duplicate should not add it again
await hass.services.async_call(
DOMAIN,
SERVICE_ADD_WEEKDAY,
{ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "mon"},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed"]
async def test_remove_weekday(hass: HomeAssistant) -> None:
"""Test remove_weekday service."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["mon", "wed", "fri"]}}},
)
entity_id = "input_weekday.test_1"
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed", "fri"]
await hass.services.async_call(
DOMAIN,
SERVICE_REMOVE_WEEKDAY,
{ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "wed"},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "fri"]
# Removing non-existent weekday should not error
await hass.services.async_call(
DOMAIN,
SERVICE_REMOVE_WEEKDAY,
{ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "wed"},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "fri"]
async def test_toggle_weekday(hass: HomeAssistant) -> None:
"""Test toggle_weekday service."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["mon"]}}},
)
entity_id = "input_weekday.test_1"
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon"]
# Toggle off (remove)
await hass.services.async_call(
DOMAIN,
SERVICE_TOGGLE_WEEKDAY,
{ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "mon"},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == []
# Toggle on (add)
await hass.services.async_call(
DOMAIN,
SERVICE_TOGGLE_WEEKDAY,
{ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "tue"},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["tue"]
async def test_clear(hass: HomeAssistant) -> None:
"""Test clear service."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["mon", "wed", "fri"]}}},
)
entity_id = "input_weekday.test_1"
state = hass.states.get(entity_id)
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed", "fri"]
await hass.services.async_call(
DOMAIN,
SERVICE_CLEAR,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == ""
assert state.attributes[ATTR_WEEKDAYS] == []
async def test_config_with_name(hass: HomeAssistant) -> None:
"""Test configuration with name."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"name": "Test Weekday", "weekdays": ["sat", "sun"]}}},
)
state = hass.states.get("input_weekday.test_1")
assert state is not None
assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Weekday"
assert state.attributes[ATTR_WEEKDAYS] == ["sat", "sun"]
async def test_empty_weekdays(hass: HomeAssistant) -> None:
"""Test empty weekdays configuration."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": []}}},
)
state = hass.states.get("input_weekday.test_1")
assert state is not None
assert state.state == ""
assert state.attributes[ATTR_WEEKDAYS] == []
async def test_default_weekdays(hass: HomeAssistant) -> None:
"""Test default weekdays (empty list)."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {}}},
)
state = hass.states.get("input_weekday.test_1")
assert state is not None
assert state.state == ""
assert state.attributes[ATTR_WEEKDAYS] == []
async def test_config_removes_duplicates(hass: HomeAssistant) -> None:
"""Test that configuration removes duplicate weekdays."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["mon", "tue", "mon", "wed"]}}},
)
state = hass.states.get("input_weekday.test_1")
assert state is not None
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "tue", "wed"]
async def test_reload(hass: HomeAssistant) -> None:
"""Test reload service."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["mon"]}}},
)
state_1 = hass.states.get("input_weekday.test_1")
state_2 = hass.states.get("input_weekday.test_2")
assert state_1 is not None
assert state_2 is None
assert state_1.attributes[ATTR_WEEKDAYS] == ["mon"]
with patch(
"homeassistant.config.load_yaml_config_file",
return_value={
DOMAIN: {
"test_2": {"weekdays": ["tue", "thu"]},
}
},
):
await hass.services.async_call(
DOMAIN,
SERVICE_RELOAD,
blocking=True,
)
await hass.async_block_till_done()
state_1 = hass.states.get("input_weekday.test_1")
state_2 = hass.states.get("input_weekday.test_2")
assert state_1 is None
assert state_2 is not None
assert state_2.attributes[ATTR_WEEKDAYS] == ["tue", "thu"]
async def test_state_restoration(hass: HomeAssistant) -> None:
"""Test state restoration."""
mock_restore_cache(
hass,
(
State(
"input_weekday.test_1",
"mon,wed,fri",
{ATTR_WEEKDAYS: ["mon", "wed", "fri"]},
),
),
)
hass.state = "starting"
await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {}}},
)
state = hass.states.get("input_weekday.test_1")
assert state
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed", "fri"]
async def test_state_restoration_with_initial(hass: HomeAssistant) -> None:
"""Test state restoration with initial value - should prefer initial."""
mock_restore_cache(
hass,
(
State(
"input_weekday.test_1",
"mon,wed,fri",
{ATTR_WEEKDAYS: ["mon", "wed", "fri"]},
),
),
)
hass.state = "starting"
await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["sat", "sun"]}}},
)
state = hass.states.get("input_weekday.test_1")
assert state
assert state.attributes[ATTR_WEEKDAYS] == ["sat", "sun"]
async def test_storage(hass: HomeAssistant, storage_setup) -> None:
"""Test storage."""
assert await storage_setup()
state = hass.states.get("input_weekday.from_storage")
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed", "fri"]
assert state.attributes[ATTR_EDITABLE]
async def test_editable_state_attribute(hass: HomeAssistant) -> None:
"""Test editable attribute."""
assert await async_setup_component(
hass,
DOMAIN,
{DOMAIN: {"test_1": {"weekdays": ["mon"]}}},
)
state = hass.states.get("input_weekday.test_1")
assert state.attributes[ATTR_EDITABLE] is False
async def test_websocket_create(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test create via websocket."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 1,
"type": f"{DOMAIN}/create",
"name": "My Weekday",
"weekdays": ["mon", "fri"],
}
)
resp = await client.receive_json()
assert resp["success"]
state = hass.states.get("input_weekday.my_weekday")
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "fri"]
async def test_websocket_update(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test update via websocket."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 1,
"type": f"{DOMAIN}/create",
"name": "My Weekday",
"weekdays": ["mon"],
}
)
resp = await client.receive_json()
assert resp["success"]
state = hass.states.get("input_weekday.my_weekday")
assert state.attributes[ATTR_WEEKDAYS] == ["mon"]
entity_entry = entity_registry.async_get("input_weekday.my_weekday")
await client.send_json(
{
"id": 2,
"type": f"{DOMAIN}/update",
f"{DOMAIN}_id": entity_entry.unique_id,
"weekdays": ["tue", "wed"],
"name": "Updated Weekday",
}
)
resp = await client.receive_json()
assert resp["success"]
state = hass.states.get("input_weekday.my_weekday")
assert state.attributes[ATTR_WEEKDAYS] == ["tue", "wed"]
assert state.attributes[ATTR_FRIENDLY_NAME] == "Updated Weekday"
async def test_websocket_delete(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
entity_registry: er.EntityRegistry,
) -> None:
"""Test delete via websocket."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
client = await hass_ws_client(hass)
await client.send_json(
{
"id": 1,
"type": f"{DOMAIN}/create",
"name": "My Weekday",
"weekdays": ["mon"],
}
)
resp = await client.receive_json()
assert resp["success"]
state = hass.states.get("input_weekday.my_weekday")
assert state is not None
entity_entry = entity_registry.async_get("input_weekday.my_weekday")
await client.send_json(
{
"id": 2,
"type": f"{DOMAIN}/delete",
f"{DOMAIN}_id": entity_entry.unique_id,
}
)
resp = await client.receive_json()
assert resp["success"]
state = hass.states.get("input_weekday.my_weekday")
assert state is None

View File

@@ -0,0 +1,37 @@
"""Tests for the Input Weekday recorder."""
from homeassistant.components.input_weekday import ATTR_EDITABLE, ATTR_WEEKDAYS
from homeassistant.components.recorder import Recorder
from homeassistant.components.recorder.history import get_significant_states
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.components.recorder.common import async_wait_recording_done
async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None:
"""Test that certain attributes are excluded."""
now = dt_util.utcnow()
assert await async_setup_component(
hass,
"input_weekday",
{"input_weekday": {"test": {"weekdays": ["mon", "wed"]}}},
)
state = hass.states.get("input_weekday.test")
assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed"]
assert state.attributes[ATTR_EDITABLE] is False
await async_wait_recording_done(hass)
states = await hass.async_add_executor_job(
get_significant_states, hass, now, None, ["input_weekday.test"]
)
assert len(states) == 1
for entity_states in states.values():
for state in entity_states:
assert ATTR_WEEKDAYS in state.attributes
assert ATTR_EDITABLE not in state.attributes
assert ATTR_FRIENDLY_NAME in state.attributes

View File

@@ -0,0 +1,59 @@
"""Test reproduce state for Input Weekday."""
import pytest
from homeassistant.components.input_weekday import ATTR_WEEKDAYS, DOMAIN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.state import async_reproduce_state
from homeassistant.setup import async_setup_component
from tests.common import async_mock_service
@pytest.fixture
async def setup_component(hass: HomeAssistant):
"""Set up component."""
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {"test_weekday": {"weekdays": []}}}
)
async def test_reproduce_weekday(hass: HomeAssistant) -> None:
"""Test reproduce weekday."""
calls = async_mock_service(hass, DOMAIN, "set_weekdays")
await async_reproduce_state(
hass,
[
State(
"input_weekday.test_weekday",
"mon,wed,fri",
{ATTR_WEEKDAYS: ["mon", "wed", "fri"]},
)
],
)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0].data == {
"entity_id": "input_weekday.test_weekday",
ATTR_WEEKDAYS: ["mon", "wed", "fri"],
}
async def test_reproduce_weekday_missing_attribute(
hass: HomeAssistant, setup_component, caplog: pytest.LogCaptureFixture
) -> None:
"""Test reproduce weekday with missing weekdays attribute."""
calls = async_mock_service(hass, DOMAIN, "set_weekdays")
await async_reproduce_state(
hass,
[State("input_weekday.test_weekday", "mon,wed")],
)
await hass.async_block_till_done()
assert len(calls) == 0
assert "weekdays attribute is missing" in caplog.text

View File

@@ -45,7 +45,7 @@ def dimmable_light() -> NHCLight:
mock.is_dimmable = True
mock.name = "dimmable light"
mock.suggested_area = "room"
mock.state = 255
mock.state = 100
return mock

View File

@@ -41,7 +41,7 @@
# name: test_entities[light.dimmable_light-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': 255,
'brightness': 100,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'friendly_name': 'dimmable light',
'supported_color_modes': list([

View File

@@ -42,7 +42,7 @@ async def test_entities(
@pytest.mark.parametrize(
("light_id", "data", "set_brightness"),
[
(0, {ATTR_ENTITY_ID: "light.light"}, 255),
(0, {ATTR_ENTITY_ID: "light.light"}, None),
(
1,
{ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50},
@@ -119,7 +119,7 @@ async def test_updating(
assert hass.states.get("light.light").state == STATE_OFF
assert hass.states.get("light.dimmable_light").state == STATE_ON
assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 255
assert hass.states.get("light.dimmable_light").attributes[ATTR_BRIGHTNESS] == 100
dimmable_light.state = 204
await find_update_callback(mock_niko_home_control_connection, 2)(204)

View File

@@ -4,7 +4,6 @@ from collections.abc import Generator
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from pynintendoparental.device import Device
import pytest
from homeassistant.components.nintendo_parental.const import DOMAIN
@@ -24,18 +23,6 @@ def mock_config_entry() -> MockConfigEntry:
)
@pytest.fixture
def mock_nintendo_device() -> Device:
"""Return a mocked device."""
mock = AsyncMock(spec=Device)
mock.device_id = "testdevid"
mock.name = "Home Assistant Test"
mock.extra = {"device": {"firmwareVersion": {"displayedVersion": "99.99.99"}}}
mock.limit_time = 120
mock.today_playing_time = 110
return mock
@pytest.fixture
def mock_nintendo_authenticator() -> Generator[MagicMock]:
"""Mock Nintendo Authenticator."""
@@ -66,27 +53,6 @@ def mock_nintendo_authenticator() -> Generator[MagicMock]:
yield mock_auth
@pytest.fixture
def mock_nintendo_client(
mock_nintendo_device: Device,
) -> Generator[AsyncMock]:
"""Mock a Nintendo client."""
with (
patch(
"homeassistant.components.nintendo_parental.NintendoParental",
autospec=True,
) as mock_client,
patch(
"homeassistant.components.nintendo_parental.config_flow.NintendoParental",
new=mock_client,
),
):
client = mock_client.return_value
client.update.return_value = True
client.devices.return_value = {"testdevid": mock_nintendo_device}
yield client
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""

View File

@@ -77,7 +77,7 @@ async def test_invalid_auth(
# Simulate invalid authentication by raising an exception
mock_nintendo_authenticator.complete_login.side_effect = (
InvalidSessionTokenException
InvalidSessionTokenException(status_code=401, message="Test")
)
result = await hass.config_entries.flow.async_configure(

View File

@@ -157,6 +157,7 @@ def create_function_tool_call_item(
ResponseFunctionCallArgumentsDoneEvent(
arguments="".join(arguments),
item_id=id,
name=name,
output_index=output_index,
sequence_number=0,
type="response.function_call_arguments.done",

View File

@@ -569,7 +569,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: ["assist"],
CONF_TEMPERATURE: 0.8,
CONF_CHAT_MODEL: "o5",
CONF_CHAT_MODEL: "gpt-5",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_REASONING_EFFORT: "low",
@@ -607,6 +607,52 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CODE_INTERPRETER: False,
},
),
( # Case 5: code interpreter supported to not supported model
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: ["assist"],
CONF_TEMPERATURE: 0.8,
CONF_CHAT_MODEL: "gpt-5",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_REASONING_EFFORT: "low",
CONF_CODE_INTERPRETER: True,
CONF_VERBOSITY: "medium",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
CONF_WEB_SEARCH_USER_LOCATION: False,
},
(
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
},
{
CONF_TEMPERATURE: 0.8,
CONF_CHAT_MODEL: "gpt-5-pro",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
},
{
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
CONF_WEB_SEARCH_USER_LOCATION: False,
},
),
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: 0.8,
CONF_CHAT_MODEL: "gpt-5-pro",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_VERBOSITY: "medium",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
CONF_WEB_SEARCH_USER_LOCATION: False,
},
),
],
)
async def test_subentry_switching(

View File

@@ -474,7 +474,7 @@ async def test_web_search(
assert mock_create_stream.mock_calls[0][2]["tools"] == [
{
"type": "web_search_preview",
"type": "web_search",
"search_context_size": "low",
"user_location": {
"type": "approximate",

View File

@@ -80,7 +80,9 @@ async def test_low_battery(hass: HomeAssistant) -> None:
async def test_initial_states(hass: HomeAssistant) -> None:
"""Test plant initialises attributes if sensor already exists."""
hass.states.async_set(
MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}
MOISTURE_ENTITY,
5,
{ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM},
)
plant_name = "some_plant"
assert await async_setup_component(
@@ -101,7 +103,9 @@ async def test_update_states(hass: HomeAssistant) -> None:
hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}}
)
hass.states.async_set(
MOISTURE_ENTITY, 5, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}
MOISTURE_ENTITY,
5,
{ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM},
)
await hass.async_block_till_done()
state = hass.states.get(f"plant.{plant_name}")
@@ -121,7 +125,7 @@ async def test_unavailable_state(hass: HomeAssistant) -> None:
hass.states.async_set(
MOISTURE_ENTITY,
STATE_UNAVAILABLE,
{ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS},
{ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM},
)
await hass.async_block_till_done()
state = hass.states.get(f"plant.{plant_name}")
@@ -139,7 +143,9 @@ async def test_state_problem_if_unavailable(hass: HomeAssistant) -> None:
hass, plant.DOMAIN, {plant.DOMAIN: {plant_name: GOOD_CONFIG}}
)
hass.states.async_set(
MOISTURE_ENTITY, 42, {ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS}
MOISTURE_ENTITY,
42,
{ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM},
)
await hass.async_block_till_done()
state = hass.states.get(f"plant.{plant_name}")
@@ -148,7 +154,7 @@ async def test_state_problem_if_unavailable(hass: HomeAssistant) -> None:
hass.states.async_set(
MOISTURE_ENTITY,
STATE_UNAVAILABLE,
{ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS},
{ATTR_UNIT_OF_MEASUREMENT: UnitOfConductivity.MICROSIEMENS_PER_CM},
)
await hass.async_block_till_done()
state = hass.states.get(f"plant.{plant_name}")

View File

@@ -0,0 +1,241 @@
# serializer version: 1
# name: test_all_entities[sensor.focused_einstein_image-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.focused_einstein_image',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Image',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'image',
'unique_id': 'portainer_test_entry_123_focused_einstein_image',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.focused_einstein_image-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'focused_einstein Image',
}),
'context': <ANY>,
'entity_id': 'sensor.focused_einstein_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'docker.io/library/redis:7',
})
# ---
# name: test_all_entities[sensor.funny_chatelet_image-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.funny_chatelet_image',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Image',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'image',
'unique_id': 'portainer_test_entry_123_funny_chatelet_image',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.funny_chatelet_image-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'funny_chatelet Image',
}),
'context': <ANY>,
'entity_id': 'sensor.funny_chatelet_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'docker.io/library/ubuntu:latest',
})
# ---
# name: test_all_entities[sensor.practical_morse_image-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.practical_morse_image',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Image',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'image',
'unique_id': 'portainer_test_entry_123_practical_morse_image',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.practical_morse_image-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'practical_morse Image',
}),
'context': <ANY>,
'entity_id': 'sensor.practical_morse_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'docker.io/library/python:3.13-slim',
})
# ---
# name: test_all_entities[sensor.serene_banach_image-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.serene_banach_image',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Image',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'image',
'unique_id': 'portainer_test_entry_123_serene_banach_image',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.serene_banach_image-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'serene_banach Image',
}),
'context': <ANY>,
'entity_id': 'sensor.serene_banach_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'docker.io/library/nginx:latest',
})
# ---
# name: test_all_entities[sensor.stoic_turing_image-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.stoic_turing_image',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Image',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'image',
'unique_id': 'portainer_test_entry_123_stoic_turing_image',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[sensor.stoic_turing_image-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'stoic_turing Image',
}),
'context': <ANY>,
'entity_id': 'sensor.stoic_turing_image',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'docker.io/library/postgres:15',
})
# ---

View File

@@ -0,0 +1,32 @@
"""Tests for the Portainer sensor platform."""
from unittest.mock import patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("mock_portainer_client")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
with patch(
"homeassistant.components.portainer._PLATFORMS",
[Platform.SENSOR],
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)

View File

@@ -1785,7 +1785,7 @@ async def test_unit_conversion_priority_suggested_unit_change_2(
UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER,
0,
),
(SensorDeviceClass.CONDUCTIVITY, UnitOfConductivity.MICROSIEMENS, 1),
(SensorDeviceClass.CONDUCTIVITY, UnitOfConductivity.MICROSIEMENS_PER_CM, 1),
(SensorDeviceClass.CURRENT, UnitOfElectricCurrent.MILLIAMPERE, 0),
(SensorDeviceClass.DATA_RATE, UnitOfDataRate.KILOBITS_PER_SECOND, 0),
(SensorDeviceClass.DATA_SIZE, UnitOfInformation.KILOBITS, 0),

View File

@@ -127,7 +127,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-button:200',
'unique_id': '123456789ABC-button:200-button_generic',
'unit_of_measurement': None,
})
# ---
@@ -175,7 +175,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '123456789ABC-button:200',
'unique_id': '123456789ABC-button:200-button_generic',
'unit_of_measurement': None,
})
# ---

View File

@@ -9,7 +9,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.components.shelly.const import DOMAIN
from homeassistant.components.shelly.const import DOMAIN, MODEL_FRANKEVER_WATER_VALVE
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
@@ -17,7 +17,13 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceRegistry
from homeassistant.helpers.entity_registry import EntityRegistry
from . import init_integration, patch_platforms, register_device, register_entity
from . import (
MOCK_MAC,
init_integration,
patch_platforms,
register_device,
register_entity,
)
@pytest.fixture(autouse=True)
@@ -417,3 +423,56 @@ async def test_migrate_unique_id_blu_trv(
assert entity_entry.unique_id == "F8447725F0DD-blutrv:200-calibrate"
assert "Migrating unique_id for button.trv_name_calibrate" in caplog.text
@pytest.mark.parametrize(
("old_id", "new_id", "role"),
[
("button", "button_generic", None),
("button", "button_open", "open"),
("button", "button_close", "close"),
],
)
async def test_migrate_unique_id_virtual_components_roles(
hass: HomeAssistant,
mock_rpc_device: Mock,
entity_registry: EntityRegistry,
caplog: pytest.LogCaptureFixture,
monkeypatch: pytest.MonkeyPatch,
old_id: str,
new_id: str,
role: str | None,
) -> None:
"""Test migration of unique_id for virtual components to include role."""
entry = await init_integration(
hass, 3, model=MODEL_FRANKEVER_WATER_VALVE, skip_setup=True
)
old_unique_id = f"{MOCK_MAC}-{old_id}:200"
new_unique_id = f"{old_unique_id}-{new_id}"
config = deepcopy(mock_rpc_device.config)
if role:
config[f"{old_id}:200"] = {
"role": role,
}
else:
config[f"{old_id}:200"] = {}
monkeypatch.setattr(mock_rpc_device, "config", config)
entity = entity_registry.async_get_or_create(
suggested_object_id="test_name_test_button",
disabled_by=None,
domain=BUTTON_DOMAIN,
platform=DOMAIN,
unique_id=old_unique_id,
config_entry=entry,
)
assert entity.unique_id == old_unique_id
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_entry = entity_registry.async_get("button.test_name_test_button")
assert entity_entry
assert entity_entry.unique_id == new_unique_id
assert "Migrating unique_id for button.test_name_test_button" in caplog.text

View File

@@ -404,6 +404,7 @@ async def test_rpc_device_services(
)
assert (state := hass.states.get(entity_id))
assert state.state == STATE_ON
mock_rpc_device.switch_set.assert_called_once_with(0, True)
monkeypatch.setitem(mock_rpc_device.status["switch:0"], "output", False)
await hass.services.async_call(
@@ -415,6 +416,7 @@ async def test_rpc_device_services(
mock_rpc_device.mock_update()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OFF
mock_rpc_device.switch_set.assert_called_with(0, False)
async def test_rpc_device_unique_ids(
@@ -507,7 +509,7 @@ async def test_rpc_set_state_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test RPC device set state connection/call errors."""
monkeypatch.setattr(mock_rpc_device, "call_rpc", AsyncMock(side_effect=exc))
mock_rpc_device.switch_set.side_effect = exc
monkeypatch.delitem(mock_rpc_device.status, "cover:0")
monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False)
await init_integration(hass, 2)
@@ -525,11 +527,7 @@ async def test_rpc_auth_error(
hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test RPC device set state authentication error."""
monkeypatch.setattr(
mock_rpc_device,
"call_rpc",
AsyncMock(side_effect=InvalidAuthError),
)
mock_rpc_device.switch_set.side_effect = InvalidAuthError
monkeypatch.delitem(mock_rpc_device.status, "cover:0")
monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False)
entry = await init_integration(hass, 2)
@@ -657,6 +655,7 @@ async def test_rpc_device_virtual_switch(
mock_rpc_device.mock_update()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OFF
mock_rpc_device.boolean_set.assert_called_once_with(200, False)
monkeypatch.setitem(mock_rpc_device.status["boolean:200"], "value", True)
await hass.services.async_call(
@@ -668,6 +667,7 @@ async def test_rpc_device_virtual_switch(
mock_rpc_device.mock_update()
assert (state := hass.states.get(entity_id))
assert state.state == STATE_ON
mock_rpc_device.boolean_set.assert_called_with(200, True)
@pytest.mark.usefixtures("disable_async_remove_shelly_rpc_entities")
@@ -815,6 +815,7 @@ async def test_rpc_device_script_switch(
assert (state := hass.states.get(entity_id))
assert state.state == STATE_OFF
mock_rpc_device.script_stop.assert_called_once_with(1)
monkeypatch.setitem(mock_rpc_device.status[key], "running", True)
await hass.services.async_call(
@@ -827,3 +828,4 @@ async def test_rpc_device_script_switch(
assert (state := hass.states.get(entity_id))
assert state.state == STATE_ON
mock_rpc_device.script_start.assert_called_once_with(1)

View File

@@ -230,7 +230,7 @@ DEVICE_MOCKS = [
"wg2_tmwhss6ntjfc7prs", # https://github.com/home-assistant/core/issues/150662
"wg2_v7owd9tzcaninc36", # https://github.com/orgs/home-assistant/discussions/539
"wk_6kijc7nd", # https://github.com/home-assistant/core/issues/136513
"wk_IAYz2WK1th0cMLmL", # https://github.com/orgs/home-assistant/discussions/842
"wk_IAYz2WK1th0cMLmL", # https://github.com/home-assistant/core/issues/150077
"wk_aqoouq7x", # https://github.com/home-assistant/core/issues/146263
"wk_ccpwojhalfxryigz", # https://github.com/home-assistant/core/issues/145551
"wk_cpmgn2cf", # https://github.com/orgs/home-assistant/discussions/684

View File

@@ -10,9 +10,9 @@
"online": true,
"sub": false,
"time_zone": "+01:00",
"active_time": "2018-12-04T17:50:07+00:00",
"create_time": "2018-12-04T17:50:07+00:00",
"update_time": "2025-09-03T07:44:16+00:00",
"active_time": "2022-11-15T08:35:43+00:00",
"create_time": "2022-11-15T08:35:43+00:00",
"update_time": "2022-11-15T08:35:43+00:00",
"function": {
"switch": {
"type": "Boolean",
@@ -22,6 +22,16 @@
"type": "Boolean",
"value": {}
},
"temp_set": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 10,
"max": 70,
"scale": 1,
"step": 5
}
},
"eco": {
"type": "Boolean",
"value": {}
@@ -35,26 +45,14 @@
"scale": 0,
"step": 5
}
}
},
"status_range": {
"eco": {
"type": "Boolean",
"value": {}
},
"Mode": {
"type": "Enum",
"value": {
"range": ["0", "1"]
}
},
"program": {
"type": "Raw",
"value": {
"maxlen": 128
}
},
"tempSwitch": {
"type": "Enum",
"value": {
"range": ["0", "1"]
}
},
"TempSet": {
"temp_set": {
"type": "Integer",
"value": {
"unit": "\u2103",
@@ -63,12 +61,6 @@
"scale": 1,
"step": 5
}
}
},
"status_range": {
"eco": {
"type": "Boolean",
"value": {}
},
"switch": {
"type": "Boolean",
@@ -87,43 +79,14 @@
"scale": 0,
"step": 5
}
},
"floorTemp": {
"type": "Integer",
"value": {
"max": 198,
"min": 0,
"scale": 0,
"step": 5,
"unit": "\u2103"
}
},
"floortempFunction": {
"type": "Boolean",
"value": {}
},
"TempSet": {
"type": "Integer",
"value": {
"unit": "\u2103",
"min": 10,
"max": 70,
"scale": 1,
"step": 5
}
}
},
"status": {
"switch": false,
"upper_temp": 55,
"eco": true,
"child_lock": false,
"Mode": 1,
"program": "DwYoDwceHhQoORceOhceOxceAAkoAAoeHhQoORceOhceOxceAAkoAAoeHhQoORceOhceOxce",
"floorTemp": 0,
"tempSwitch": 0,
"floortempFunction": true,
"TempSet": 41
"switch": true,
"temp_set": 46,
"upper_temp": 45,
"eco": false,
"child_lock": true
},
"set_up": true,
"support_local": true

View File

@@ -383,9 +383,9 @@
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 35,
'min_temp': 7,
'target_temp_step': 1.0,
'max_temp': 7.0,
'min_temp': 1.0,
'target_temp_step': 0.5,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
@@ -410,7 +410,7 @@
'platform': 'tuya',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 384>,
'supported_features': <ClimateEntityFeature: 385>,
'translation_key': None,
'unique_id': 'tuya.LmLMc0ht1KW2zYAIkw',
'unit_of_measurement': None,
@@ -419,23 +419,24 @@
# name: test_platform_setup_and_discovery[climate.el_termostato_de_la_cocina-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 5.5,
'current_temperature': 4.5,
'friendly_name': 'El termostato de la cocina',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 35,
'min_temp': 7,
'supported_features': <ClimateEntityFeature: 384>,
'target_temp_step': 1.0,
'max_temp': 7.0,
'min_temp': 1.0,
'supported_features': <ClimateEntityFeature: 385>,
'target_temp_step': 0.5,
'temperature': 4.6,
}),
'context': <ANY>,
'entity_id': 'climate.el_termostato_de_la_cocina',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
'state': 'heat_cool',
})
# ---
# name: test_platform_setup_and_discovery[climate.empore-entry]

View File

@@ -3238,7 +3238,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
'state': 'on',
})
# ---
# name: test_platform_setup_and_discovery[switch.elivco_kitchen_socket_child_lock-entry]

View File

@@ -4,7 +4,7 @@
'attributes': ReadOnlyDict({
'entity_picture': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg',
'friendly_name': 'Google for Developers Latest upload',
'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=TzInfo(UTC)),
'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=TzInfo(0)),
'video_id': 'wysukDrMdqU',
}),
'context': <ANY>,

View File

@@ -952,6 +952,33 @@ async def test_zeroconf_discovery_via_socket_already_setup_with_ip_match(
assert result["reason"] == "single_instance_allowed"
@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True))
async def test_zeroconf_not_onboarded(hass: HomeAssistant) -> None:
"""Test zeroconf discovery needing confirmation when not onboarded."""
service_info = ZeroconfServiceInfo(
ip_address=ip_address("192.168.1.100"),
ip_addresses=[ip_address("192.168.1.100")],
hostname="tube-zigbee-gw.local.",
name="mock_name",
port=6638,
properties={"name": "tube_123456"},
type="mock_type",
)
with patch(
"homeassistant.components.onboarding.async_is_onboarded", return_value=False
):
result_create = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=service_info,
)
await hass.async_block_till_done()
# not automatically confirmed
assert result_create["type"] is FlowResultType.FORM
assert result_create["step_id"] == "confirm"
@patch(
"homeassistant.components.zha.radio_manager.ZhaRadioManager.detect_radio_type",
mock_detect_radio_type(radio_type=RadioType.deconz),

View File

@@ -22,7 +22,7 @@ from homeassistant.core import (
HomeAssistant,
callback,
)
from homeassistant.exceptions import TemplateError
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.helpers.event import (
@@ -4975,43 +4975,25 @@ async def test_async_track_state_report_change_event(hass: HomeAssistant) -> Non
}
async def test_async_track_template_no_hass_deprecated(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test async_track_template with a template without hass is deprecated."""
message = (
"Detected code that calls async_track_template_result with template without "
"hass. This will stop working in Home Assistant 2025.10, please "
"report this issue"
)
async def test_async_track_template_no_hass_fails(hass: HomeAssistant) -> None:
"""Test async_track_template with a template without hass now fails."""
message = "Calls async_track_template_result with template without hass"
async_track_template(hass, Template("blah"), lambda x, y, z: None)
assert message in caplog.text
caplog.clear()
with pytest.raises(HomeAssistantError, match=message):
async_track_template(hass, Template("blah"), lambda x, y, z: None)
async_track_template(hass, Template("blah", hass), lambda x, y, z: None)
assert message not in caplog.text
caplog.clear()
async def test_async_track_template_result_no_hass_deprecated(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test async_track_template_result with a template without hass is deprecated."""
message = (
"Detected code that calls async_track_template_result with template without "
"hass. This will stop working in Home Assistant 2025.10, please "
"report this issue"
)
async def test_async_track_template_result_no_hass_fails(hass: HomeAssistant) -> None:
"""Test async_track_template_result with a template without hass now fails."""
message = "Calls async_track_template_result with template without hass"
async_track_template_result(
hass, [TrackTemplate(Template("blah"), None)], lambda x, y, z: None
)
assert message in caplog.text
caplog.clear()
with pytest.raises(HomeAssistantError, match=message):
async_track_template_result(
hass, [TrackTemplate(Template("blah"), None)], lambda x, y, z: None
)
async_track_template_result(
hass, [TrackTemplate(Template("blah", hass), None)], lambda x, y, z: None
)
assert message not in caplog.text
caplog.clear()

View File

@@ -1,19 +1,12 @@
"""Test const module."""
from enum import Enum
import logging
import sys
from unittest.mock import Mock, patch
import pytest
from homeassistant import const
from .common import (
extract_stack_to_frame,
help_test_all,
import_and_test_deprecated_constant,
)
from .common import help_test_all, import_and_test_deprecated_constant
def _create_tuples(
@@ -48,78 +41,3 @@ def test_deprecated_constant_name_changes(
replacement,
breaks_in_version,
)
def test_deprecated_unit_of_conductivity_alias() -> None:
"""Test UnitOfConductivity deprecation."""
# Test the deprecated members are aliases
assert set(const.UnitOfConductivity) == {"S/cm", "μS/cm", "mS/cm"}
def test_deprecated_unit_of_conductivity_members(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test UnitOfConductivity deprecation."""
module_name = "config.custom_components.hue.light"
filename = f"/home/paulus/{module_name.replace('.', '/')}.py"
with (
patch.dict(sys.modules, {module_name: Mock(__file__=filename)}),
patch(
"homeassistant.helpers.frame.linecache.getline",
return_value="await session.close()",
),
patch(
"homeassistant.helpers.frame.get_current_frame",
return_value=extract_stack_to_frame(
[
Mock(
filename="/home/paulus/homeassistant/core.py",
lineno="23",
line="do_something()",
),
Mock(
filename=filename,
lineno="23",
line="await session.close()",
),
Mock(
filename="/home/paulus/aiohue/lights.py",
lineno="2",
line="something()",
),
]
),
),
):
const.UnitOfConductivity.SIEMENS # noqa: B018
const.UnitOfConductivity.MICROSIEMENS # noqa: B018
const.UnitOfConductivity.MILLISIEMENS # noqa: B018
assert len(caplog.record_tuples) == 3
def deprecation_message(member: str, replacement: str) -> str:
return (
f"The deprecated enum member UnitOfConductivity.{member} was used from hue. "
"It will be removed in HA Core 2025.11.0. Use UnitOfConductivity."
f"{replacement} instead, please report it to the author of the 'hue' custom"
" integration"
)
assert (
const.__name__,
logging.WARNING,
deprecation_message("SIEMENS", "SIEMENS_PER_CM"),
) in caplog.record_tuples
assert (
const.__name__,
logging.WARNING,
deprecation_message("MICROSIEMENS", "MICROSIEMENS_PER_CM"),
) in caplog.record_tuples
assert (
const.__name__,
logging.WARNING,
deprecation_message("MILLISIEMENS", "MILLISIEMENS_PER_CM"),
) in caplog.record_tuples

View File

@@ -281,48 +281,6 @@ _CONVERTED_VALUE: dict[
),
],
ConductivityConverter: [
# Deprecated to deprecated
(5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS),
(5, UnitOfConductivity.SIEMENS, 5e6, UnitOfConductivity.MICROSIEMENS),
(5, UnitOfConductivity.MILLISIEMENS, 5e3, UnitOfConductivity.MICROSIEMENS),
(5, UnitOfConductivity.MILLISIEMENS, 5e-3, UnitOfConductivity.SIEMENS),
(5e6, UnitOfConductivity.MICROSIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS),
(5e6, UnitOfConductivity.MICROSIEMENS, 5, UnitOfConductivity.SIEMENS),
# Deprecated to new
(5, UnitOfConductivity.SIEMENS, 5e3, UnitOfConductivity.MILLISIEMENS_PER_CM),
(5, UnitOfConductivity.SIEMENS, 5e6, UnitOfConductivity.MICROSIEMENS_PER_CM),
(
5,
UnitOfConductivity.MILLISIEMENS,
5e3,
UnitOfConductivity.MICROSIEMENS_PER_CM,
),
(5, UnitOfConductivity.MILLISIEMENS, 5e-3, UnitOfConductivity.SIEMENS_PER_CM),
(
5e6,
UnitOfConductivity.MICROSIEMENS,
5e3,
UnitOfConductivity.MILLISIEMENS_PER_CM,
),
(5e6, UnitOfConductivity.MICROSIEMENS, 5, UnitOfConductivity.SIEMENS_PER_CM),
# New to deprecated
(5, UnitOfConductivity.SIEMENS_PER_CM, 5e3, UnitOfConductivity.MILLISIEMENS),
(5, UnitOfConductivity.SIEMENS_PER_CM, 5e6, UnitOfConductivity.MICROSIEMENS),
(
5,
UnitOfConductivity.MILLISIEMENS_PER_CM,
5e3,
UnitOfConductivity.MICROSIEMENS,
),
(5, UnitOfConductivity.MILLISIEMENS_PER_CM, 5e-3, UnitOfConductivity.SIEMENS),
(
5e6,
UnitOfConductivity.MICROSIEMENS_PER_CM,
5e3,
UnitOfConductivity.MILLISIEMENS,
),
(5e6, UnitOfConductivity.MICROSIEMENS_PER_CM, 5, UnitOfConductivity.SIEMENS),
# New to new
(
5,
UnitOfConductivity.SIEMENS_PER_CM,