This commit is contained in:
Franck Nijhof 2023-12-13 18:48:43 +01:00 committed by GitHub
commit 711d9e21ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 1218 additions and 540 deletions

View File

@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@ -51,25 +51,6 @@ class AfterShipConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_import(self, config: dict[str, Any]) -> FlowResult:
"""Import configuration from yaml."""
try:
self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]})
except AbortFlow as err:
async_create_issue(
self.hass,
DOMAIN,
"deprecated_yaml_import_issue_already_configured",
breaks_in_ha_version="2024.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_already_configured",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "AfterShip",
},
)
raise err
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
@ -84,6 +65,8 @@ class AfterShipConfigFlow(ConfigFlow, domain=DOMAIN):
"integration_title": "AfterShip",
},
)
self._async_abort_entries_match({CONF_API_KEY: config[CONF_API_KEY]})
return self.async_create_entry(
title=config.get(CONF_NAME, "AfterShip"),
data={CONF_API_KEY: config[CONF_API_KEY]},

View File

@ -49,10 +49,6 @@
}
},
"issues": {
"deprecated_yaml_import_issue_already_configured": {
"title": "The {integration_title} YAML configuration import failed",
"description": "Configuring {integration_title} using YAML is being removed but the YAML configuration was already imported.\n\nRemove the YAML configuration and restart Home Assistant."
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The {integration_title} YAML configuration import failed",
"description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."

View File

@ -1304,13 +1304,14 @@ async def async_api_set_range(
service = None
data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id}
range_value = directive.payload["rangeValue"]
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
# Cover Position
if instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}":
range_value = int(range_value)
if range_value == 0:
if supported & cover.CoverEntityFeature.CLOSE and range_value == 0:
service = cover.SERVICE_CLOSE_COVER
elif range_value == 100:
elif supported & cover.CoverEntityFeature.OPEN and range_value == 100:
service = cover.SERVICE_OPEN_COVER
else:
service = cover.SERVICE_SET_COVER_POSITION
@ -1319,9 +1320,9 @@ async def async_api_set_range(
# Cover Tilt
elif instance == f"{cover.DOMAIN}.tilt":
range_value = int(range_value)
if range_value == 0:
if supported & cover.CoverEntityFeature.CLOSE_TILT and range_value == 0:
service = cover.SERVICE_CLOSE_COVER_TILT
elif range_value == 100:
elif supported & cover.CoverEntityFeature.OPEN_TILT and range_value == 100:
service = cover.SERVICE_OPEN_COVER_TILT
else:
service = cover.SERVICE_SET_COVER_TILT_POSITION
@ -1332,9 +1333,7 @@ async def async_api_set_range(
range_value = int(range_value)
if range_value == 0:
service = fan.SERVICE_TURN_OFF
else:
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported and fan.FanEntityFeature.SET_SPEED:
elif supported & fan.FanEntityFeature.SET_SPEED:
service = fan.SERVICE_SET_PERCENTAGE
data[fan.ATTR_PERCENTAGE] = range_value
else:

View File

@ -369,6 +369,7 @@ class PipelineStage(StrEnum):
STT = "stt"
INTENT = "intent"
TTS = "tts"
END = "end"
PIPELINE_STAGE_ORDER = [
@ -1024,7 +1025,6 @@ class PipelineRun:
)
)
if tts_input := tts_input.strip():
try:
# Synthesize audio and get URL
tts_media_id = tts_generate_media_source_id(
@ -1051,8 +1051,6 @@ class PipelineRun:
"media_id": tts_media_id,
**asdict(tts_media),
}
else:
tts_output = {}
self.process_event(
PipelineEvent(PipelineEventType.TTS_END, {"tts_output": tts_output})
@ -1345,7 +1343,11 @@ class PipelineInput:
self.conversation_id,
self.device_id,
)
if tts_input.strip():
current_stage = PipelineStage.TTS
else:
# Skip TTS
current_stage = PipelineStage.END
if self.run.end_stage != PipelineStage.INTENT:
# text-to-speech

View File

@ -25,6 +25,11 @@ from .const import (
)
from .coordinator import BlinkUpdateCoordinator
SERVICE_UPDATE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
}
)
SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]),
@ -152,7 +157,7 @@ def setup_services(hass: HomeAssistant) -> None:
# Register all the above services
service_mapping = [
(blink_refresh, SERVICE_REFRESH, None),
(blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA),
(
async_handle_save_video_service,
SERVICE_SAVE_VIDEO,

View File

@ -1,14 +1,28 @@
# Describes the format for available Blink services
blink_update:
trigger_camera:
target:
entity:
fields:
device_id:
required: true
selector:
device:
integration: blink
trigger_camera:
fields:
device_id:
required: true
selector:
device:
integration: blink
domain: camera
save_video:
fields:
device_id:
required: true
selector:
device:
integration: blink
name:
required: true
example: "Living Room"
@ -22,6 +36,11 @@ save_video:
save_recent_clips:
fields:
device_id:
required: true
selector:
device:
integration: blink
name:
required: true
example: "Living Room"
@ -35,6 +54,11 @@ save_recent_clips:
send_pin:
fields:
device_id:
required: true
selector:
device:
integration: blink
pin:
example: "abc123"
selector:

View File

@ -57,11 +57,23 @@
"services": {
"blink_update": {
"name": "Update",
"description": "Forces a refresh."
"description": "Forces a refresh.",
"fields": {
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
}
}
},
"trigger_camera": {
"name": "Trigger camera",
"description": "Requests camera to take new image."
"description": "Requests camera to take new image.",
"fields": {
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
}
}
},
"save_video": {
"name": "Save video",
@ -74,6 +86,10 @@
"filename": {
"name": "File name",
"description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)."
},
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
}
}
},
@ -88,6 +104,10 @@
"file_path": {
"name": "Output directory",
"description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)."
},
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
}
}
},
@ -98,6 +118,10 @@
"pin": {
"name": "Pin",
"description": "PIN received from blink. Leave empty if you only received a verification email."
},
"device_id": {
"name": "Device ID",
"description": "The Blink device id."
}
}
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/caldav",
"iot_class": "cloud_polling",
"loggers": ["caldav", "vobject"],
"requirements": ["caldav==1.3.6"]
"requirements": ["caldav==1.3.8"]
}

View File

@ -98,10 +98,7 @@ def _to_ics_fields(item: TodoItem) -> dict[str, Any]:
if status := item.status:
item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION")
if due := item.due:
if isinstance(due, datetime):
item_data["due"] = dt_util.as_utc(due).strftime("%Y%m%dT%H%M%SZ")
else:
item_data["due"] = due.strftime("%Y%m%d")
item_data["due"] = due
if description := item.description:
item_data["description"] = description
return item_data
@ -162,7 +159,10 @@ class WebDavTodoListEntity(TodoListEntity):
except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV lookup error: {err}") from err
vtodo = todo.icalendar_component # type: ignore[attr-defined]
vtodo.update(**_to_ics_fields(item))
updated_fields = _to_ics_fields(item)
if "due" in updated_fields:
todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined]
vtodo.update(**updated_fields)
try:
await self.hass.async_add_executor_job(
partial(

View File

@ -21,7 +21,7 @@ class GetTemperatureIntent(intent.IntentHandler):
"""Handle GetTemperature intents."""
intent_type = INTENT_GET_TEMPERATURE
slot_schema = {vol.Optional("area"): str}
slot_schema = {vol.Optional("area"): str, vol.Optional("name"): str}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
@ -49,6 +49,20 @@ class GetTemperatureIntent(intent.IntentHandler):
if climate_state is None:
raise intent.IntentHandleError(f"No climate entity in area {area_name}")
climate_entity = component.get_entity(climate_state.entity_id)
elif "name" in slots:
# Filter by name
entity_name = slots["name"]["value"]
for maybe_climate in intent.async_match_states(
hass, name=entity_name, domains=[DOMAIN]
):
climate_state = maybe_climate
break
if climate_state is None:
raise intent.IntentHandleError(f"No climate entity named {entity_name}")
climate_entity = component.get_entity(climate_state.entity_id)
else:
# First entity

View File

@ -144,7 +144,10 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not self._setup_complete:
await self._async_setup_and_authenticate()
self._async_mark_setup_complete()
return (await envoy.update()).raw
# dump all received data in debug mode to assist troubleshooting
envoy_data = await envoy.update()
_LOGGER.debug("Envoy data: %s", envoy_data)
return envoy_data.raw
except INVALID_AUTH_ERRORS as err:
if self._setup_complete and tries == 0:
# token likely expired or firmware changed, try to re-authenticate

View File

@ -35,8 +35,9 @@ CONFIG_SCHEMA = vol.Schema(
)
async def async_setup_platform(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Fast.com component. (deprecated)."""
if DOMAIN in config:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,

View File

@ -60,7 +60,10 @@ class FitbitOAuth2Implementation(AuthImplementation):
resp.raise_for_status()
except aiohttp.ClientResponseError as err:
if _LOGGER.isEnabledFor(logging.DEBUG):
error_body = await resp.text() if not session.closed else ""
try:
error_body = await resp.text()
except aiohttp.ClientError:
error_body = ""
_LOGGER.debug(
"Client response error status=%s, body=%s", err.status, error_body
)

View File

@ -9,5 +9,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
"requirements": ["pyhiveapi==0.5.14"]
"requirements": ["pyhiveapi==0.5.16"]
}

View File

@ -405,7 +405,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
raise AbortFlow("characteristic_missing") from err
except improv_ble_errors.CommandFailed:
raise
except Exception as err: # pylint: disable=broad-except
except Exception as err:
_LOGGER.exception("Unexpected exception")
raise AbortFlow("unknown") from err

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==6.1.0"]
"requirements": ["ical==6.1.1"]
}

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==6.1.0"]
"requirements": ["ical==6.1.1"]
}

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import enum
import logging
from time import localtime, strftime, time
from typing import Any
@ -151,6 +152,13 @@ async def async_setup_entry(
)
class LyricThermostatType(enum.Enum):
"""Lyric thermostats are classified as TCC or LCC devices."""
TCC = enum.auto()
LCC = enum.auto()
class LyricClimate(LyricDeviceEntity, ClimateEntity):
"""Defines a Honeywell Lyric climate entity."""
@ -201,8 +209,10 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
# Setup supported features
if device.changeableValues.thermostatSetpointStatus:
self._attr_supported_features = SUPPORT_FLAGS_LCC
self._attr_thermostat_type = LyricThermostatType.LCC
else:
self._attr_supported_features = SUPPORT_FLAGS_TCC
self._attr_thermostat_type = LyricThermostatType.TCC
# Setup supported fan modes
if device_fan_modes := device.settings.attributes.get("fan", {}).get(
@ -365,6 +375,16 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
"""Set hvac mode."""
_LOGGER.debug("HVAC mode: %s", hvac_mode)
try:
match self._attr_thermostat_type:
case LyricThermostatType.TCC:
await self._async_set_hvac_mode_tcc(hvac_mode)
case LyricThermostatType.LCC:
await self._async_set_hvac_mode_lcc(hvac_mode)
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
await self.coordinator.async_refresh()
async def _async_set_hvac_mode_tcc(self, hvac_mode: HVACMode) -> None:
if LYRIC_HVAC_MODES[hvac_mode] == LYRIC_HVAC_MODE_HEAT_COOL:
# If the system is off, turn it to Heat first then to Auto,
# otherwise it turns to.
@ -403,18 +423,21 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity):
self.location, self.device, autoChangeoverActive=True
)
else:
_LOGGER.debug(
"HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode]
)
_LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode])
await self._update_thermostat(
self.location,
self.device,
mode=LYRIC_HVAC_MODES[hvac_mode],
autoChangeoverActive=False,
)
except LYRIC_EXCEPTIONS as exception:
_LOGGER.error(exception)
await self.coordinator.async_refresh()
async def _async_set_hvac_mode_lcc(self, hvac_mode: HVACMode) -> None:
_LOGGER.debug("HVAC mode passed to lyric: %s", LYRIC_HVAC_MODES[hvac_mode])
await self._update_thermostat(
self.location,
self.device,
mode=LYRIC_HVAC_MODES[hvac_mode],
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset (PermanentHold, HoldUntil, NoHold, VacationHold) mode."""

View File

@ -27,6 +27,7 @@ async def async_setup_entry(
Alpha2IODeviceBatterySensor(coordinator, io_device_id)
for io_device_id, io_device in coordinator.data["io_devices"].items()
if io_device["_HEATAREA_ID"]
and io_device["_HEATAREA_ID"] in coordinator.data["heat_areas"]
)

View File

@ -25,7 +25,7 @@ async def async_setup_entry(
Alpha2HeatControlValveOpeningSensor(coordinator, heat_control_id)
for heat_control_id, heat_control in coordinator.data["heat_controls"].items()
if heat_control["INUSE"]
and heat_control["_HEATAREA_ID"]
and heat_control["_HEATAREA_ID"] in coordinator.data["heat_areas"]
and heat_control.get("ACTOR_PERCENT") is not None
)

View File

@ -247,7 +247,6 @@ async def async_check_config_schema(
schema(config)
except vol.Invalid as exc:
integration = await async_get_integration(hass, DOMAIN)
# pylint: disable-next=protected-access
message = conf_util.format_schema_error(
hass, exc, domain, config, integration.documentation
)

View File

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import Final
from typing import Final, Literal
from homeassistant.const import Platform
@ -36,6 +36,23 @@ ZEROCONF_MAP: Final[dict[str, str]] = {
"stretch": "Stretch",
}
NumberType = Literal[
"maximum_boiler_temperature",
"max_dhw_temperature",
"temperature_offset",
]
SelectType = Literal[
"select_dhw_mode",
"select_regulation_mode",
"select_schedule",
]
SelectOptionsType = Literal[
"dhw_modes",
"regulation_modes",
"available_schedules",
]
# Default directives
DEFAULT_MAX_TEMP: Final = 30
DEFAULT_MIN_TEMP: Final = 4

View File

@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["crcmod", "plugwise"],
"requirements": ["plugwise==0.34.5"],
"requirements": ["plugwise==0.35.3"],
"zeroconf": ["_plugwise._tcp.local."]
}

View File

@ -5,7 +5,6 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from plugwise import Smile
from plugwise.constants import NumberType
from homeassistant.components.number import (
NumberDeviceClass,
@ -18,7 +17,7 @@ from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DOMAIN, NumberType
from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity

View File

@ -5,7 +5,6 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from plugwise import Smile
from plugwise.constants import SelectOptionsType, SelectType
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
@ -13,7 +12,7 @@ from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .const import DOMAIN, SelectOptionsType, SelectType
from .coordinator import PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity

View File

@ -108,7 +108,10 @@
}
},
"select_schedule": {
"name": "Thermostat schedule"
"name": "Thermostat schedule",
"state": {
"off": "Off"
}
}
},
"sensor": {

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
"iot_class": "cloud_polling",
"requirements": ["pyschlage==2023.11.0"]
"requirements": ["pyschlage==2023.12.0"]
}

View File

@ -528,10 +528,10 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
def _determine_preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes."""
supported_modes = self._device.status.attributes[
supported_modes: list | None = self._device.status.attributes[
"supportedAcOptionalMode"
].value
if WINDFREE in supported_modes:
if supported_modes and WINDFREE in supported_modes:
return [WINDFREE]
return None

View File

@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["hatasmota"],
"mqtt": ["tasmota/discovery/#"],
"requirements": ["HATasmota==0.7.3"]
"requirements": ["HATasmota==0.8.0"]
}

View File

@ -112,8 +112,11 @@ class TasmotaAvailability(TasmotaEntity):
def __init__(self, **kwds: Any) -> None:
"""Initialize the availability mixin."""
self._available = False
super().__init__(**kwds)
if self._tasmota_entity.deep_sleep_enabled:
self._available = True
else:
self._available = False
async def async_added_to_hass(self) -> None:
"""Subscribe to MQTT events."""
@ -122,6 +125,8 @@ class TasmotaAvailability(TasmotaEntity):
async_subscribe_connection_status(self.hass, self.async_mqtt_connected)
)
await super().async_added_to_hass()
if self._tasmota_entity.deep_sleep_enabled:
await self._tasmota_entity.poll_status()
async def availability_updated(self, available: bool) -> None:
"""Handle updated availability."""
@ -135,6 +140,8 @@ class TasmotaAvailability(TasmotaEntity):
if not self.hass.is_stopping:
if not mqtt_connected(self.hass):
self._available = False
elif self._tasmota_entity.deep_sleep_enabled:
self._available = True
self.async_write_ha_state()
@property

View File

@ -192,18 +192,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = withings_data
register_lock = asyncio.Lock()
webhooks_registered = False
async def unregister_webhook(
_: Any,
) -> None:
LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID])
nonlocal webhooks_registered
async with register_lock:
LOGGER.debug(
"Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]
)
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
await async_unsubscribe_webhooks(client)
for coordinator in withings_data.coordinators:
coordinator.webhook_subscription_listener(False)
webhooks_registered = False
async def register_webhook(
_: Any,
) -> None:
nonlocal webhooks_registered
async with register_lock:
if webhooks_registered:
return
if cloud.async_active_subscription(hass):
webhook_url = await _async_cloudhook_generate_url(hass, entry)
else:
@ -228,16 +240,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
get_webhook_handler(withings_data),
allowed_methods=[METH_POST],
)
LOGGER.debug("Registered Withings webhook at hass: %s", webhook_url)
await async_subscribe_webhooks(client, webhook_url)
for coordinator in withings_data.coordinators:
coordinator.webhook_subscription_listener(True)
LOGGER.debug("Register Withings webhook: %s", webhook_url)
LOGGER.debug("Registered Withings webhook at Withings: %s", webhook_url)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
)
webhooks_registered = True
async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
LOGGER.debug("Cloudconnection state changed to %s", state)
if state is cloud.CloudConnectionState.CLOUD_CONNECTED:
await register_webhook(None)

View File

@ -17,11 +17,11 @@ class SatelliteDevice:
satellite_id: str
device_id: str
is_active: bool = False
is_enabled: bool = True
is_muted: bool = False
pipeline_name: str | None = None
_is_active_listener: Callable[[], None] | None = None
_is_enabled_listener: Callable[[], None] | None = None
_is_muted_listener: Callable[[], None] | None = None
_pipeline_listener: Callable[[], None] | None = None
@callback
@ -33,12 +33,12 @@ class SatelliteDevice:
self._is_active_listener()
@callback
def set_is_enabled(self, enabled: bool) -> None:
"""Set enabled state."""
if enabled != self.is_enabled:
self.is_enabled = enabled
if self._is_enabled_listener is not None:
self._is_enabled_listener()
def set_is_muted(self, muted: bool) -> None:
"""Set muted state."""
if muted != self.is_muted:
self.is_muted = muted
if self._is_muted_listener is not None:
self._is_muted_listener()
@callback
def set_pipeline_name(self, pipeline_name: str) -> None:
@ -54,9 +54,9 @@ class SatelliteDevice:
self._is_active_listener = is_active_listener
@callback
def set_is_enabled_listener(self, is_enabled_listener: Callable[[], None]) -> None:
"""Listen for updates to is_enabled."""
self._is_enabled_listener = is_enabled_listener
def set_is_muted_listener(self, is_muted_listener: Callable[[], None]) -> None:
"""Listen for updates to muted status."""
self._is_muted_listener = is_muted_listener
@callback
def set_pipeline_listener(self, pipeline_listener: Callable[[], None]) -> None:
@ -70,11 +70,11 @@ class SatelliteDevice:
"binary_sensor", DOMAIN, f"{self.satellite_id}-assist_in_progress"
)
def get_satellite_enabled_entity_id(self, hass: HomeAssistant) -> str | None:
"""Return entity id for satellite enabled switch."""
def get_muted_entity_id(self, hass: HomeAssistant) -> str | None:
"""Return entity id for satellite muted switch."""
ent_reg = er.async_get(hass)
return ent_reg.async_get_entity_id(
"switch", DOMAIN, f"{self.satellite_id}-satellite_enabled"
"switch", DOMAIN, f"{self.satellite_id}-mute"
)
def get_pipeline_entity_id(self, hass: HomeAssistant) -> str | None:

View File

@ -49,7 +49,6 @@ class WyomingSatellite:
self.hass = hass
self.service = service
self.device = device
self.is_enabled = True
self.is_running = True
self._client: AsyncTcpClient | None = None
@ -57,9 +56,9 @@ class WyomingSatellite:
self._is_pipeline_running = False
self._audio_queue: asyncio.Queue[bytes | None] = asyncio.Queue()
self._pipeline_id: str | None = None
self._enabled_changed_event = asyncio.Event()
self._muted_changed_event = asyncio.Event()
self.device.set_is_enabled_listener(self._enabled_changed)
self.device.set_is_muted_listener(self._muted_changed)
self.device.set_pipeline_listener(self._pipeline_changed)
async def run(self) -> None:
@ -69,12 +68,12 @@ class WyomingSatellite:
try:
while self.is_running:
try:
# Check if satellite has been disabled
if not self.device.is_enabled:
await self.on_disabled()
# Check if satellite has been muted
while self.device.is_muted:
await self.on_muted()
if not self.is_running:
# Satellite was stopped while waiting to be enabled
break
# Satellite was stopped while waiting to be unmuted
return
# Connect and run pipeline loop
await self._run_once()
@ -92,8 +91,8 @@ class WyomingSatellite:
"""Signal satellite task to stop running."""
self.is_running = False
# Unblock waiting for enabled
self._enabled_changed_event.set()
# Unblock waiting for unmuted
self._muted_changed_event.set()
async def on_restart(self) -> None:
"""Block until pipeline loop will be restarted."""
@ -111,9 +110,9 @@ class WyomingSatellite:
)
await asyncio.sleep(_RECONNECT_SECONDS)
async def on_disabled(self) -> None:
"""Block until device may be enabled again."""
await self._enabled_changed_event.wait()
async def on_muted(self) -> None:
"""Block until device may be unmated again."""
await self._muted_changed_event.wait()
async def on_stopped(self) -> None:
"""Run when run() has fully stopped."""
@ -121,14 +120,14 @@ class WyomingSatellite:
# -------------------------------------------------------------------------
def _enabled_changed(self) -> None:
"""Run when device enabled status changes."""
if not self.device.is_enabled:
def _muted_changed(self) -> None:
"""Run when device muted status changes."""
if self.device.is_muted:
# Cancel any running pipeline
self._audio_queue.put_nowait(None)
self._enabled_changed_event.set()
self._muted_changed_event.set()
self._muted_changed_event.clear()
def _pipeline_changed(self) -> None:
"""Run when device pipeline changes."""
@ -140,7 +139,7 @@ class WyomingSatellite:
"""Run pipelines until an error occurs."""
self.device.set_is_active(False)
while self.is_running and self.is_enabled:
while self.is_running and (not self.device.is_muted):
try:
await self._connect()
break
@ -150,7 +149,7 @@ class WyomingSatellite:
assert self._client is not None
_LOGGER.debug("Connected to satellite")
if (not self.is_running) or (not self.is_enabled):
if (not self.is_running) or self.device.is_muted:
# Run was cancelled or satellite was disabled during connection
return
@ -159,7 +158,7 @@ class WyomingSatellite:
# Wait until we get RunPipeline event
run_pipeline: RunPipeline | None = None
while self.is_running and self.is_enabled:
while self.is_running and (not self.device.is_muted):
run_event = await self._client.read_event()
if run_event is None:
raise ConnectionResetError("Satellite disconnected")
@ -173,7 +172,7 @@ class WyomingSatellite:
assert run_pipeline is not None
_LOGGER.debug("Received run information: %s", run_pipeline)
if (not self.is_running) or (not self.is_enabled):
if (not self.is_running) or self.device.is_muted:
# Run was cancelled or satellite was disabled while waiting for
# RunPipeline event.
return
@ -188,7 +187,7 @@ class WyomingSatellite:
raise ValueError(f"Invalid end stage: {end_stage}")
# Each loop is a pipeline run
while self.is_running and self.is_enabled:
while self.is_running and (not self.device.is_muted):
# Use select to get pipeline each time in case it's changed
pipeline_id = pipeline_select.get_chosen_pipeline(
self.hass,
@ -243,9 +242,17 @@ class WyomingSatellite:
chunk = AudioChunk.from_event(client_event)
chunk = self._chunk_converter.convert(chunk)
self._audio_queue.put_nowait(chunk.audio)
elif AudioStop.is_type(client_event.type):
# Stop pipeline
_LOGGER.debug("Client requested pipeline to stop")
self._audio_queue.put_nowait(b"")
break
else:
_LOGGER.debug("Unexpected event from satellite: %s", client_event)
# Ensure task finishes
await _pipeline_task
_LOGGER.debug("Pipeline finished")
def _event_callback(self, event: assist_pipeline.PipelineEvent) -> None:
@ -336,12 +343,23 @@ class WyomingSatellite:
async def _connect(self) -> None:
"""Connect to satellite over TCP."""
await self._disconnect()
_LOGGER.debug(
"Connecting to satellite at %s:%s", self.service.host, self.service.port
)
self._client = AsyncTcpClient(self.service.host, self.service.port)
await self._client.connect()
async def _disconnect(self) -> None:
"""Disconnect if satellite is currently connected."""
if self._client is None:
return
_LOGGER.debug("Disconnecting from satellite")
await self._client.disconnect()
self._client = None
async def _stream_tts(self, media_id: str) -> None:
"""Stream TTS WAV audio to satellite in chunks."""
assert self._client is not None

View File

@ -42,8 +42,8 @@
}
},
"switch": {
"satellite_enabled": {
"name": "Satellite enabled"
"mute": {
"name": "Mute"
}
}
}

View File

@ -29,17 +29,17 @@ async def async_setup_entry(
# Setup is only forwarded for satellites
assert item.satellite is not None
async_add_entities([WyomingSatelliteEnabledSwitch(item.satellite.device)])
async_add_entities([WyomingSatelliteMuteSwitch(item.satellite.device)])
class WyomingSatelliteEnabledSwitch(
class WyomingSatelliteMuteSwitch(
WyomingSatelliteEntity, restore_state.RestoreEntity, SwitchEntity
):
"""Entity to represent if satellite is enabled."""
"""Entity to represent if satellite is muted."""
entity_description = SwitchEntityDescription(
key="satellite_enabled",
translation_key="satellite_enabled",
key="mute",
translation_key="mute",
entity_category=EntityCategory.CONFIG,
)
@ -49,17 +49,17 @@ class WyomingSatelliteEnabledSwitch(
state = await self.async_get_last_state()
# Default to on
self._attr_is_on = (state is None) or (state.state == STATE_ON)
# Default to off
self._attr_is_on = (state is not None) and (state.state == STATE_ON)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
self._attr_is_on = True
self.async_write_ha_state()
self._device.set_is_enabled(True)
self._device.set_is_muted(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
self._attr_is_on = False
self.async_write_ha_state()
self._device.set_is_enabled(False)
self._device.set_is_muted(False)

View File

@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
"requirements": ["zeroconf==0.127.0"]
"requirements": ["zeroconf==0.128.4"]
}

View File

@ -37,8 +37,6 @@ from .core.const import (
DOMAIN,
PLATFORMS,
SIGNAL_ADD_ENTITIES,
STARTUP_FAILURE_DELAY_S,
STARTUP_RETRIES,
RadioType,
)
from .core.device import get_device_automation_triggers
@ -161,15 +159,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
_LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache)
# Retry setup a few times before giving up to deal with missing serial ports in VMs
for attempt in range(STARTUP_RETRIES):
try:
zha_gateway = await ZHAGateway.async_from_config(
hass=hass,
config=zha_data.yaml_config,
config_entry=config_entry,
)
break
except NetworkSettingsInconsistent as exc:
await warn_on_inconsistent_network_settings(
hass,
@ -182,28 +177,22 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
) from exc
except TransientConnectionError as exc:
raise ConfigEntryNotReady from exc
except Exception as exc: # pylint: disable=broad-except
_LOGGER.debug(
"Couldn't start coordinator (attempt %s of %s)",
attempt + 1,
STARTUP_RETRIES,
exc_info=exc,
)
except Exception as exc:
_LOGGER.debug("Failed to set up ZHA", exc_info=exc)
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
if attempt < STARTUP_RETRIES - 1:
await asyncio.sleep(STARTUP_FAILURE_DELAY_S)
continue
if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp:
if (
not device_path.startswith("socket://")
and RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp
):
try:
# Ignore all exceptions during probing, they shouldn't halt setup
await warn_on_wrong_silabs_firmware(
hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
)
if await warn_on_wrong_silabs_firmware(hass, device_path):
raise ConfigEntryError("Incorrect firmware installed") from exc
except AlreadyRunningEZSP as ezsp_exc:
raise ConfigEntryNotReady from ezsp_exc
raise
raise ConfigEntryNotReady from exc
repairs.async_delete_blocking_issues(hass)

View File

@ -139,7 +139,6 @@ CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join"
CONF_ENABLE_QUIRKS = "enable_quirks"
CONF_RADIO_TYPE = "radio_type"
CONF_USB_PATH = "usb_path"
CONF_USE_THREAD = "use_thread"
CONF_ZIGPY = "zigpy_config"
CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains"
@ -409,9 +408,6 @@ class Strobe(t.enum8):
Strobe = 0x01
STARTUP_FAILURE_DELAY_S = 3
STARTUP_RETRIES = 3
EZSP_OVERWRITE_EUI64 = (
"i_understand_i_can_update_eui64_only_once_and_i_still_want_to_do_it"
)

View File

@ -46,7 +46,6 @@ from .const import (
ATTR_SIGNATURE,
ATTR_TYPE,
CONF_RADIO_TYPE,
CONF_USE_THREAD,
CONF_ZIGPY,
DEBUG_COMP_BELLOWS,
DEBUG_COMP_ZHA,
@ -158,15 +157,6 @@ class ZHAGateway:
if CONF_NWK_VALIDATE_SETTINGS not in app_config:
app_config[CONF_NWK_VALIDATE_SETTINGS] = True
# The bellows UART thread sometimes propagates a cancellation into the main Core
# event loop, when a connection to a TCP coordinator fails in a specific way
if (
CONF_USE_THREAD not in app_config
and radio_type is RadioType.ezsp
and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://")
):
app_config[CONF_USE_THREAD] = False
# Local import to avoid circular dependencies
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (

View File

@ -21,13 +21,13 @@
"universal_silabs_flasher"
],
"requirements": [
"bellows==0.37.1",
"bellows==0.37.3",
"pyserial==3.5",
"pyserial-asyncio==0.6",
"zha-quirks==0.0.107",
"zigpy-deconz==0.22.0",
"zigpy==0.60.0",
"zigpy-xbee==0.20.0",
"zigpy-deconz==0.22.2",
"zigpy==0.60.1",
"zigpy-xbee==0.20.1",
"zigpy-zigate==0.12.0",
"zigpy-znp==0.12.0",
"universal-silabs-flasher==0.0.15",

View File

@ -10,7 +10,6 @@ import logging
import os
from typing import Any, Self
from bellows.config import CONF_USE_THREAD
import voluptuous as vol
from zigpy.application import ControllerApplication
import zigpy.backups
@ -175,7 +174,6 @@ class ZhaRadioManager:
app_config[CONF_DATABASE] = database_path
app_config[CONF_DEVICE] = self.device_settings
app_config[CONF_NWK_BACKUP_ENABLED] = False
app_config[CONF_USE_THREAD] = False
app_config = self.radio_type.controller.SCHEMA(app_config)
app = await self.radio_type.controller.new(

View File

@ -7,7 +7,7 @@ from typing import Final
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2023
MINOR_VERSION: Final = 12
PATCH_VERSION: Final = "1"
PATCH_VERSION: Final = "2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0)

View File

@ -57,7 +57,7 @@ voluptuous-serialize==2.6.0
voluptuous==0.13.1
webrtc-noise-gain==1.2.3
yarl==1.9.2
zeroconf==0.127.0
zeroconf==0.128.4
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2023.12.1"
version = "2023.12.2"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"

View File

@ -28,7 +28,7 @@ DoorBirdPy==2.1.0
HAP-python==4.9.1
# homeassistant.components.tasmota
HATasmota==0.7.3
HATasmota==0.8.0
# homeassistant.components.mastodon
Mastodon.py==1.5.1
@ -523,7 +523,7 @@ beautifulsoup4==4.12.2
# beewi-smartclim==0.0.10
# homeassistant.components.zha
bellows==0.37.1
bellows==0.37.3
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.14.6
@ -604,7 +604,7 @@ btsmarthub-devicelist==0.2.3
buienradar==1.0.5
# homeassistant.components.caldav
caldav==1.3.6
caldav==1.3.8
# homeassistant.components.circuit
circuit-webhook==1.0.1
@ -1054,7 +1054,7 @@ ibmiotf==0.3.4
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
ical==6.1.0
ical==6.1.1
# homeassistant.components.ping
icmplib==3.0
@ -1476,7 +1476,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==0.34.5
plugwise==0.35.3
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@ -1775,7 +1775,7 @@ pyhaversion==22.8.0
pyheos==0.7.2
# homeassistant.components.hive
pyhiveapi==0.5.14
pyhiveapi==0.5.16
# homeassistant.components.homematic
pyhomematic==0.1.77
@ -2026,7 +2026,7 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.schlage
pyschlage==2023.11.0
pyschlage==2023.12.0
# homeassistant.components.sensibo
pysensibo==1.0.36
@ -2810,7 +2810,7 @@ zamg==0.3.3
zengge==0.2
# homeassistant.components.zeroconf
zeroconf==0.127.0
zeroconf==0.128.4
# homeassistant.components.zeversolar
zeversolar==0.3.1
@ -2825,10 +2825,10 @@ zhong-hong-hvac==1.0.9
ziggo-mediabox-xl==1.1.0
# homeassistant.components.zha
zigpy-deconz==0.22.0
zigpy-deconz==0.22.2
# homeassistant.components.zha
zigpy-xbee==0.20.0
zigpy-xbee==0.20.1
# homeassistant.components.zha
zigpy-zigate==0.12.0
@ -2837,7 +2837,7 @@ zigpy-zigate==0.12.0
zigpy-znp==0.12.0
# homeassistant.components.zha
zigpy==0.60.0
zigpy==0.60.1
# homeassistant.components.zoneminder
zm-py==0.5.2

View File

@ -14,7 +14,7 @@ mock-open==1.4.0
mypy==1.7.1
pre-commit==3.5.0
pydantic==1.10.12
pylint==3.0.2
pylint==3.0.3
pylint-per-file-ignores==1.2.1
pipdeptree==2.11.0
pytest-asyncio==0.21.0

View File

@ -25,7 +25,7 @@ DoorBirdPy==2.1.0
HAP-python==4.9.1
# homeassistant.components.tasmota
HATasmota==0.7.3
HATasmota==0.8.0
# homeassistant.components.doods
# homeassistant.components.generic
@ -445,7 +445,7 @@ base36==0.1.1
beautifulsoup4==4.12.2
# homeassistant.components.zha
bellows==0.37.1
bellows==0.37.3
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.14.6
@ -503,7 +503,7 @@ bthome-ble==3.2.0
buienradar==1.0.5
# homeassistant.components.caldav
caldav==1.3.6
caldav==1.3.8
# homeassistant.components.coinbase
coinbase==2.1.0
@ -832,7 +832,7 @@ ibeacon-ble==1.0.1
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
ical==6.1.0
ical==6.1.1
# homeassistant.components.ping
icmplib==3.0
@ -1134,7 +1134,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==0.34.5
plugwise==0.35.3
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@ -1340,7 +1340,7 @@ pyhaversion==22.8.0
pyheos==0.7.2
# homeassistant.components.hive
pyhiveapi==0.5.14
pyhiveapi==0.5.16
# homeassistant.components.homematic
pyhomematic==0.1.77
@ -1531,7 +1531,7 @@ pyrympro==0.0.7
pysabnzbd==1.1.1
# homeassistant.components.schlage
pyschlage==2023.11.0
pyschlage==2023.12.0
# homeassistant.components.sensibo
pysensibo==1.0.36
@ -2105,7 +2105,7 @@ yt-dlp==2023.11.16
zamg==0.3.3
# homeassistant.components.zeroconf
zeroconf==0.127.0
zeroconf==0.128.4
# homeassistant.components.zeversolar
zeversolar==0.3.1
@ -2114,10 +2114,10 @@ zeversolar==0.3.1
zha-quirks==0.0.107
# homeassistant.components.zha
zigpy-deconz==0.22.0
zigpy-deconz==0.22.2
# homeassistant.components.zha
zigpy-xbee==0.20.0
zigpy-xbee==0.20.1
# homeassistant.components.zha
zigpy-zigate==0.12.0
@ -2126,7 +2126,7 @@ zigpy-zigate==0.12.0
zigpy-znp==0.12.0
# homeassistant.components.zha
zigpy==0.60.0
zigpy==0.60.1
# homeassistant.components.zwave_js
zwave-js-server-python==0.54.0

View File

@ -77,7 +77,9 @@ async def test_flow_cannot_connect(hass: HomeAssistant, mock_setup_entry) -> Non
}
async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None:
async def test_import_flow(
hass: HomeAssistant, issue_registry: ir.IssueRegistry, mock_setup_entry
) -> None:
"""Test importing yaml config."""
with patch(
@ -95,11 +97,12 @@ async def test_import_flow(hass: HomeAssistant, mock_setup_entry) -> None:
assert result["data"] == {
CONF_API_KEY: "yaml-api-key",
}
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
async def test_import_flow_already_exists(hass: HomeAssistant) -> None:
async def test_import_flow_already_exists(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test importing yaml config where entry already exists."""
entry = MockConfigEntry(domain=DOMAIN, data={CONF_API_KEY: "yaml-api-key"})
entry.add_to_hass(hass)
@ -108,3 +111,4 @@ async def test_import_flow_already_exists(hass: HomeAssistant) -> None:
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert len(issue_registry.issues) == 1

View File

@ -6,7 +6,7 @@ import pytest
from homeassistant.components.alexa import smart_home, state_report
import homeassistant.components.camera as camera
from homeassistant.components.cover import CoverDeviceClass
from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature
from homeassistant.components.media_player import MediaPlayerEntityFeature
from homeassistant.components.vacuum import VacuumEntityFeature
from homeassistant.config import async_process_ha_core_config
@ -1884,8 +1884,199 @@ async def test_group(hass: HomeAssistant) -> None:
)
async def test_cover_position_range(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("position", "position_attr_in_service_call", "supported_features", "service_call"),
[
(
30,
30,
CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE,
"cover.set_cover_position",
),
(
0,
None,
CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE,
"cover.close_cover",
),
(
99,
99,
CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE,
"cover.set_cover_position",
),
(
100,
None,
CoverEntityFeature.SET_POSITION
| CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE,
"cover.open_cover",
),
(
0,
0,
CoverEntityFeature.SET_POSITION,
"cover.set_cover_position",
),
(
60,
60,
CoverEntityFeature.SET_POSITION,
"cover.set_cover_position",
),
(
100,
100,
CoverEntityFeature.SET_POSITION,
"cover.set_cover_position",
),
(
0,
0,
CoverEntityFeature.SET_POSITION | CoverEntityFeature.OPEN,
"cover.set_cover_position",
),
(
100,
100,
CoverEntityFeature.SET_POSITION | CoverEntityFeature.CLOSE,
"cover.set_cover_position",
),
],
ids=[
"position_30_open_close",
"position_0_open_close",
"position_99_open_close",
"position_100_open_close",
"position_0_no_open_close",
"position_60_no_open_close",
"position_100_no_open_close",
"position_0_no_close",
"position_100_no_open",
],
)
async def test_cover_position(
hass: HomeAssistant,
position: int,
position_attr_in_service_call: int | None,
supported_features: CoverEntityFeature,
service_call: str,
) -> None:
"""Test cover discovery and position using rangeController."""
device = (
"cover.test_range",
"open",
{
"friendly_name": "Test cover range",
"device_class": "blind",
"supported_features": supported_features,
"position": position,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "cover#test_range"
assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
assert appliance["friendlyName"] == "Test cover range"
capabilities = assert_endpoint_capabilities(
appliance,
"Alexa.PowerController",
"Alexa.RangeController",
"Alexa.EndpointHealth",
"Alexa",
)
range_capability = get_capability(capabilities, "Alexa.RangeController")
assert range_capability is not None
assert range_capability["instance"] == "cover.position"
properties = range_capability["properties"]
assert properties["nonControllable"] is False
assert {"name": "rangeValue"} in properties["supported"]
capability_resources = range_capability["capabilityResources"]
assert capability_resources is not None
assert {
"@type": "text",
"value": {"text": "Position", "locale": "en-US"},
} in capability_resources["friendlyNames"]
assert {
"@type": "asset",
"value": {"assetId": "Alexa.Setting.Opening"},
} in capability_resources["friendlyNames"]
configuration = range_capability["configuration"]
assert configuration is not None
assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent"
supported_range = configuration["supportedRange"]
assert supported_range["minimumValue"] == 0
assert supported_range["maximumValue"] == 100
assert supported_range["precision"] == 1
# Assert for Position Semantics
position_semantics = range_capability["semantics"]
assert position_semantics is not None
position_action_mappings = position_semantics["actionMappings"]
assert position_action_mappings is not None
assert {
"@type": "ActionsToDirective",
"actions": ["Alexa.Actions.Lower", "Alexa.Actions.Close"],
"directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}},
} in position_action_mappings
assert {
"@type": "ActionsToDirective",
"actions": ["Alexa.Actions.Raise", "Alexa.Actions.Open"],
"directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}},
} in position_action_mappings
position_state_mappings = position_semantics["stateMappings"]
assert position_state_mappings is not None
assert {
"@type": "StatesToValue",
"states": ["Alexa.States.Closed"],
"value": 0,
} in position_state_mappings
assert {
"@type": "StatesToRange",
"states": ["Alexa.States.Open"],
"range": {"minimumValue": 1, "maximumValue": 100},
} in position_state_mappings
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_range",
service_call,
hass,
payload={"rangeValue": position},
instance="cover.position",
)
assert call.data.get("position") == position_attr_in_service_call
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == position
async def test_cover_position_range(
hass: HomeAssistant,
) -> None:
"""Test cover discovery and position range using rangeController.
Also tests an invalid cover position being handled correctly.
"""
device = (
"cover.test_range",
"open",
@ -1969,59 +2160,6 @@ async def test_cover_position_range(hass: HomeAssistant) -> None:
"range": {"minimumValue": 1, "maximumValue": 100},
} in position_state_mappings
call, _ = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_range",
"cover.set_cover_position",
hass,
payload={"rangeValue": 50},
instance="cover.position",
)
assert call.data["position"] == 50
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_range",
"cover.close_cover",
hass,
payload={"rangeValue": 0},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 0
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_range",
"cover.open_cover",
hass,
payload={"rangeValue": 100},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 100
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"AdjustRangeValue",
"cover#test_range",
"cover.open_cover",
hass,
payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False},
instance="cover.position",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 100
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"AdjustRangeValue",
@ -3435,8 +3573,159 @@ async def test_presence_sensor(hass: HomeAssistant) -> None:
assert {"name": "humanPresenceDetectionState"} in properties["supported"]
async def test_cover_tilt_position_range(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
(
"tilt_position",
"tilt_position_attr_in_service_call",
"supported_features",
"service_call",
),
[
(
30,
30,
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.set_cover_tilt_position",
),
(
0,
None,
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.close_cover_tilt",
),
(
99,
99,
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.set_cover_tilt_position",
),
(
100,
None,
CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT,
"cover.open_cover_tilt",
),
(
0,
0,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
),
(
60,
60,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
),
(
100,
100,
CoverEntityFeature.SET_TILT_POSITION,
"cover.set_cover_tilt_position",
),
(
0,
0,
CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.OPEN_TILT,
"cover.set_cover_tilt_position",
),
(
100,
100,
CoverEntityFeature.SET_TILT_POSITION | CoverEntityFeature.CLOSE_TILT,
"cover.set_cover_tilt_position",
),
],
ids=[
"tilt_position_30_open_close",
"tilt_position_0_open_close",
"tilt_position_99_open_close",
"tilt_position_100_open_close",
"tilt_position_0_no_open_close",
"tilt_position_60_no_open_close",
"tilt_position_100_no_open_close",
"tilt_position_0_no_close",
"tilt_position_100_no_open",
],
)
async def test_cover_tilt_position(
hass: HomeAssistant,
tilt_position: int,
tilt_position_attr_in_service_call: int | None,
supported_features: CoverEntityFeature,
service_call: str,
) -> None:
"""Test cover discovery and tilt position using rangeController."""
device = (
"cover.test_tilt_range",
"open",
{
"friendly_name": "Test cover tilt range",
"device_class": "blind",
"supported_features": supported_features,
"tilt_position": tilt_position,
},
)
appliance = await discovery_test(device, hass)
assert appliance["endpointId"] == "cover#test_tilt_range"
assert appliance["displayCategories"][0] == "INTERIOR_BLIND"
assert appliance["friendlyName"] == "Test cover tilt range"
capabilities = assert_endpoint_capabilities(
appliance,
"Alexa.PowerController",
"Alexa.RangeController",
"Alexa.EndpointHealth",
"Alexa",
)
range_capability = get_capability(capabilities, "Alexa.RangeController")
assert range_capability is not None
assert range_capability["instance"] == "cover.tilt"
semantics = range_capability["semantics"]
assert semantics is not None
action_mappings = semantics["actionMappings"]
assert action_mappings is not None
state_mappings = semantics["stateMappings"]
assert state_mappings is not None
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_tilt_range",
service_call,
hass,
payload={"rangeValue": tilt_position},
instance="cover.tilt",
)
assert call.data.get("tilt_position") == tilt_position_attr_in_service_call
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == tilt_position
async def test_cover_tilt_position_range(hass: HomeAssistant) -> None:
"""Test cover discovery and tilt position range using rangeController.
Also tests and invalid tilt position being handled correctly.
"""
device = (
"cover.test_tilt_range",
"open",
@ -3485,48 +3774,6 @@ async def test_cover_tilt_position_range(hass: HomeAssistant) -> None:
)
assert call.data["tilt_position"] == 50
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_tilt_range",
"cover.close_cover_tilt",
hass,
payload={"rangeValue": 0},
instance="cover.tilt",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 0
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"SetRangeValue",
"cover#test_tilt_range",
"cover.open_cover_tilt",
hass,
payload={"rangeValue": 100},
instance="cover.tilt",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 100
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"AdjustRangeValue",
"cover#test_tilt_range",
"cover.open_cover_tilt",
hass,
payload={"rangeValueDelta": 99, "rangeValueDeltaDefault": False},
instance="cover.tilt",
)
properties = msg["context"]["properties"][0]
assert properties["name"] == "rangeValue"
assert properties["namespace"] == "Alexa.RangeController"
assert properties["value"] == 100
call, msg = await assert_request_calls_service(
"Alexa.RangeController",
"AdjustRangeValue",

View File

@ -662,15 +662,33 @@
# ---
# name: test_pipeline_empty_tts_output.1
dict({
'engine': 'test',
'language': 'en-US',
'tts_input': '',
'voice': 'james_earl_jones',
'conversation_id': None,
'device_id': None,
'engine': 'homeassistant',
'intent_input': 'never mind',
'language': 'en',
})
# ---
# name: test_pipeline_empty_tts_output.2
dict({
'tts_output': dict({
'intent_output': dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
}),
}),
}),
})
# ---

View File

@ -2467,10 +2467,10 @@ async def test_pipeline_empty_tts_output(
await client.send_json_auto_id(
{
"type": "assist_pipeline/run",
"start_stage": "tts",
"start_stage": "intent",
"end_stage": "tts",
"input": {
"text": "",
"text": "never mind",
},
}
)
@ -2486,16 +2486,15 @@ async def test_pipeline_empty_tts_output(
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
# text-to-speech
# intent
msg = await client.receive_json()
assert msg["event"]["type"] == "tts-start"
assert msg["event"]["type"] == "intent-start"
assert msg["event"]["data"] == snapshot
events.append(msg["event"])
msg = await client.receive_json()
assert msg["event"]["type"] == "tts-end"
assert msg["event"]["type"] == "intent-end"
assert msg["event"]["data"] == snapshot
assert not msg["event"]["data"]["tts_output"]
events.append(msg["event"])
# run end

View File

@ -1,4 +1,5 @@
"""The tests for the webdav todo component."""
from datetime import UTC, date, datetime
from typing import Any
from unittest.mock import MagicMock, Mock
@ -200,12 +201,16 @@ async def test_supported_components(
),
(
{"due_date": "2023-11-18"},
{"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118"},
{"status": "NEEDS-ACTION", "summary": "Cheese", "due": date(2023, 11, 18)},
{**RESULT_ITEM, "due": "2023-11-18"},
),
(
{"due_datetime": "2023-11-18T08:30:00-06:00"},
{"status": "NEEDS-ACTION", "summary": "Cheese", "due": "20231118T143000Z"},
{
"status": "NEEDS-ACTION",
"summary": "Cheese",
"due": datetime(2023, 11, 18, 14, 30, 00, tzinfo=UTC),
},
{**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"},
),
(
@ -311,13 +316,13 @@ async def test_add_item_failure(
),
(
{"due_date": "2023-11-18"},
["SUMMARY:Cheese", "DUE:20231118"],
["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"],
"1",
{**RESULT_ITEM, "due": "2023-11-18"},
),
(
{"due_datetime": "2023-11-18T08:30:00-06:00"},
["SUMMARY:Cheese", "DUE:20231118T143000Z"],
["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"],
"1",
{**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"},
),

View File

@ -153,7 +153,7 @@ async def test_get_temperature(
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 10.0
# Select by area instead (climate_2)
# Select by area (climate_2)
response = await intent.async_handle(
hass,
"test",
@ -166,6 +166,19 @@ async def test_get_temperature(
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 22.0
# Select by name (climate_2)
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Climate 2"}},
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 22.0
async def test_get_temperature_no_entities(
hass: HomeAssistant,

View File

@ -107,18 +107,21 @@ async def test_token_refresh_success(
@pytest.mark.parametrize("token_expiration_time", [12345])
@pytest.mark.parametrize("closing", [True, False])
async def test_token_requires_reauth(
hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]],
config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
setup_credentials: None,
closing: bool,
) -> None:
"""Test where token is expired and the refresh attempt requires reauth."""
aioclient_mock.post(
OAUTH2_TOKEN,
status=HTTPStatus.UNAUTHORIZED,
closing=closing,
)
assert not await integration_setup()

View File

@ -1301,6 +1301,7 @@ async def test_event_differs_timezone(
}
@pytest.mark.freeze_time("2023-11-30 12:15:00 +00:00")
async def test_invalid_rrule_fix(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,

View File

@ -112,7 +112,8 @@
"Bios Schema met Film Avond",
"GF7 Woonkamer",
"Badkamer Schema",
"CV Jessie"
"CV Jessie",
"off"
],
"dev_class": "zone_thermostat",
"firmware": "2016-10-27T02:00:00+02:00",
@ -251,7 +252,8 @@
"Bios Schema met Film Avond",
"GF7 Woonkamer",
"Badkamer Schema",
"CV Jessie"
"CV Jessie",
"off"
],
"dev_class": "zone_thermostat",
"firmware": "2016-08-02T02:00:00+02:00",
@ -334,7 +336,8 @@
"Bios Schema met Film Avond",
"GF7 Woonkamer",
"Badkamer Schema",
"CV Jessie"
"CV Jessie",
"off"
],
"dev_class": "zone_thermostat",
"firmware": "2016-10-27T02:00:00+02:00",
@ -344,7 +347,7 @@
"model": "Lisa",
"name": "Zone Lisa Bios",
"preset_modes": ["home", "asleep", "away", "vacation", "no_frost"],
"select_schedule": "None",
"select_schedule": "off",
"sensors": {
"battery": 67,
"setpoint": 13.0,
@ -373,7 +376,8 @@
"Bios Schema met Film Avond",
"GF7 Woonkamer",
"Badkamer Schema",
"CV Jessie"
"CV Jessie",
"off"
],
"dev_class": "thermostatic_radiator_valve",
"firmware": "2019-03-27T01:00:00+01:00",
@ -383,7 +387,7 @@
"model": "Tom/Floor",
"name": "CV Kraan Garage",
"preset_modes": ["home", "asleep", "away", "vacation", "no_frost"],
"select_schedule": "None",
"select_schedule": "off",
"sensors": {
"battery": 68,
"setpoint": 5.5,
@ -414,7 +418,8 @@
"Bios Schema met Film Avond",
"GF7 Woonkamer",
"Badkamer Schema",
"CV Jessie"
"CV Jessie",
"off"
],
"dev_class": "zone_thermostat",
"firmware": "2016-10-27T02:00:00+02:00",

View File

@ -59,7 +59,7 @@
},
"3cb70739631c4d17a86b8b12e8a5161b": {
"active_preset": "home",
"available_schedules": ["standaard"],
"available_schedules": ["standaard", "off"],
"dev_class": "thermostat",
"firmware": "2018-02-08T11:15:53+01:00",
"hardware": "6539-1301-5002",

View File

@ -52,7 +52,7 @@
"ad4838d7d35c4d6ea796ee12ae5aedf8": {
"active_preset": "asleep",
"available": true,
"available_schedules": ["Weekschema", "Badkamer", "Test"],
"available_schedules": ["Weekschema", "Badkamer", "Test", "off"],
"control_state": "cooling",
"dev_class": "thermostat",
"location": "f2bf9048bef64cc5b6d5110154e33c81",
@ -102,7 +102,7 @@
"e2f4322d57924fa090fbbc48b3a140dc": {
"active_preset": "home",
"available": true,
"available_schedules": ["Weekschema", "Badkamer", "Test"],
"available_schedules": ["Weekschema", "Badkamer", "Test", "off"],
"control_state": "off",
"dev_class": "zone_thermostat",
"firmware": "2016-10-10T02:00:00+02:00",

View File

@ -80,7 +80,7 @@
"ad4838d7d35c4d6ea796ee12ae5aedf8": {
"active_preset": "asleep",
"available": true,
"available_schedules": ["Weekschema", "Badkamer", "Test"],
"available_schedules": ["Weekschema", "Badkamer", "Test", "off"],
"control_state": "preheating",
"dev_class": "thermostat",
"location": "f2bf9048bef64cc5b6d5110154e33c81",
@ -124,7 +124,7 @@
"e2f4322d57924fa090fbbc48b3a140dc": {
"active_preset": "home",
"available": true,
"available_schedules": ["Weekschema", "Badkamer", "Test"],
"available_schedules": ["Weekschema", "Badkamer", "Test", "off"],
"control_state": "off",
"dev_class": "zone_thermostat",
"firmware": "2016-10-10T02:00:00+02:00",

View File

@ -59,7 +59,7 @@
},
"3cb70739631c4d17a86b8b12e8a5161b": {
"active_preset": "home",
"available_schedules": ["standaard"],
"available_schedules": ["standaard", "off"],
"dev_class": "thermostat",
"firmware": "2018-02-08T11:15:53+01:00",
"hardware": "6539-1301-5002",

View File

@ -59,7 +59,7 @@
},
"3cb70739631c4d17a86b8b12e8a5161b": {
"active_preset": "home",
"available_schedules": ["standaard"],
"available_schedules": ["standaard", "off"],
"dev_class": "thermostat",
"firmware": "2018-02-08T11:15:53+01:00",
"hardware": "6539-1301-5002",

View File

@ -115,6 +115,7 @@
'GF7 Woonkamer',
'Badkamer Schema',
'CV Jessie',
'off',
]),
'dev_class': 'zone_thermostat',
'firmware': '2016-10-27T02:00:00+02:00',
@ -260,6 +261,7 @@
'GF7 Woonkamer',
'Badkamer Schema',
'CV Jessie',
'off',
]),
'dev_class': 'zone_thermostat',
'firmware': '2016-08-02T02:00:00+02:00',
@ -349,6 +351,7 @@
'GF7 Woonkamer',
'Badkamer Schema',
'CV Jessie',
'off',
]),
'dev_class': 'zone_thermostat',
'firmware': '2016-10-27T02:00:00+02:00',
@ -364,7 +367,7 @@
'vacation',
'no_frost',
]),
'select_schedule': 'None',
'select_schedule': 'off',
'sensors': dict({
'battery': 67,
'setpoint': 13.0,
@ -394,6 +397,7 @@
'GF7 Woonkamer',
'Badkamer Schema',
'CV Jessie',
'off',
]),
'dev_class': 'thermostatic_radiator_valve',
'firmware': '2019-03-27T01:00:00+01:00',
@ -409,7 +413,7 @@
'vacation',
'no_frost',
]),
'select_schedule': 'None',
'select_schedule': 'off',
'sensors': dict({
'battery': 68,
'setpoint': 5.5,
@ -441,6 +445,7 @@
'GF7 Woonkamer',
'Badkamer Schema',
'CV Jessie',
'off',
]),
'dev_class': 'zone_thermostat',
'firmware': '2016-10-27T02:00:00+02:00',

View File

@ -31,6 +31,8 @@ from .test_common import (
help_test_availability_discovery_update,
help_test_availability_poll_state,
help_test_availability_when_connection_lost,
help_test_deep_sleep_availability,
help_test_deep_sleep_availability_when_connection_lost,
help_test_discovery_device_remove,
help_test_discovery_removal,
help_test_discovery_update_unchanged,
@ -313,6 +315,21 @@ async def test_availability_when_connection_lost(
)
async def test_deep_sleep_availability_when_connection_lost(
hass: HomeAssistant,
mqtt_client_mock: MqttMockPahoClient,
mqtt_mock: MqttMockHAClient,
setup_tasmota,
) -> None:
"""Test availability after MQTT disconnection."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["swc"][0] = 1
config["swn"][0] = "Test"
await help_test_deep_sleep_availability_when_connection_lost(
hass, mqtt_client_mock, mqtt_mock, Platform.BINARY_SENSOR, config
)
async def test_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
@ -323,6 +340,18 @@ async def test_availability(
await help_test_availability(hass, mqtt_mock, Platform.BINARY_SENSOR, config)
async def test_deep_sleep_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
"""Test availability when deep sleep is enabled."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["swc"][0] = 1
config["swn"][0] = "Test"
await help_test_deep_sleep_availability(
hass, mqtt_mock, Platform.BINARY_SENSOR, config
)
async def test_availability_discovery_update(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:

View File

@ -4,6 +4,7 @@ import json
from unittest.mock import ANY
from hatasmota.const import (
CONF_DEEP_SLEEP,
CONF_MAC,
CONF_OFFLINE,
CONF_ONLINE,
@ -188,6 +189,76 @@ async def help_test_availability_when_connection_lost(
assert state.state != STATE_UNAVAILABLE
async def help_test_deep_sleep_availability_when_connection_lost(
hass,
mqtt_client_mock,
mqtt_mock,
domain,
config,
sensor_config=None,
object_id="tasmota_test",
):
"""Test availability after MQTT disconnection when deep sleep is enabled.
This is a test helper for the TasmotaAvailability mixin.
"""
config[CONF_DEEP_SLEEP] = 1
async_fire_mqtt_message(
hass,
f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config",
json.dumps(config),
)
await hass.async_block_till_done()
if sensor_config:
async_fire_mqtt_message(
hass,
f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors",
json.dumps(sensor_config),
)
await hass.async_block_till_done()
# Device online
state = hass.states.get(f"{domain}.{object_id}")
assert state.state != STATE_UNAVAILABLE
# Disconnected from MQTT server -> state changed to unavailable
mqtt_mock.connected = False
await hass.async_add_executor_job(mqtt_client_mock.on_disconnect, None, None, 0)
await hass.async_block_till_done()
await hass.async_block_till_done()
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.{object_id}")
assert state.state == STATE_UNAVAILABLE
# Reconnected to MQTT server -> state no longer unavailable
mqtt_mock.connected = True
await hass.async_add_executor_job(mqtt_client_mock.on_connect, None, None, None, 0)
await hass.async_block_till_done()
await hass.async_block_till_done()
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.{object_id}")
assert state.state != STATE_UNAVAILABLE
# Receive LWT again
async_fire_mqtt_message(
hass,
get_topic_tele_will(config),
config_get_state_online(config),
)
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.{object_id}")
assert state.state != STATE_UNAVAILABLE
async_fire_mqtt_message(
hass,
get_topic_tele_will(config),
config_get_state_offline(config),
)
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.{object_id}")
assert state.state != STATE_UNAVAILABLE
async def help_test_availability(
hass,
mqtt_mock,
@ -236,6 +307,55 @@ async def help_test_availability(
assert state.state == STATE_UNAVAILABLE
async def help_test_deep_sleep_availability(
hass,
mqtt_mock,
domain,
config,
sensor_config=None,
object_id="tasmota_test",
):
"""Test availability when deep sleep is enabled.
This is a test helper for the TasmotaAvailability mixin.
"""
config[CONF_DEEP_SLEEP] = 1
async_fire_mqtt_message(
hass,
f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config",
json.dumps(config),
)
await hass.async_block_till_done()
if sensor_config:
async_fire_mqtt_message(
hass,
f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors",
json.dumps(sensor_config),
)
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.{object_id}")
assert state.state != STATE_UNAVAILABLE
async_fire_mqtt_message(
hass,
get_topic_tele_will(config),
config_get_state_online(config),
)
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.{object_id}")
assert state.state != STATE_UNAVAILABLE
async_fire_mqtt_message(
hass,
get_topic_tele_will(config),
config_get_state_offline(config),
)
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.{object_id}")
assert state.state != STATE_UNAVAILABLE
async def help_test_availability_discovery_update(
hass,
mqtt_mock,

View File

@ -22,6 +22,8 @@ from .test_common import (
help_test_availability_discovery_update,
help_test_availability_poll_state,
help_test_availability_when_connection_lost,
help_test_deep_sleep_availability,
help_test_deep_sleep_availability_when_connection_lost,
help_test_discovery_device_remove,
help_test_discovery_removal,
help_test_discovery_update_unchanged,
@ -663,6 +665,27 @@ async def test_availability_when_connection_lost(
)
async def test_deep_sleep_availability_when_connection_lost(
hass: HomeAssistant,
mqtt_client_mock: MqttMockPahoClient,
mqtt_mock: MqttMockHAClient,
setup_tasmota,
) -> None:
"""Test availability after MQTT disconnection."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["dn"] = "Test"
config["rl"][0] = 3
config["rl"][1] = 3
await help_test_deep_sleep_availability_when_connection_lost(
hass,
mqtt_client_mock,
mqtt_mock,
Platform.COVER,
config,
object_id="test_cover_1",
)
async def test_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
@ -676,6 +699,19 @@ async def test_availability(
)
async def test_deep_sleep_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
"""Test availability."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["dn"] = "Test"
config["rl"][0] = 3
config["rl"][1] = 3
await help_test_deep_sleep_availability(
hass, mqtt_mock, Platform.COVER, config, object_id="test_cover_1"
)
async def test_availability_discovery_update(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:

View File

@ -22,6 +22,8 @@ from .test_common import (
help_test_availability_discovery_update,
help_test_availability_poll_state,
help_test_availability_when_connection_lost,
help_test_deep_sleep_availability,
help_test_deep_sleep_availability_when_connection_lost,
help_test_discovery_device_remove,
help_test_discovery_removal,
help_test_discovery_update_unchanged,
@ -232,6 +234,20 @@ async def test_availability_when_connection_lost(
)
async def test_deep_sleep_availability_when_connection_lost(
hass: HomeAssistant,
mqtt_client_mock: MqttMockPahoClient,
mqtt_mock: MqttMockHAClient,
setup_tasmota,
) -> None:
"""Test availability after MQTT disconnection."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["if"] = 1
await help_test_deep_sleep_availability_when_connection_lost(
hass, mqtt_client_mock, mqtt_mock, Platform.FAN, config, object_id="tasmota"
)
async def test_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
@ -243,6 +259,17 @@ async def test_availability(
)
async def test_deep_sleep_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
"""Test availability."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["if"] = 1
await help_test_deep_sleep_availability(
hass, mqtt_mock, Platform.FAN, config, object_id="tasmota"
)
async def test_availability_discovery_update(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:

View File

@ -22,6 +22,8 @@ from .test_common import (
help_test_availability_discovery_update,
help_test_availability_poll_state,
help_test_availability_when_connection_lost,
help_test_deep_sleep_availability,
help_test_deep_sleep_availability_when_connection_lost,
help_test_discovery_device_remove,
help_test_discovery_removal,
help_test_discovery_update_unchanged,
@ -1669,6 +1671,21 @@ async def test_availability_when_connection_lost(
)
async def test_deep_sleep_availability_when_connection_lost(
hass: HomeAssistant,
mqtt_client_mock: MqttMockPahoClient,
mqtt_mock: MqttMockHAClient,
setup_tasmota,
) -> None:
"""Test availability after MQTT disconnection."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["rl"][0] = 2
config["lt_st"] = 1 # 1 channel light (Dimmer)
await help_test_deep_sleep_availability_when_connection_lost(
hass, mqtt_client_mock, mqtt_mock, Platform.LIGHT, config
)
async def test_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
@ -1679,6 +1696,16 @@ async def test_availability(
await help_test_availability(hass, mqtt_mock, Platform.LIGHT, config)
async def test_deep_sleep_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
"""Test availability."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["rl"][0] = 2
config["lt_st"] = 1 # 1 channel light (Dimmer)
await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.LIGHT, config)
async def test_availability_discovery_update(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:

View File

@ -28,6 +28,8 @@ from .test_common import (
help_test_availability_discovery_update,
help_test_availability_poll_state,
help_test_availability_when_connection_lost,
help_test_deep_sleep_availability,
help_test_deep_sleep_availability_when_connection_lost,
help_test_discovery_device_remove,
help_test_discovery_removal,
help_test_discovery_update_unchanged,
@ -1222,6 +1224,26 @@ async def test_availability_when_connection_lost(
)
async def test_deep_sleep_availability_when_connection_lost(
hass: HomeAssistant,
mqtt_client_mock: MqttMockPahoClient,
mqtt_mock: MqttMockHAClient,
setup_tasmota,
) -> None:
"""Test availability after MQTT disconnection."""
config = copy.deepcopy(DEFAULT_CONFIG)
sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG)
await help_test_deep_sleep_availability_when_connection_lost(
hass,
mqtt_client_mock,
mqtt_mock,
Platform.SENSOR,
config,
sensor_config,
"tasmota_dht11_temperature",
)
async def test_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
@ -1238,6 +1260,22 @@ async def test_availability(
)
async def test_deep_sleep_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
"""Test availability."""
config = copy.deepcopy(DEFAULT_CONFIG)
sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG)
await help_test_deep_sleep_availability(
hass,
mqtt_mock,
Platform.SENSOR,
config,
sensor_config,
"tasmota_dht11_temperature",
)
async def test_availability_discovery_update(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:

View File

@ -20,6 +20,8 @@ from .test_common import (
help_test_availability_discovery_update,
help_test_availability_poll_state,
help_test_availability_when_connection_lost,
help_test_deep_sleep_availability,
help_test_deep_sleep_availability_when_connection_lost,
help_test_discovery_device_remove,
help_test_discovery_removal,
help_test_discovery_update_unchanged,
@ -158,6 +160,20 @@ async def test_availability_when_connection_lost(
)
async def test_deep_sleep_availability_when_connection_lost(
hass: HomeAssistant,
mqtt_client_mock: MqttMockPahoClient,
mqtt_mock: MqttMockHAClient,
setup_tasmota,
) -> None:
"""Test availability after MQTT disconnection."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["rl"][0] = 1
await help_test_deep_sleep_availability_when_connection_lost(
hass, mqtt_client_mock, mqtt_mock, Platform.SWITCH, config
)
async def test_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
@ -167,6 +183,15 @@ async def test_availability(
await help_test_availability(hass, mqtt_mock, Platform.SWITCH, config)
async def test_deep_sleep_availability(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:
"""Test availability."""
config = copy.deepcopy(DEFAULT_CONFIG)
config["rl"][0] = 1
await help_test_deep_sleep_availability(hass, mqtt_mock, Platform.SWITCH, config)
async def test_availability_discovery_update(
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
) -> None:

View File

@ -5,7 +5,7 @@ from homeassistant.components.assist_pipeline.select import OPTION_PREFERRED
from homeassistant.components.wyoming import DOMAIN
from homeassistant.components.wyoming.devices import SatelliteDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
@ -34,11 +34,11 @@ async def test_device_registry_info(
assert assist_in_progress_state is not None
assert assist_in_progress_state.state == STATE_OFF
satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass)
assert satellite_enabled_id
satellite_enabled_state = hass.states.get(satellite_enabled_id)
assert satellite_enabled_state is not None
assert satellite_enabled_state.state == STATE_ON
muted_id = satellite_device.get_muted_entity_id(hass)
assert muted_id
muted_state = hass.states.get(muted_id)
assert muted_state is not None
assert muted_state.state == STATE_OFF
pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass)
assert pipeline_entity_id
@ -59,9 +59,9 @@ async def test_remove_device_registry_entry(
assert assist_in_progress_id
assert hass.states.get(assist_in_progress_id) is not None
satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass)
assert satellite_enabled_id
assert hass.states.get(satellite_enabled_id) is not None
muted_id = satellite_device.get_muted_entity_id(hass)
assert muted_id
assert hass.states.get(muted_id) is not None
pipeline_entity_id = satellite_device.get_pipeline_entity_id(hass)
assert pipeline_entity_id
@ -74,5 +74,5 @@ async def test_remove_device_registry_entry(
# Everything should be gone
assert hass.states.get(assist_in_progress_id) is None
assert hass.states.get(satellite_enabled_id) is None
assert hass.states.get(muted_id) is None
assert hass.states.get(pipeline_entity_id) is None

View File

@ -196,7 +196,7 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None:
await mock_client.detect_event.wait()
assert not device.is_active
assert device.is_enabled
assert not device.is_muted
# Wake word is detected
event_callback(
@ -312,35 +312,36 @@ async def test_satellite_pipeline(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
async def test_satellite_disabled(hass: HomeAssistant) -> None:
"""Test callback for a satellite that has been disabled."""
on_disabled_event = asyncio.Event()
async def test_satellite_muted(hass: HomeAssistant) -> None:
"""Test callback for a satellite that has been muted."""
on_muted_event = asyncio.Event()
original_make_satellite = wyoming._make_satellite
def make_disabled_satellite(
def make_muted_satellite(
hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService
):
satellite = original_make_satellite(hass, config_entry, service)
satellite.device.is_enabled = False
satellite.device.set_is_muted(True)
return satellite
async def on_disabled(self):
on_disabled_event.set()
async def on_muted(self):
self.device.set_is_muted(False)
on_muted_event.set()
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
return_value=SATELLITE_INFO,
), patch(
"homeassistant.components.wyoming._make_satellite", make_disabled_satellite
"homeassistant.components.wyoming._make_satellite", make_muted_satellite
), patch(
"homeassistant.components.wyoming.satellite.WyomingSatellite.on_disabled",
on_disabled,
"homeassistant.components.wyoming.satellite.WyomingSatellite.on_muted",
on_muted,
):
await setup_config_entry(hass)
async with asyncio.timeout(1):
await on_disabled_event.wait()
await on_muted_event.wait()
async def test_satellite_restart(hass: HomeAssistant) -> None:
@ -368,11 +369,19 @@ async def test_satellite_restart(hass: HomeAssistant) -> None:
async def test_satellite_reconnect(hass: HomeAssistant) -> None:
"""Test satellite reconnect call after connection refused."""
on_reconnect_event = asyncio.Event()
num_reconnects = 0
reconnect_event = asyncio.Event()
stopped_event = asyncio.Event()
async def on_reconnect(self):
nonlocal num_reconnects
num_reconnects += 1
if num_reconnects >= 2:
reconnect_event.set()
self.stop()
on_reconnect_event.set()
async def on_stopped(self):
stopped_event.set()
with patch(
"homeassistant.components.wyoming.data.load_wyoming_info",
@ -383,10 +392,14 @@ async def test_satellite_reconnect(hass: HomeAssistant) -> None:
), patch(
"homeassistant.components.wyoming.satellite.WyomingSatellite.on_reconnect",
on_reconnect,
), patch(
"homeassistant.components.wyoming.satellite.WyomingSatellite.on_stopped",
on_stopped,
):
await setup_config_entry(hass)
async with asyncio.timeout(1):
await on_reconnect_event.wait()
await reconnect_event.wait()
await stopped_event.wait()
async def test_satellite_disconnect_before_pipeline(hass: HomeAssistant) -> None:

View File

@ -5,28 +5,28 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
async def test_satellite_enabled(
async def test_muted(
hass: HomeAssistant,
satellite_config_entry: ConfigEntry,
satellite_device: SatelliteDevice,
) -> None:
"""Test satellite enabled."""
satellite_enabled_id = satellite_device.get_satellite_enabled_entity_id(hass)
assert satellite_enabled_id
"""Test satellite muted."""
muted_id = satellite_device.get_muted_entity_id(hass)
assert muted_id
state = hass.states.get(satellite_enabled_id)
state = hass.states.get(muted_id)
assert state is not None
assert state.state == STATE_ON
assert satellite_device.is_enabled
assert state.state == STATE_OFF
assert not satellite_device.is_muted
await hass.services.async_call(
"switch",
"turn_off",
{"entity_id": satellite_enabled_id},
"turn_on",
{"entity_id": muted_id},
blocking=True,
)
state = hass.states.get(satellite_enabled_id)
state = hass.states.get(muted_id)
assert state is not None
assert state.state == STATE_OFF
assert not satellite_device.is_enabled
assert state.state == STATE_ON
assert satellite_device.is_muted

View File

@ -46,7 +46,7 @@ def disable_request_retry_delay():
with patch(
"homeassistant.components.zha.core.cluster_handlers.RETRYABLE_REQUEST_DECORATOR",
zigpy.util.retryable_request(tries=3, delay=0),
), patch("homeassistant.components.zha.STARTUP_FAILURE_DELAY_S", 0.01):
):
yield

View File

@ -1,9 +1,8 @@
"""Test ZHA Gateway."""
import asyncio
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from zigpy.application import ControllerApplication
import zigpy.profiles.zha as zha
import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.lighting as lighting
@ -223,48 +222,6 @@ async def test_gateway_create_group_with_id(
assert zha_group.group_id == 0x1234
@patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices",
MagicMock(),
)
@patch(
"homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups",
MagicMock(),
)
@pytest.mark.parametrize(
("device_path", "thread_state", "config_override"),
[
("/dev/ttyUSB0", True, {}),
("socket://192.168.1.123:9999", False, {}),
("socket://192.168.1.123:9999", True, {"use_thread": True}),
],
)
async def test_gateway_initialize_bellows_thread(
device_path: str,
thread_state: bool,
config_override: dict,
hass: HomeAssistant,
zigpy_app_controller: ControllerApplication,
config_entry: MockConfigEntry,
) -> None:
"""Test ZHA disabling the UART thread when connecting to a TCP coordinator."""
config_entry.data = dict(config_entry.data)
config_entry.data["device"]["path"] = device_path
config_entry.add_to_hass(hass)
zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry)
with patch(
"bellows.zigbee.application.ControllerApplication.new",
return_value=zigpy_app_controller,
) as mock_new:
await zha_gateway.async_initialize()
mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state
await zha_gateway.shutdown()
@pytest.mark.parametrize(
("device_path", "config_override", "expected_channel"),
[

View File

@ -95,7 +95,6 @@ def test_detect_radio_hardware_failure(hass: HomeAssistant) -> None:
assert _detect_radio_hardware(hass, SKYCONNECT_DEVICE) == HardwareType.OTHER
@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1)
@pytest.mark.parametrize(
("detected_hardware", "expected_learn_more_url"),
[
@ -176,7 +175,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure(
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.SETUP_ERROR
assert config_entry.state == ConfigEntryState.SETUP_RETRY
await hass.config_entries.async_unload(config_entry.entry_id)
@ -189,7 +188,6 @@ async def test_multipan_firmware_no_repair_on_probe_failure(
assert issue is None
@patch("homeassistant.components.zha.STARTUP_RETRIES", new=1)
async def test_multipan_firmware_retry_on_probe_ezsp(
hass: HomeAssistant,
config_entry: MockConfigEntry,