mirror of
https://github.com/home-assistant/core.git
synced 2026-05-22 08:45:16 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7236b534a8 | |||
| cb54fd4921 | |||
| b391fc61ea | |||
| fcd4e4939c | |||
| deb8b5da05 | |||
| c7754a6ce9 | |||
| 242724bd50 | |||
| 42454563db | |||
| bf03d0c216 | |||
| 568107e06b | |||
| 7da44428b6 | |||
| 0a27f31949 | |||
| 905b868c82 | |||
| 3187289913 | |||
| 87cecd4a44 | |||
| fed38b0e38 | |||
| 6a36d1260b | |||
| 49fc1b413d | |||
| bffb0417cc | |||
| 8b8c687fc3 |
@@ -25,6 +25,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -15,6 +15,7 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
|
||||
|
||||
- When entering a new environment or worktree, run `script/setup` to set up the virtual environment with all development dependencies (pylint, pre-commit hooks, etc.). This is required before committing.
|
||||
- .vscode/tasks.json contains useful commands used for development.
|
||||
- After finishing a code session, run `uv run prek run --all-files` to check for linting and formatting issues.
|
||||
|
||||
## Python Syntax Notes
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.29.11",
|
||||
"dbus-fast==5.0.0",
|
||||
"habluetooth==6.1.0"
|
||||
"dbus-fast==5.0.3",
|
||||
"habluetooth==6.2.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ from homeassistant.components.climate import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.const import ATTR_LOCKED, ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DeconzConfigEntry
|
||||
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
|
||||
from .const import ATTR_OFFSET, ATTR_VALVE
|
||||
from .entity import DeconzDevice
|
||||
from .hub import DeconzHub
|
||||
|
||||
|
||||
@@ -43,8 +43,6 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
ATTR_DARK = "dark"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCKED = "locked"
|
||||
ATTR_OFFSET = "offset"
|
||||
ATTR_ON = "on"
|
||||
ATTR_VALVE = "valve"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.2.1",
|
||||
"aiodiscover==3.2.0",
|
||||
"aiodiscover==3.2.3",
|
||||
"cached-ipaddress==1.0.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
|
||||
import aiodns
|
||||
from aiodns.error import DNSError
|
||||
from pycares import AresError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PORT
|
||||
@@ -78,11 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DnsIPConfigEntry) -> boo
|
||||
) from err
|
||||
|
||||
errors = [
|
||||
result
|
||||
for result in results
|
||||
if isinstance(
|
||||
result, (TimeoutError, DNSError, AresError, asyncio.CancelledError)
|
||||
)
|
||||
result for result in results if isinstance(result, (TimeoutError, DNSError))
|
||||
]
|
||||
if errors and len(errors) == len(results):
|
||||
await _close_resolvers()
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["guntamatic==1.8.0"]
|
||||
"requirements": ["guntamatic==1.9.0"]
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow, llm
|
||||
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .const import CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
|
||||
from .coordinator import ModelContextProtocolCoordinator, TokenManager
|
||||
from .types import ModelContextProtocolConfigEntry
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from yarl import URL
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -24,13 +24,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
|
||||
from . import async_get_config_entry_implementation
|
||||
from .application_credentials import authorization_server_context
|
||||
from .const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_AUTHORIZATION_URL,
|
||||
CONF_SCOPE,
|
||||
CONF_TOKEN_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import CONF_AUTHORIZATION_URL, CONF_SCOPE, CONF_TOKEN_URL, DOMAIN
|
||||
from .coordinator import TokenManager, mcp_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
DOMAIN = "mcp"
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_ACCESS_TOKEN = "access_token"
|
||||
CONF_AUTHORIZATION_URL = "authorization_url"
|
||||
CONF_TOKEN_URL = "token_url"
|
||||
CONF_SCOPE = "scope"
|
||||
|
||||
@@ -41,7 +41,7 @@ from mcp.shared.message import SessionMessage
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONTENT_TYPE_JSON
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
@@ -56,10 +56,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
STREAMABLE_API = "/api/mcp"
|
||||
TIMEOUT = 60 # Seconds
|
||||
|
||||
# Content types
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONTENT_TYPE_JSON = "application/json"
|
||||
|
||||
# Legacy SSE endpoint
|
||||
SSE_API = f"/{DOMAIN}/sse"
|
||||
MESSAGES_API = f"/{DOMAIN}/messages/{{session_id}}"
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["opendisplay"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["py-opendisplay==5.9.0"]
|
||||
"requirements": ["py-opendisplay==7.2.3"]
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ async def _async_upload_image(call: ServiceCall) -> None:
|
||||
pil_image,
|
||||
refresh_mode=refresh_mode,
|
||||
dither_mode=dither_mode,
|
||||
tone_compression=tone_compression,
|
||||
tone=tone_compression,
|
||||
fit=fit_mode,
|
||||
rotate=rotation,
|
||||
)
|
||||
|
||||
@@ -118,6 +118,9 @@
|
||||
"services": {
|
||||
"prune_images": {
|
||||
"service": "mdi:delete-sweep"
|
||||
},
|
||||
"recreate_container": {
|
||||
"service": "mdi:restart"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ from .coordinator import PortainerConfigEntry
|
||||
|
||||
ATTR_DATE_UNTIL = "until"
|
||||
ATTR_DANGLING = "dangling"
|
||||
ATTR_TIMEOUT = "timeout"
|
||||
ATTR_PULL_IMAGE = "pull_image"
|
||||
ATTR_CONTAINER_DEVICE_ID = "container_device_id"
|
||||
|
||||
SERVICE_PRUNE_IMAGES = "prune_images"
|
||||
SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
|
||||
@@ -32,6 +35,17 @@ SERVICE_PRUNE_IMAGES_SCHEMA = vol.Schema(
|
||||
},
|
||||
)
|
||||
|
||||
SERVICE_RECREATE_CONTAINER = "recreate_container"
|
||||
SERVICE_RECREATE_CONTAINER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_CONTAINER_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_TIMEOUT): vol.All(
|
||||
cv.time_period, vol.Range(min=timedelta(minutes=1))
|
||||
),
|
||||
vol.Optional(ATTR_PULL_IMAGE): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def _extract_config_entry(service_call: ServiceCall) -> PortainerConfigEntry:
|
||||
"""Extract config entry from the service call."""
|
||||
@@ -75,6 +89,45 @@ async def _get_endpoint_id(
|
||||
return endpoint_data.endpoint.id
|
||||
|
||||
|
||||
async def _get_container_and_endpoint_ids(
|
||||
call: ServiceCall,
|
||||
) -> tuple[PortainerConfigEntry, int, str]:
|
||||
"""Get config entry, endpoint ID and container ID from the container device ID."""
|
||||
device_reg = dr.async_get(call.hass)
|
||||
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
config_entry: PortainerConfigEntry | None = None
|
||||
for loaded_entry in call.hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
if loaded_entry.entry_id in device.config_entries:
|
||||
config_entry = loaded_entry
|
||||
break
|
||||
|
||||
if config_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
for data in coordinator.data.values():
|
||||
for container_name, container_data in data.containers.items():
|
||||
if (
|
||||
DOMAIN,
|
||||
f"{config_entry.entry_id}_{data.endpoint.id}_{container_name}",
|
||||
) in device.identifiers:
|
||||
return config_entry, data.endpoint.id, container_data.container.id
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
|
||||
async def prune_images(call: ServiceCall) -> None:
|
||||
"""Prune unused images in Portainer, with more controls."""
|
||||
config_entry = await _extract_config_entry(call)
|
||||
@@ -104,6 +157,40 @@ async def prune_images(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
async def recreate_container(call: ServiceCall) -> None:
|
||||
"""Recreate a container in Portainer, with more controls."""
|
||||
config_entry, endpoint_id, container_id = await _get_container_and_endpoint_ids(
|
||||
call
|
||||
)
|
||||
coordinator = config_entry.runtime_data
|
||||
timeout: timedelta | None = call.data.get(ATTR_TIMEOUT)
|
||||
|
||||
try:
|
||||
await coordinator.portainer.container_recreate(
|
||||
endpoint_id=endpoint_id,
|
||||
container_id=container_id,
|
||||
**({"timeout": timeout} if timeout is not None else {}),
|
||||
pull_image=call.data.get(ATTR_PULL_IMAGE, False),
|
||||
)
|
||||
except PortainerAuthenticationError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth_no_details",
|
||||
) from err
|
||||
except PortainerConnectionError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_no_details",
|
||||
) from err
|
||||
except PortainerTimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="timeout_connect_no_details",
|
||||
) from err
|
||||
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
|
||||
@@ -113,3 +200,10 @@ async def async_setup_services(hass: HomeAssistant) -> None:
|
||||
prune_images,
|
||||
SERVICE_PRUNE_IMAGES_SCHEMA,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
recreate_container,
|
||||
SERVICE_RECREATE_CONTAINER_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -16,3 +16,20 @@ prune_images:
|
||||
required: false
|
||||
selector:
|
||||
boolean: {}
|
||||
|
||||
recreate_container:
|
||||
fields:
|
||||
container_device_id:
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: portainer
|
||||
model: Container
|
||||
timeout:
|
||||
required: false
|
||||
selector:
|
||||
duration:
|
||||
pull_image:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
|
||||
@@ -235,6 +235,24 @@
|
||||
}
|
||||
},
|
||||
"name": "Prune unused images"
|
||||
},
|
||||
"recreate_container": {
|
||||
"description": "Recreates a container on a Portainer endpoint. This is more disruptive than a restart as the container will be stopped, removed, and then re-created with the same configuration. Use with caution.",
|
||||
"fields": {
|
||||
"container_device_id": {
|
||||
"description": "The container to recreate.",
|
||||
"name": "Container"
|
||||
},
|
||||
"pull_image": {
|
||||
"description": "Whether to pull the image before recreating the container. This can be used to update the container to the latest version of the image.",
|
||||
"name": "Pull image"
|
||||
},
|
||||
"timeout": {
|
||||
"description": "The time to wait for the container to stop before killing it. If not provided, a default of 5 minutes will be used.",
|
||||
"name": "Timeout"
|
||||
}
|
||||
},
|
||||
"name": "Recreate container"
|
||||
}
|
||||
},
|
||||
"system_health": {
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
CONF_MAC,
|
||||
CONF_NAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
@@ -30,8 +31,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_DEVICE = "device"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL = "model"
|
||||
|
||||
BLE_TEMP_HANDLE = 0x24
|
||||
BLE_TEMP_UUID = "0000ff92-0000-1000-8000-00805f9b34fb"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Define constants for the SleepIQ component."""
|
||||
|
||||
from homeassistant.const import PRESSURE
|
||||
|
||||
DATA_SLEEPIQ = "data_sleepiq"
|
||||
DOMAIN = "sleepiq"
|
||||
|
||||
@@ -11,8 +13,6 @@ FIRMNESS = "firmness"
|
||||
ICON_EMPTY = "mdi:bed-empty"
|
||||
ICON_OCCUPIED = "mdi:bed"
|
||||
IS_IN_BED = "is_in_bed"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
PRESSURE = "pressure"
|
||||
SLEEP_NUMBER = "sleep_number"
|
||||
FOOT_WARMING_TIMER = "foot_warming_timer"
|
||||
FOOT_WARMER = "foot_warmer"
|
||||
|
||||
@@ -11,14 +11,13 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTime
|
||||
from homeassistant.const import PRESSURE, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
HEART_RATE,
|
||||
HRV,
|
||||
PRESSURE,
|
||||
RESPIRATORY_RATE,
|
||||
SLEEP_DURATION,
|
||||
SLEEP_NUMBER,
|
||||
|
||||
@@ -7,6 +7,7 @@ import smarttub
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -19,8 +20,6 @@ from .entity import SmartTubOnboardSensorBase
|
||||
# the desired duration, in hours, of the cycle
|
||||
ATTR_DURATION = "duration"
|
||||
ATTR_CYCLE_LAST_UPDATED = "cycle_last_updated"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODE = "mode"
|
||||
# the hour of the day at which to start the cycle (0-23)
|
||||
ATTR_START_HOUR = "start_hour"
|
||||
|
||||
|
||||
@@ -38,12 +38,8 @@ PLATFORMS = [
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_LOCK = "lock"
|
||||
SERVICE_REMOTE_START = "remote_start"
|
||||
SERVICE_REMOTE_STOP = "remote_stop"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
SERVICE_UNLOCK = "unlock"
|
||||
SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door"
|
||||
|
||||
ATTR_DOOR = "door"
|
||||
|
||||
@@ -4,9 +4,10 @@ import logging
|
||||
|
||||
from subarulink.exceptions import SubaruException
|
||||
|
||||
from homeassistant.const import SERVICE_UNLOCK
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import SERVICE_REMOTE_START, SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN
|
||||
from .const import SERVICE_REMOTE_START, VEHICLE_NAME, VEHICLE_VIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ from surepy.enums import Location
|
||||
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import ATTR_LOCATION, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
ATTR_FLAP_ID,
|
||||
ATTR_LOCATION,
|
||||
ATTR_LOCK_STATE,
|
||||
ATTR_PET_NAME,
|
||||
DOMAIN,
|
||||
|
||||
@@ -18,7 +18,5 @@ SURE_BATT_VOLTAGE_DIFF = SURE_BATT_VOLTAGE_FULL - SURE_BATT_VOLTAGE_LOW
|
||||
SERVICE_SET_LOCK_STATE = "set_lock_state"
|
||||
SERVICE_SET_PET_LOCATION = "set_pet_location"
|
||||
ATTR_FLAP_ID = "flap_id"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_LOCATION = "location"
|
||||
ATTR_LOCK_STATE = "lock_state"
|
||||
ATTR_PET_NAME = "pet_name"
|
||||
|
||||
@@ -8,7 +8,7 @@ from surepy.enums import EntityType, Location, LockState
|
||||
from surepy.exceptions import SurePetcareAuthenticationError, SurePetcareError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.const import ATTR_LOCATION, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -16,7 +16,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import (
|
||||
ATTR_FLAP_ID,
|
||||
ATTR_LOCATION,
|
||||
ATTR_LOCK_STATE,
|
||||
ATTR_PET_NAME,
|
||||
DOMAIN,
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==10.4.1"]
|
||||
"requirements": ["uiprotect==10.5.0"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Automatically generated by gen_requirements_all.py, do not edit
|
||||
|
||||
aiodhcpwatcher==1.2.1
|
||||
aiodiscover==3.2.0
|
||||
aiodiscover==3.2.3
|
||||
aiodns==4.0.4
|
||||
aiogithubapi==26.0.0
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
@@ -30,12 +30,12 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.7
|
||||
cryptography==48.0.0
|
||||
dbus-fast==5.0.0
|
||||
dbus-fast==5.0.3
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.2
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==6.1.0
|
||||
habluetooth==6.2.0
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
@@ -133,7 +133,7 @@ multidict>=6.0.2
|
||||
Brotli>=1.2.0
|
||||
|
||||
# ensure pydantic version does not float since it might have breaking changes
|
||||
pydantic==2.13.2
|
||||
pydantic==2.13.4
|
||||
|
||||
# Required for Python 3.14.0 compatibility (#119223).
|
||||
mashumaro>=3.17.0
|
||||
|
||||
Generated
+6
-6
@@ -233,7 +233,7 @@ aiocomelit==2.0.3
|
||||
aiodhcpwatcher==1.2.1
|
||||
|
||||
# homeassistant.components.dhcp
|
||||
aiodiscover==3.2.0
|
||||
aiodiscover==3.2.3
|
||||
|
||||
# homeassistant.components.dnsip
|
||||
aiodns==4.0.4
|
||||
@@ -794,7 +794,7 @@ datadog==0.52.0
|
||||
datapoint==0.12.1
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
dbus-fast==5.0.0
|
||||
dbus-fast==5.0.3
|
||||
|
||||
# homeassistant.components.debugpy
|
||||
debugpy==1.8.17
|
||||
@@ -1183,7 +1183,7 @@ growattServer==2.1.0
|
||||
gspread==5.5.0
|
||||
|
||||
# homeassistant.components.guntamatic
|
||||
guntamatic==1.8.0
|
||||
guntamatic==1.9.0
|
||||
|
||||
# homeassistant.components.profiler
|
||||
guppy3==3.1.6
|
||||
@@ -1210,7 +1210,7 @@ ha-xthings-cloud==1.0.5
|
||||
habiticalib==0.4.7
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==6.1.0
|
||||
habluetooth==6.2.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.7
|
||||
@@ -1923,7 +1923,7 @@ py-nightscout==1.2.2
|
||||
py-nymta==0.4.0
|
||||
|
||||
# homeassistant.components.opendisplay
|
||||
py-opendisplay==5.9.0
|
||||
py-opendisplay==7.2.3
|
||||
|
||||
# homeassistant.components.schluter
|
||||
py-schluter==0.1.7
|
||||
@@ -3224,7 +3224,7 @@ uasiren==0.0.1
|
||||
uhooapi==1.2.8
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==10.4.1
|
||||
uiprotect==10.5.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
|
||||
@@ -18,7 +18,7 @@ license-expression==30.4.3
|
||||
mock-open==1.4.0
|
||||
mypy==2.1.0
|
||||
prek==0.2.28
|
||||
pydantic==2.13.2
|
||||
pydantic==2.13.4
|
||||
pylint==4.0.5
|
||||
pylint-per-file-ignores==3.2.1
|
||||
pipdeptree==2.26.1
|
||||
|
||||
@@ -117,7 +117,7 @@ multidict>=6.0.2
|
||||
Brotli>=1.2.0
|
||||
|
||||
# ensure pydantic version does not float since it might have breaking changes
|
||||
pydantic==2.13.2
|
||||
pydantic==2.13.4
|
||||
|
||||
# Required for Python 3.14.0 compatibility (#119223).
|
||||
mashumaro>=3.17.0
|
||||
|
||||
+75
-16
@@ -2,13 +2,19 @@
|
||||
"""Helper script to split test into n buckets."""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from math import ceil
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Final
|
||||
|
||||
# tests/components has ~1000 sub-directories, which makes it the natural
|
||||
# place to subdivide to keep each pytest invocation roughly equal in size.
|
||||
_FAN_OUT_DIRS: Final = frozenset({"components"})
|
||||
|
||||
|
||||
class Bucket:
|
||||
"""Class to hold bucket."""
|
||||
@@ -164,33 +170,86 @@ class TestFolder:
|
||||
return result
|
||||
|
||||
|
||||
def collect_tests(path: Path) -> TestFolder:
|
||||
"""Collect all tests."""
|
||||
def _collect_batch(paths: list[Path]) -> tuple[str, str, int]:
|
||||
"""Run pytest --collect-only on a batch of paths."""
|
||||
result = subprocess.run(
|
||||
["pytest", "--collect-only", "-qq", "-p", "no:warnings", path],
|
||||
["pytest", "--collect-only", "-qq", "-p", "no:warnings", *map(str, paths)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout, result.stderr, result.returncode
|
||||
|
||||
if result.returncode != 0:
|
||||
print("Failed to collect tests:")
|
||||
print(result.stderr)
|
||||
print(result.stdout)
|
||||
|
||||
def _iter_eligible_children(path: Path) -> list[Path]:
|
||||
"""Return immediate children of ``path`` that pytest should collect.
|
||||
|
||||
Filters out hidden/dunder entries, non-``test_*.py`` files (so helper
|
||||
modules like ``conftest.py`` and ``common.py`` are not passed as
|
||||
explicit collection targets), and pycache-style directories.
|
||||
"""
|
||||
children: list[Path] = []
|
||||
for entry in sorted(path.iterdir()):
|
||||
if entry.name.startswith((".", "_")):
|
||||
continue
|
||||
if entry.is_dir() or (entry.suffix == ".py" and entry.name.startswith("test_")):
|
||||
children.append(entry)
|
||||
return children
|
||||
|
||||
|
||||
def _enumerate_batch_paths(path: Path) -> list[Path]:
|
||||
"""Return the child paths to run pytest --collect-only over.
|
||||
|
||||
Files are returned as-is. Directories are expanded one level deep, with
|
||||
a second level of expansion for entries named in ``_FAN_OUT_DIRS`` so the
|
||||
enormous ``tests/components`` tree fans out into per-integration paths.
|
||||
"""
|
||||
if path.is_file():
|
||||
return [path]
|
||||
|
||||
paths: list[Path] = []
|
||||
for entry in _iter_eligible_children(path):
|
||||
if entry.is_dir() and entry.name in _FAN_OUT_DIRS:
|
||||
paths.extend(_iter_eligible_children(entry))
|
||||
else:
|
||||
paths.append(entry)
|
||||
return paths
|
||||
|
||||
|
||||
def collect_tests(path: Path) -> TestFolder:
|
||||
"""Collect all tests."""
|
||||
batch_paths = _enumerate_batch_paths(path)
|
||||
if not batch_paths:
|
||||
print(f"No eligible test paths found under {path}")
|
||||
sys.exit(1)
|
||||
workers = min(len(batch_paths), os.cpu_count() or 1) or 1
|
||||
# Round-robin chunking keeps batches roughly balanced when path
|
||||
# ordering correlates with test size.
|
||||
batches = [batch_paths[i::workers] for i in range(workers)]
|
||||
|
||||
if workers == 1:
|
||||
results = [_collect_batch(batches[0])]
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
results = list(executor.map(_collect_batch, batches))
|
||||
|
||||
folder = TestFolder(path)
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not path or not total_tests:
|
||||
print(f"Unexpected line: {line}")
|
||||
for stdout, stderr, returncode in results:
|
||||
if returncode != 0:
|
||||
print("Failed to collect tests:")
|
||||
print(stderr)
|
||||
print(stdout)
|
||||
sys.exit(1)
|
||||
for line in stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not file_path or not total_tests:
|
||||
print(f"Unexpected line: {line}")
|
||||
sys.exit(1)
|
||||
|
||||
file = TestFile(int(total_tests), Path(file_path))
|
||||
folder.add_test_file(file)
|
||||
file = TestFile(int(total_tests), Path(file_path))
|
||||
folder.add_test_file(file)
|
||||
|
||||
return folder
|
||||
|
||||
|
||||
@@ -182,8 +182,13 @@ async def test_diagnostics(
|
||||
"scanners": [
|
||||
{
|
||||
"adapter": "hci0",
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"discovered_devices_and_advertisement_data": [],
|
||||
"last_connect_completed_time": 0.0,
|
||||
"last_detection": ANY,
|
||||
"monotonic_time": ANY,
|
||||
"name": "hci0 (00:00:00:00:00:01)",
|
||||
@@ -202,6 +207,11 @@ async def test_diagnostics(
|
||||
},
|
||||
{
|
||||
"adapter": "hci1",
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"last_connect_completed_time": 0.0,
|
||||
"discovered_devices_and_advertisement_data": [
|
||||
{
|
||||
"address": "44:44:33:11:23:45",
|
||||
@@ -397,7 +407,12 @@ async def test_diagnostics_macos(
|
||||
"scanners": [
|
||||
{
|
||||
"adapter": "Core Bluetooth",
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"last_connect_completed_time": 0.0,
|
||||
"discovered_devices_and_advertisement_data": [
|
||||
{
|
||||
"address": "44:44:33:11:23:45",
|
||||
@@ -602,8 +617,13 @@ async def test_diagnostics_remote_adapter(
|
||||
"scanners": [
|
||||
{
|
||||
"adapter": "hci0",
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"discovered_devices_and_advertisement_data": [],
|
||||
"last_connect_completed_time": 0.0,
|
||||
"last_detection": ANY,
|
||||
"monotonic_time": ANY,
|
||||
"name": "hci0 (00:00:00:00:00:01)",
|
||||
@@ -621,9 +641,14 @@ async def test_diagnostics_remote_adapter(
|
||||
},
|
||||
},
|
||||
{
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"current_mode": None,
|
||||
"requested_mode": None,
|
||||
"last_connect_completed_time": 0.0,
|
||||
"discovered_device_timestamps": {"44:44:33:11:23:45": ANY},
|
||||
"discovered_devices_and_advertisement_data": [
|
||||
{
|
||||
|
||||
@@ -138,8 +138,10 @@ async def test_setup_and_stop_passive(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert init_kwargs == {
|
||||
"adapter": "hci0",
|
||||
"bluez": scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
|
||||
"bluez": {
|
||||
**scanner.PASSIVE_SCANNER_ARGS, # pylint: disable=c-extension-no-member
|
||||
"adapter": "hci0",
|
||||
},
|
||||
"scanning_mode": "passive",
|
||||
}
|
||||
|
||||
@@ -188,7 +190,7 @@ async def test_setup_and_stop_old_bluez(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert init_kwargs == {
|
||||
"adapter": "hci0",
|
||||
"bluez": {"adapter": "hci0"},
|
||||
"scanning_mode": "active",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Test for DNS IP integration Init."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiodns.error import DNSError
|
||||
from pycares import AresError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.dnsip.const import (
|
||||
@@ -180,8 +178,6 @@ async def test_migrate_error_from_future(hass: HomeAssistant) -> None:
|
||||
[
|
||||
TimeoutError(),
|
||||
DNSError(),
|
||||
AresError(),
|
||||
asyncio.CancelledError(),
|
||||
],
|
||||
)
|
||||
async def test_setup_dns_error(hass: HomeAssistant, error: Exception) -> None:
|
||||
|
||||
@@ -81,11 +81,16 @@ async def test_diagnostics_with_bluetooth(
|
||||
"connections_free": 0,
|
||||
"connections_limit": 0,
|
||||
"scanner": {
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": True,
|
||||
"current_mode": None,
|
||||
"requested_mode": None,
|
||||
"discovered_device_timestamps": {},
|
||||
"discovered_devices_and_advertisement_data": [],
|
||||
"last_connect_completed_time": 0.0,
|
||||
"last_detection": ANY,
|
||||
"monotonic_time": ANY,
|
||||
"name": "test (AA:BB:CC:DD:EE:FC)",
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""The tests the History component websocket_api."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
@@ -12,12 +9,7 @@ import pytest
|
||||
|
||||
from homeassistant.components import history
|
||||
from homeassistant.components.history import websocket_api
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_FINAL_WRITE,
|
||||
EVENT_STATE_CHANGED,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -43,27 +35,6 @@ def listeners_without_writes(listeners: dict[str, int]) -> dict[str, int]:
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def assert_no_listener_leak(hass: HomeAssistant) -> Iterator[None]:
|
||||
"""Capture bus listeners on entry, assert no leak on exit.
|
||||
|
||||
EVENT_STATE_CHANGED is excluded because unrelated components can
|
||||
asynchronously add or remove state_changed listeners during a test.
|
||||
"""
|
||||
excluded = {EVENT_HOMEASSISTANT_FINAL_WRITE, EVENT_STATE_CHANGED}
|
||||
|
||||
def _snapshot() -> dict[str, int]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in hass.bus.async_listeners().items()
|
||||
if key not in excluded
|
||||
}
|
||||
|
||||
before = _snapshot()
|
||||
yield
|
||||
assert _snapshot() == before
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass_history")
|
||||
def test_setup() -> None:
|
||||
"""Test setup method of history."""
|
||||
@@ -1600,28 +1571,7 @@ async def test_overflow_queue(
|
||||
"""Test overflowing the history stream queue."""
|
||||
now = dt_util.utcnow()
|
||||
wanted_entities = ["sensor.two", "sensor.four", "sensor.one"]
|
||||
|
||||
unsub_calls = 0
|
||||
|
||||
def spy_track_state_change_event(*args: Any, **kwargs: Any) -> Callable[[], None]:
|
||||
nonlocal unsub_calls
|
||||
real_unsub = async_track_state_change_event(*args, **kwargs)
|
||||
|
||||
def wrapped_unsub() -> None:
|
||||
nonlocal unsub_calls
|
||||
unsub_calls += 1
|
||||
real_unsub()
|
||||
|
||||
return wrapped_unsub
|
||||
|
||||
with (
|
||||
patch.object(websocket_api, "MAX_PENDING_HISTORY_STATES", 5),
|
||||
patch.object(
|
||||
websocket_api,
|
||||
"async_track_state_change_event",
|
||||
spy_track_state_change_event,
|
||||
),
|
||||
):
|
||||
with patch.object(websocket_api, "MAX_PENDING_HISTORY_STATES", 5):
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"history",
|
||||
@@ -1645,63 +1595,61 @@ async def test_overflow_queue(
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
client = await hass_ws_client()
|
||||
init_listeners = hass.bus.async_listeners()
|
||||
|
||||
with assert_no_listener_leak(hass):
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "history/stream",
|
||||
"entity_ids": wanted_entities,
|
||||
"start_time": now.isoformat(),
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": False,
|
||||
"no_attributes": True,
|
||||
"minimal_response": True,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 1
|
||||
assert response["type"] == "result"
|
||||
|
||||
response = await client.receive_json()
|
||||
first_end_time = sensor_two_last_updated_timestamp
|
||||
|
||||
assert response == {
|
||||
"event": {
|
||||
"end_time": pytest.approx(first_end_time),
|
||||
"start_time": pytest.approx(now.timestamp()),
|
||||
"states": {
|
||||
"sensor.one": [
|
||||
{
|
||||
"lu": pytest.approx(sensor_one_last_updated_timestamp),
|
||||
"s": "on",
|
||||
}
|
||||
],
|
||||
"sensor.two": [
|
||||
{
|
||||
"lu": pytest.approx(sensor_two_last_updated_timestamp),
|
||||
"s": "off",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "event",
|
||||
"type": "history/stream",
|
||||
"entity_ids": wanted_entities,
|
||||
"start_time": now.isoformat(),
|
||||
"include_start_time_state": True,
|
||||
"significant_changes_only": False,
|
||||
"no_attributes": True,
|
||||
"minimal_response": True,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["id"] == 1
|
||||
assert response["type"] == "result"
|
||||
|
||||
await async_recorder_block_till_done(hass)
|
||||
# Overflow the queue
|
||||
for val in range(10):
|
||||
hass.states.async_set(
|
||||
"sensor.one", str(val), attributes={"any": "attr"}
|
||||
)
|
||||
hass.states.async_set(
|
||||
"sensor.two", str(val), attributes={"any": "attr"}
|
||||
)
|
||||
await async_recorder_block_till_done(hass)
|
||||
response = await client.receive_json()
|
||||
first_end_time = sensor_two_last_updated_timestamp
|
||||
|
||||
assert unsub_calls == 1
|
||||
assert response == {
|
||||
"event": {
|
||||
"end_time": pytest.approx(first_end_time),
|
||||
"start_time": pytest.approx(now.timestamp()),
|
||||
"states": {
|
||||
"sensor.one": [
|
||||
{
|
||||
"lu": pytest.approx(sensor_one_last_updated_timestamp),
|
||||
"s": "on",
|
||||
}
|
||||
],
|
||||
"sensor.two": [
|
||||
{
|
||||
"lu": pytest.approx(sensor_two_last_updated_timestamp),
|
||||
"s": "off",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
"id": 1,
|
||||
"type": "event",
|
||||
}
|
||||
|
||||
await async_recorder_block_till_done(hass)
|
||||
# Overflow the queue
|
||||
for val in range(10):
|
||||
hass.states.async_set("sensor.one", str(val), attributes={"any": "attr"})
|
||||
hass.states.async_set("sensor.two", str(val), attributes={"any": "attr"})
|
||||
await async_recorder_block_till_done(hass)
|
||||
|
||||
assert listeners_without_writes(
|
||||
hass.bus.async_listeners()
|
||||
) == listeners_without_writes(init_listeners)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("recorder_mock")
|
||||
|
||||
@@ -13,13 +13,12 @@ from homeassistant.components.application_credentials import (
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.mcp.const import (
|
||||
CONF_ACCESS_TOKEN,
|
||||
CONF_AUTHORIZATION_URL,
|
||||
CONF_SCOPE,
|
||||
CONF_TOKEN_URL,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_TOKEN, CONF_URL
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ MOCK_TEST_CONFIG = {
|
||||
|
||||
TEST_ENTRY = "portainer_test_entry_123"
|
||||
TEST_INSTANCE_ID = "299ab403-70a8-4c05-92f7-bf7a994d50df"
|
||||
TEST_CONTAINER_NAME = "practical_morse"
|
||||
TEST_CONTAINER_ID = "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -93,6 +95,7 @@ def mock_portainer_client() -> Generator[AsyncMock]:
|
||||
client.stop_container = AsyncMock(return_value=None)
|
||||
client.start_stack = AsyncMock(return_value=None)
|
||||
client.stop_stack = AsyncMock(return_value=None)
|
||||
client.container_recreate = AsyncMock(return_value=None)
|
||||
|
||||
yield client
|
||||
|
||||
|
||||
@@ -13,9 +13,13 @@ from voluptuous import MultipleInvalid
|
||||
|
||||
from homeassistant.components.portainer.const import DOMAIN
|
||||
from homeassistant.components.portainer.services import (
|
||||
ATTR_CONTAINER_DEVICE_ID,
|
||||
ATTR_DANGLING,
|
||||
ATTR_DATE_UNTIL,
|
||||
ATTR_PULL_IMAGE,
|
||||
ATTR_TIMEOUT,
|
||||
SERVICE_PRUNE_IMAGES,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
)
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -23,13 +27,17 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers.device_registry import DeviceRegistry
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import TEST_ENTRY
|
||||
from .conftest import TEST_CONTAINER_ID, TEST_CONTAINER_NAME, TEST_ENTRY
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_ENDPOINT_ID = 1
|
||||
TEST_DEVICE_IDENTIFIER = f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}"
|
||||
|
||||
TEST_CONTAINER_DEVICE_IDENTIFIER = (
|
||||
f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}_{TEST_CONTAINER_NAME}"
|
||||
)
|
||||
|
||||
|
||||
async def test_services(
|
||||
hass: HomeAssistant,
|
||||
@@ -102,6 +110,99 @@ async def test_service_prune_images(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("call_arguments", "extra_expected_kwargs"),
|
||||
[
|
||||
({}, {"pull_image": False}),
|
||||
(
|
||||
{ATTR_TIMEOUT: timedelta(minutes=10)},
|
||||
{"pull_image": False, "timeout": timedelta(minutes=10)},
|
||||
),
|
||||
(
|
||||
{ATTR_TIMEOUT: timedelta(minutes=12), ATTR_PULL_IMAGE: True},
|
||||
{"pull_image": True, "timeout": timedelta(minutes=12)},
|
||||
),
|
||||
],
|
||||
ids=["no optional", "with duration", "with duration and pull_image"],
|
||||
)
|
||||
async def test_service_recreate_container(
|
||||
hass: HomeAssistant,
|
||||
device_registry: DeviceRegistry,
|
||||
mock_portainer_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
call_arguments: dict,
|
||||
extra_expected_kwargs: dict,
|
||||
) -> None:
|
||||
"""Test recreate container service with the variants."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
container = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
|
||||
)
|
||||
assert container is not None
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{
|
||||
ATTR_CONTAINER_DEVICE_ID: container.id,
|
||||
**call_arguments,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_called_once_with(
|
||||
endpoint_id=TEST_ENDPOINT_ID,
|
||||
container_id=TEST_CONTAINER_ID,
|
||||
**extra_expected_kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "translation_key"),
|
||||
[
|
||||
(
|
||||
PortainerAuthenticationError("auth"),
|
||||
"invalid_auth_no_details",
|
||||
),
|
||||
(
|
||||
PortainerConnectionError("conn"),
|
||||
"cannot_connect_no_details",
|
||||
),
|
||||
(
|
||||
PortainerTimeoutError("timeout"),
|
||||
"timeout_connect_no_details",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_service_recreate_container_portainer_exceptions(
|
||||
hass: HomeAssistant,
|
||||
device_registry: DeviceRegistry,
|
||||
mock_portainer_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
exception: PortainerAuthenticationError
|
||||
| PortainerConnectionError
|
||||
| PortainerTimeoutError,
|
||||
translation_key: str,
|
||||
) -> None:
|
||||
"""Test recreate container service handles Portainer exceptions."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
container = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
|
||||
)
|
||||
assert container is not None
|
||||
|
||||
mock_portainer_client.container_recreate.side_effect = exception
|
||||
with pytest.raises(HomeAssistantError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{ATTR_CONTAINER_DEVICE_ID: container.id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert err.value.translation_key == translation_key
|
||||
mock_portainer_client.container_recreate.assert_called_once()
|
||||
|
||||
|
||||
async def test_service_validation_errors(
|
||||
hass: HomeAssistant,
|
||||
device_registry: DeviceRegistry,
|
||||
@@ -115,8 +216,11 @@ async def test_service_validation_errors(
|
||||
identifiers={(DOMAIN, TEST_DEVICE_IDENTIFIER)}
|
||||
)
|
||||
assert device is not None
|
||||
container = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, TEST_CONTAINER_DEVICE_IDENTIFIER)}
|
||||
)
|
||||
assert container is not None
|
||||
|
||||
# Test missing device_id
|
||||
with pytest.raises(MultipleInvalid, match="required key not provided"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
@@ -126,7 +230,6 @@ async def test_service_validation_errors(
|
||||
)
|
||||
mock_portainer_client.images_prune.assert_not_called()
|
||||
|
||||
# Test invalid until (too short, needs to be at least 1 minute)
|
||||
with pytest.raises(MultipleInvalid, match="value must be at least"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
@@ -136,7 +239,6 @@ async def test_service_validation_errors(
|
||||
)
|
||||
mock_portainer_client.images_prune.assert_not_called()
|
||||
|
||||
# Test invalid device
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
@@ -146,6 +248,39 @@ async def test_service_validation_errors(
|
||||
)
|
||||
mock_portainer_client.images_prune.assert_not_called()
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{ATTR_CONTAINER_DEVICE_ID: "invalid_device_id"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_not_called()
|
||||
|
||||
other_entry = MockConfigEntry(domain="well_no_portainer_for_sure")
|
||||
other_entry.add_to_hass(hass)
|
||||
non_portainer_device = device_registry.async_get_or_create(
|
||||
config_entry_id=other_entry.entry_id,
|
||||
identifiers={("well_no_portainer_for_sure", "some_identifier")},
|
||||
)
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{ATTR_CONTAINER_DEVICE_ID: non_portainer_device.id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_not_called()
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
{ATTR_CONTAINER_DEVICE_ID: device.id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "message"),
|
||||
|
||||
@@ -113,6 +113,10 @@ async def test_rpc_config_entry_diagnostics(
|
||||
"entry": entry_dict | {"discovery_keys": {}},
|
||||
"bluetooth": {
|
||||
"scanner": {
|
||||
"connect_completed_total": 0,
|
||||
"connect_failed_total": 0,
|
||||
"connect_failures": {},
|
||||
"connect_in_progress": {},
|
||||
"connectable": False,
|
||||
"current_mode": {
|
||||
"__type": "<enum 'BluetoothScanningMode'>",
|
||||
@@ -122,6 +126,7 @@ async def test_rpc_config_entry_diagnostics(
|
||||
"__type": "<enum 'BluetoothScanningMode'>",
|
||||
"repr": "<BluetoothScanningMode.ACTIVE: 'active'>",
|
||||
},
|
||||
"last_connect_completed_time": 0.0,
|
||||
"discovered_device_timestamps": {"AA:BB:CC:DD:EE:FF": ANY},
|
||||
"discovered_devices_and_advertisement_data": [
|
||||
{
|
||||
|
||||
@@ -6,15 +6,10 @@ from asyncsleepiq import (
|
||||
SleepIQTimeoutException,
|
||||
)
|
||||
|
||||
from homeassistant.components.sleepiq.const import (
|
||||
DOMAIN,
|
||||
IS_IN_BED,
|
||||
PRESSURE,
|
||||
SLEEP_NUMBER,
|
||||
)
|
||||
from homeassistant.components.sleepiq.const import DOMAIN, IS_IN_BED, SLEEP_NUMBER
|
||||
from homeassistant.components.sleepiq.coordinator import UPDATE_INTERVAL
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.const import CONF_USERNAME, PRESSURE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
Reference in New Issue
Block a user