mirror of
https://github.com/home-assistant/core.git
synced 2026-05-23 01:05:20 +02:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b56c87d4c | |||
| 3e892e3748 | |||
| adda8978ca | |||
| befecb3d40 | |||
| 84fd027082 | |||
| 97710425db | |||
| aa62d1dff8 | |||
| ba4a67f503 | |||
| ce135ccafa | |||
| b3a07fb123 | |||
| d0138679ce | |||
| 14defc4486 | |||
| f8d8daa136 | |||
| 2d8781ef9d | |||
| 416a3b2c56 | |||
| 8bae4774d7 | |||
| 74fba71ff4 | |||
| 7e8c889c26 | |||
| 49bf5b86be | |||
| 9bcebd2918 | |||
| 7104ee5f8d | |||
| bff7d0ef35 | |||
| 2d71439385 | |||
| 95bcfe464f | |||
| fd4b7e4adf | |||
| fd8a99140f | |||
| 1ef3301253 | |||
| 525952f016 | |||
| 3257275c5a | |||
| 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
|
||||
|
||||
|
||||
Generated
+2
@@ -2056,6 +2056,8 @@ CLAUDE.md @home-assistant/core
|
||||
/homeassistant/components/yi/ @bachya
|
||||
/homeassistant/components/yolink/ @matrixd2
|
||||
/tests/components/yolink/ @matrixd2
|
||||
/homeassistant/components/yoto/ @cdnninja @piitaya
|
||||
/tests/components/yoto/ @cdnninja @piitaya
|
||||
/homeassistant/components/youless/ @gjong
|
||||
/tests/components/youless/ @gjong
|
||||
/homeassistant/components/youtube/ @joostlek
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,15 +17,51 @@
|
||||
"boot_time": {
|
||||
"default": "mdi:av-timer"
|
||||
},
|
||||
"cpu_power_core": {
|
||||
"default": "mdi:chip"
|
||||
},
|
||||
"cpu_power_package": {
|
||||
"default": "mdi:chip"
|
||||
},
|
||||
"cpu_speed": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"display_refresh_rate": {
|
||||
"default": "mdi:monitor"
|
||||
},
|
||||
"display_resolution_x": {
|
||||
"default": "mdi:monitor"
|
||||
},
|
||||
"display_resolution_y": {
|
||||
"default": "mdi:monitor"
|
||||
},
|
||||
"displays_connected": {
|
||||
"default": "mdi:monitor"
|
||||
},
|
||||
"gpu_core_clock_speed": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"gpu_fan_speed": {
|
||||
"default": "mdi:fan"
|
||||
},
|
||||
"gpu_memory_clock_speed": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"gpu_memory_free": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"gpu_memory_used": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"gpu_memory_used_percentage": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"gpu_power_usage": {
|
||||
"default": "mdi:lightning-bolt"
|
||||
},
|
||||
"gpu_usage_percentage": {
|
||||
"default": "mdi:percent"
|
||||
},
|
||||
"kernel": {
|
||||
"default": "mdi:devices"
|
||||
},
|
||||
@@ -38,6 +74,9 @@
|
||||
"memory_used": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"memory_used_percentage": {
|
||||
"default": "mdi:memory"
|
||||
},
|
||||
"os": {
|
||||
"default": "mdi:devices"
|
||||
},
|
||||
@@ -47,6 +86,12 @@
|
||||
"processes": {
|
||||
"default": "mdi:counter"
|
||||
},
|
||||
"processes_load_cpu": {
|
||||
"default": "mdi:percent"
|
||||
},
|
||||
"space_used": {
|
||||
"default": "mdi:harddisk"
|
||||
},
|
||||
"version": {
|
||||
"default": "mdi:counter"
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import UNDEFINED, StateType
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
|
||||
@@ -284,10 +284,10 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
|
||||
),
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key="memory_used_percentage",
|
||||
translation_key="memory_used_percentage",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=2,
|
||||
icon="mdi:memory",
|
||||
value=lambda data: data.memory.virtual.percent,
|
||||
),
|
||||
SystemBridgeSensorEntityDescription(
|
||||
@@ -380,11 +380,11 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"filesystem_{partition.mount_point.replace(':', '')}",
|
||||
name=f"{partition.mount_point} space used",
|
||||
translation_key="space_used",
|
||||
translation_placeholders={"partition": partition.mount_point},
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=2,
|
||||
icon="mdi:harddisk",
|
||||
value=(
|
||||
lambda data, dk=index_device, pk=index_partition: (
|
||||
partition_usage(data, dk, pk)
|
||||
@@ -427,10 +427,10 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"display_{display.id}_resolution_x",
|
||||
name=f"Display {display.id} resolution x",
|
||||
translation_key="display_resolution_x",
|
||||
translation_placeholders={"display_id": display.id},
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PIXELS,
|
||||
icon="mdi:monitor",
|
||||
value=lambda data, k=index: display_resolution_horizontal(
|
||||
data, k
|
||||
),
|
||||
@@ -441,10 +441,10 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"display_{display.id}_resolution_y",
|
||||
name=f"Display {display.id} resolution y",
|
||||
translation_key="display_resolution_y",
|
||||
translation_placeholders={"display_id": display.id},
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PIXELS,
|
||||
icon="mdi:monitor",
|
||||
value=lambda data, k=index: display_resolution_vertical(
|
||||
data, k
|
||||
),
|
||||
@@ -455,12 +455,12 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"display_{display.id}_refresh_rate",
|
||||
name=f"Display {display.id} refresh rate",
|
||||
translation_key="display_refresh_rate",
|
||||
translation_placeholders={"display_id": display.id},
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.HERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=0,
|
||||
icon="mdi:monitor",
|
||||
value=lambda data, k=index: display_refresh_rate(data, k),
|
||||
),
|
||||
entry.data[CONF_PORT],
|
||||
@@ -474,13 +474,13 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_core_clock_speed",
|
||||
name=f"{gpu.name} clock speed",
|
||||
translation_key="gpu_core_clock_speed",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=0,
|
||||
icon="mdi:speedometer",
|
||||
value=lambda data, k=index: gpu_core_clock_speed(data, k),
|
||||
),
|
||||
entry.data[CONF_PORT],
|
||||
@@ -489,13 +489,13 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_memory_clock_speed",
|
||||
name=f"{gpu.name} memory clock speed",
|
||||
translation_key="gpu_memory_clock_speed",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
|
||||
device_class=SensorDeviceClass.FREQUENCY,
|
||||
suggested_display_precision=0,
|
||||
icon="mdi:speedometer",
|
||||
value=lambda data, k=index: gpu_memory_clock_speed(data, k),
|
||||
),
|
||||
entry.data[CONF_PORT],
|
||||
@@ -504,12 +504,12 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_memory_free",
|
||||
name=f"{gpu.name} memory free",
|
||||
translation_key="gpu_memory_free",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
suggested_display_precision=0,
|
||||
icon="mdi:memory",
|
||||
value=lambda data, k=index: gpu_memory_free(data, k),
|
||||
),
|
||||
entry.data[CONF_PORT],
|
||||
@@ -518,11 +518,11 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_memory_used_percentage",
|
||||
name=f"{gpu.name} memory used %",
|
||||
translation_key="gpu_memory_used_percentage",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=2,
|
||||
icon="mdi:memory",
|
||||
value=lambda data, k=index: gpu_memory_used_percentage(data, k),
|
||||
),
|
||||
entry.data[CONF_PORT],
|
||||
@@ -531,13 +531,13 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_memory_used",
|
||||
name=f"{gpu.name} memory used",
|
||||
translation_key="gpu_memory_used",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
|
||||
device_class=SensorDeviceClass.DATA_SIZE,
|
||||
suggested_display_precision=0,
|
||||
icon="mdi:memory",
|
||||
value=lambda data, k=index: gpu_memory_used(data, k),
|
||||
),
|
||||
entry.data[CONF_PORT],
|
||||
@@ -546,11 +546,11 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_fan_speed",
|
||||
name=f"{gpu.name} fan speed",
|
||||
translation_key="gpu_fan_speed",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
|
||||
icon="mdi:fan",
|
||||
value=lambda data, k=index: gpu_fan_speed(data, k),
|
||||
),
|
||||
entry.data[CONF_PORT],
|
||||
@@ -559,7 +559,8 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_power_usage",
|
||||
name=f"{gpu.name} power usage",
|
||||
translation_key="gpu_power_usage",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
@@ -571,7 +572,8 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_temperature",
|
||||
name=f"{gpu.name} temperature",
|
||||
translation_key="gpu_temperature",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
entity_registry_enabled_default=False,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -585,11 +587,11 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"gpu_{gpu.id}_usage_percentage",
|
||||
name=f"{gpu.name} usage %",
|
||||
translation_key="gpu_usage_percentage",
|
||||
translation_placeholders={"gpu_name": gpu.name},
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
suggested_display_precision=2,
|
||||
icon="mdi:percent",
|
||||
value=lambda data, k=index: gpu_usage_percentage(data, k),
|
||||
),
|
||||
entry.data[CONF_PORT],
|
||||
@@ -605,11 +607,11 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"processes_load_cpu_{cpu.id}",
|
||||
name=f"Load CPU {cpu.id}",
|
||||
translation_key="processes_load_cpu",
|
||||
translation_placeholders={"cpu_id": str(cpu.id)},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
icon="mdi:percent",
|
||||
suggested_display_precision=2,
|
||||
value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k),
|
||||
),
|
||||
@@ -619,11 +621,11 @@ async def async_setup_entry(
|
||||
coordinator,
|
||||
SystemBridgeSensorEntityDescription(
|
||||
key=f"cpu_power_core_{cpu.id}",
|
||||
name=f"CPU Core {cpu.id} Power",
|
||||
translation_key="cpu_power_core",
|
||||
translation_placeholders={"cpu_id": str(cpu.id)},
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
icon="mdi:chip",
|
||||
suggested_display_precision=2,
|
||||
value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k),
|
||||
),
|
||||
@@ -653,8 +655,6 @@ class SystemBridgeSensor(SystemBridgeEntity, SensorEntity):
|
||||
description.key,
|
||||
)
|
||||
self.entity_description = description
|
||||
if description.name is not UNDEFINED:
|
||||
self._attr_has_entity_name = False
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
|
||||
@@ -54,6 +54,9 @@
|
||||
"boot_time": {
|
||||
"name": "Boot time"
|
||||
},
|
||||
"cpu_power_core": {
|
||||
"name": "CPU core {cpu_id} power"
|
||||
},
|
||||
"cpu_power_package": {
|
||||
"name": "CPU package power"
|
||||
},
|
||||
@@ -66,9 +69,45 @@
|
||||
"cpu_voltage": {
|
||||
"name": "CPU voltage"
|
||||
},
|
||||
"display_refresh_rate": {
|
||||
"name": "Display {display_id} refresh rate"
|
||||
},
|
||||
"display_resolution_x": {
|
||||
"name": "Display {display_id} resolution x"
|
||||
},
|
||||
"display_resolution_y": {
|
||||
"name": "Display {display_id} resolution y"
|
||||
},
|
||||
"displays_connected": {
|
||||
"name": "Displays connected"
|
||||
},
|
||||
"gpu_core_clock_speed": {
|
||||
"name": "{gpu_name} clock speed"
|
||||
},
|
||||
"gpu_fan_speed": {
|
||||
"name": "{gpu_name} fan speed"
|
||||
},
|
||||
"gpu_memory_clock_speed": {
|
||||
"name": "{gpu_name} memory clock speed"
|
||||
},
|
||||
"gpu_memory_free": {
|
||||
"name": "{gpu_name} memory free"
|
||||
},
|
||||
"gpu_memory_used": {
|
||||
"name": "{gpu_name} memory used"
|
||||
},
|
||||
"gpu_memory_used_percentage": {
|
||||
"name": "{gpu_name} memory used %"
|
||||
},
|
||||
"gpu_power_usage": {
|
||||
"name": "{gpu_name} power usage"
|
||||
},
|
||||
"gpu_temperature": {
|
||||
"name": "{gpu_name} temperature"
|
||||
},
|
||||
"gpu_usage_percentage": {
|
||||
"name": "{gpu_name} usage %"
|
||||
},
|
||||
"kernel": {
|
||||
"name": "Kernel"
|
||||
},
|
||||
@@ -81,6 +120,9 @@
|
||||
"memory_used": {
|
||||
"name": "Memory used"
|
||||
},
|
||||
"memory_used_percentage": {
|
||||
"name": "Memory used %"
|
||||
},
|
||||
"os": {
|
||||
"name": "Operating system"
|
||||
},
|
||||
@@ -90,6 +132,12 @@
|
||||
"processes": {
|
||||
"name": "Processes"
|
||||
},
|
||||
"processes_load_cpu": {
|
||||
"name": "Load CPU {cpu_id}"
|
||||
},
|
||||
"space_used": {
|
||||
"name": "{partition} space used"
|
||||
},
|
||||
"version": {
|
||||
"name": "Version"
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity):
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_title = "System Bridge"
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -44,7 +45,6 @@ class SystemBridgeUpdateEntity(SystemBridgeEntity, UpdateEntity):
|
||||
api_port,
|
||||
"update",
|
||||
)
|
||||
self._attr_name = coordinator.data.system.hostname
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==10.4.1"]
|
||||
"requirements": ["uiprotect==10.5.0"]
|
||||
}
|
||||
|
||||
@@ -46,8 +46,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b
|
||||
try:
|
||||
await client.connect()
|
||||
except WebOsTvPairError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from err
|
||||
|
||||
# If pairing request accepted there will be no error
|
||||
# Update the stored key without triggering reauth
|
||||
|
||||
@@ -6,6 +6,7 @@ from homeassistant.components.device_automation import (
|
||||
DEVICE_TRIGGER_BASE_SCHEMA,
|
||||
InvalidDeviceAutomationConfig,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_PLATFORM, CONF_TYPE
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -13,10 +14,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import DOMAIN, trigger
|
||||
from .helpers import (
|
||||
async_get_client_by_device_entry,
|
||||
async_get_device_entry_by_device_id,
|
||||
)
|
||||
from .helpers import async_get_device_entry_by_device_id
|
||||
from .triggers.turn_on import (
|
||||
PLATFORM_TYPE as TURN_ON_PLATFORM_TYPE,
|
||||
async_get_turn_on_trigger,
|
||||
@@ -40,10 +38,31 @@ async def async_validate_trigger_config(
|
||||
device_id = config[CONF_DEVICE_ID]
|
||||
try:
|
||||
device = async_get_device_entry_by_device_id(hass, device_id)
|
||||
async_get_client_by_device_entry(hass, device)
|
||||
except ValueError as err:
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise InvalidDeviceAutomationConfig(err) from err
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_valid",
|
||||
translation_placeholders={"device_id": device_id},
|
||||
) from err
|
||||
|
||||
for config_entry_id in device.config_entries:
|
||||
if (
|
||||
entry := hass.config_entries.async_get_entry(config_entry_id)
|
||||
) and entry.domain == DOMAIN:
|
||||
if entry.state is ConfigEntryState.LOADED:
|
||||
break
|
||||
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_config_entry_not_loaded",
|
||||
translation_placeholders={"device_id": device.id},
|
||||
)
|
||||
else:
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_valid",
|
||||
translation_placeholders={"device_id": device.id},
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
|
||||
from aiowebostv import WebOsClient, WebOsTvState
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -56,31 +56,6 @@ def async_get_device_id_from_entity_id(hass: HomeAssistant, entity_id: str) -> s
|
||||
return entity_entry.device_id
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_client_by_device_entry(
|
||||
hass: HomeAssistant, device: DeviceEntry
|
||||
) -> WebOsClient:
|
||||
"""Get WebOsClient from Device Registry by device entry.
|
||||
|
||||
Raises ValueError if client is not found.
|
||||
"""
|
||||
for config_entry_id in device.config_entries:
|
||||
entry: WebOsTvConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
config_entry_id
|
||||
)
|
||||
if entry and entry.domain == DOMAIN:
|
||||
if entry.state is ConfigEntryState.LOADED:
|
||||
return entry.runtime_data
|
||||
|
||||
raise ValueError(
|
||||
f"Device {device.id} is not from a loaded {DOMAIN} config entry"
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"Device {device.id} is not from an existing {DOMAIN} config entry"
|
||||
)
|
||||
|
||||
|
||||
def get_sources(tv_state: WebOsTvState) -> list[str]:
|
||||
"""Construct sources list."""
|
||||
sources = []
|
||||
|
||||
@@ -46,9 +46,18 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "Pairing failed, make sure to accept the pairing request on your TV."
|
||||
},
|
||||
"communication_error": {
|
||||
"message": "Communication error while calling {func} for device {name}: {error}"
|
||||
},
|
||||
"device_config_entry_not_loaded": {
|
||||
"message": "The LG webOS TV integration for device {device_id} is not loaded."
|
||||
},
|
||||
"device_not_valid": {
|
||||
"message": "Device {device_id} is not a valid LG webOS TV device."
|
||||
},
|
||||
"device_off": {
|
||||
"message": "Error calling {func} for device {name}: Device is off and cannot be controlled."
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""The Yoto integration."""
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
|
||||
"""Set up Yoto from a config entry."""
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except (aiohttp.ClientError, OAuth2TokenRequestError) as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
coordinator = YotoDataUpdateCoordinator(hass, entry, session)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: YotoConfigEntry) -> bool:
|
||||
"""Unload a Yoto config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Application credentials platform for the Yoto integration."""
|
||||
|
||||
from homeassistant.components.application_credentials import ClientCredential
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
LocalOAuth2ImplementationWithPkce,
|
||||
)
|
||||
|
||||
from .const import YOTO_AUDIENCE, YOTO_SCOPES
|
||||
|
||||
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
|
||||
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant,
|
||||
auth_domain: str,
|
||||
credential: ClientCredential,
|
||||
) -> YotoOAuth2Implementation:
|
||||
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
|
||||
return YotoOAuth2Implementation(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential.client_id,
|
||||
AUTHORIZE_URL,
|
||||
TOKEN_URL,
|
||||
credential.client_secret,
|
||||
)
|
||||
|
||||
|
||||
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
|
||||
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Append Yoto's audience and scopes to every authorize URL."""
|
||||
return super().extra_authorize_data | {
|
||||
"audience": YOTO_AUDIENCE,
|
||||
"scope": " ".join(YOTO_SCOPES),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Config flow for the Yoto integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from yoto_api import YotoError, get_account_id
|
||||
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
|
||||
|
||||
class YotoOAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Authorize Home Assistant with a Yoto account using OAuth2."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return the logger used for the OAuth2 flow."""
|
||||
return _LOGGER
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Identify the Yoto account from the access token."""
|
||||
try:
|
||||
user_id = get_account_id(data["token"]["access_token"])
|
||||
except YotoError:
|
||||
return self.async_abort(reason="oauth_unauthorized")
|
||||
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Yoto", data=data)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Constants for the Yoto integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
DOMAIN = "yoto"
|
||||
|
||||
_LOGGER = logging.getLogger(__package__)
|
||||
|
||||
YOTO_AUDIENCE = "https://api.yotoplay.com"
|
||||
|
||||
YOTO_SCOPES = [
|
||||
"offline_access",
|
||||
"family:view",
|
||||
"family:devices:view",
|
||||
"family:devices:control",
|
||||
"family:devices:manage",
|
||||
"family:library:view",
|
||||
"user:content:view",
|
||||
"user:icons:manage",
|
||||
]
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
STATUS_PUSH_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
MANUFACTURER = "Yoto"
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Coordinator for the Yoto integration."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import aiohttp
|
||||
from yoto_api import Token, YotoClient, YotoError, YotoPlayer
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, OAuth2TokenRequestError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import _LOGGER, DOMAIN, SCAN_INTERVAL, STATUS_PUSH_INTERVAL
|
||||
|
||||
type YotoConfigEntry = ConfigEntry[YotoDataUpdateCoordinator]
|
||||
|
||||
|
||||
class YotoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, YotoPlayer]]):
|
||||
"""Coordinator that drives the Yoto cloud polling cycle."""
|
||||
|
||||
config_entry: YotoConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: YotoConfigEntry,
|
||||
session: OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self._session = session
|
||||
self.client = YotoClient(session=async_get_clientsession(hass))
|
||||
self._sync_token()
|
||||
|
||||
def _sync_token(self) -> None:
|
||||
"""Sync the OAuth2 access token to the Yoto client."""
|
||||
token = self._session.token
|
||||
self.client.token = Token(
|
||||
access_token=token[CONF_ACCESS_TOKEN],
|
||||
refresh_token=token.get("refresh_token", ""),
|
||||
token_type=token.get("token_type", "Bearer"),
|
||||
valid_until=dt_util.utc_from_timestamp(token["expires_at"]),
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
try:
|
||||
await self.client.refresh()
|
||||
except YotoError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
await self._async_load_library()
|
||||
|
||||
try:
|
||||
await self.client.connect_events(
|
||||
list(self.client.players), self._mqtt_event
|
||||
)
|
||||
except YotoError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
# The MQTT data/status topic is not pushed spontaneously; the firmware
|
||||
# only emits it in response to a command/status/request publish.
|
||||
self.config_entry.async_on_unload(
|
||||
async_track_time_interval(
|
||||
self.hass, self._async_status_push_tick, STATUS_PUSH_INTERVAL
|
||||
)
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, YotoPlayer]:
|
||||
"""Fetch fresh data from the Yoto cloud."""
|
||||
# _async_setup already populated the client; skip the duplicate first fetch.
|
||||
if self.data is None:
|
||||
return self.client.players
|
||||
|
||||
try:
|
||||
await self._session.async_ensure_token_valid()
|
||||
except (aiohttp.ClientError, OAuth2TokenRequestError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
self._sync_token()
|
||||
|
||||
try:
|
||||
await self.client.refresh()
|
||||
except YotoError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_error",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
return self.client.players
|
||||
|
||||
async def _async_load_library(self) -> None:
|
||||
"""Load the card library; failures only affect titles and artwork."""
|
||||
try:
|
||||
await self.client.update_library()
|
||||
except YotoError as err:
|
||||
_LOGGER.warning("Could not load Yoto card library: %s", err)
|
||||
|
||||
async def _async_status_push_tick(self, _now: datetime) -> None:
|
||||
"""Ask each player to push a fresh status snapshot over MQTT."""
|
||||
if not self.client.is_mqtt_connected:
|
||||
return
|
||||
# Fire-and-forget: the data/status response lands via the on_update
|
||||
# callback later, which already triggers async_set_updated_data.
|
||||
for device_id in list(self.client.players):
|
||||
await self.client.request_status_push(device_id)
|
||||
|
||||
def _mqtt_event(self, _player: YotoPlayer) -> None:
|
||||
"""Handle a real-time update pushed by the Yoto MQTT broker."""
|
||||
self.async_set_updated_data(self.client.players)
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""Shut down the coordinator."""
|
||||
await self.client.disconnect_events()
|
||||
await super().async_shutdown()
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Base entity for the Yoto integration."""
|
||||
|
||||
from yoto_api import YotoPlayer
|
||||
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, MANUFACTURER
|
||||
from .coordinator import YotoDataUpdateCoordinator
|
||||
|
||||
|
||||
class YotoEntity(CoordinatorEntity[YotoDataUpdateCoordinator]):
|
||||
"""Base class for Yoto entities tied to a single player."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: YotoDataUpdateCoordinator,
|
||||
player: YotoPlayer,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._player_id = player.id
|
||||
device = player.device
|
||||
mac = player.info.mac
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, player.id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, mac)} if mac else set(),
|
||||
manufacturer=MANUFACTURER,
|
||||
model=player.model,
|
||||
model_id=device.device_type,
|
||||
hw_version=device.generation,
|
||||
name=player.name,
|
||||
sw_version=player.info.firmware_version,
|
||||
)
|
||||
|
||||
@property
|
||||
def player(self) -> YotoPlayer:
|
||||
"""Return the live player record from the client."""
|
||||
return self.coordinator.data[self._player_id]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the entity is available."""
|
||||
return super().available and self._player_id in self.coordinator.data
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"domain": "yoto",
|
||||
"name": "Yoto",
|
||||
"codeowners": ["@cdnninja", "@piitaya"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"dhcp": [{ "hostname": "yoto-*" }],
|
||||
"documentation": "https://www.home-assistant.io/integrations/yoto",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["yoto_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["yoto-api==3.1.0"]
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Media player platform for the Yoto integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from yoto_api import Card, PlaybackStatus, YotoError, YotoPlayer
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import YotoConfigEntry, YotoDataUpdateCoordinator
|
||||
from .entity import YotoEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# Yoto players expose 16 hardware volume steps.
|
||||
VOLUME_STEP = 1 / 16
|
||||
|
||||
PLAYBACK_STATE_MAP = {
|
||||
PlaybackStatus.PLAYING: MediaPlayerState.PLAYING,
|
||||
PlaybackStatus.PAUSED: MediaPlayerState.PAUSED,
|
||||
PlaybackStatus.STOPPED: MediaPlayerState.IDLE,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: YotoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Yoto media player platform."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
YotoMediaPlayer(coordinator, player)
|
||||
for player in coordinator.client.players.values()
|
||||
)
|
||||
|
||||
|
||||
class YotoMediaPlayer(YotoEntity, MediaPlayerEntity):
|
||||
"""Representation of a Yoto Player."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
_attr_media_image_remotely_accessible = True
|
||||
_attr_volume_step = VOLUME_STEP
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: YotoDataUpdateCoordinator,
|
||||
player: YotoPlayer,
|
||||
) -> None:
|
||||
"""Initialize the media player."""
|
||||
super().__init__(coordinator, player)
|
||||
self._attr_unique_id = player.id
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether the player is reachable through the Yoto cloud."""
|
||||
return super().available and bool(self.player.status.is_online)
|
||||
|
||||
@property
|
||||
def state(self) -> MediaPlayerState:
|
||||
"""Return the playback state."""
|
||||
return PLAYBACK_STATE_MAP.get(
|
||||
self.player.last_event.playback_status, MediaPlayerState.IDLE
|
||||
)
|
||||
|
||||
@property
|
||||
def volume_level(self) -> float | None:
|
||||
"""Return the current volume level."""
|
||||
return self.player.last_event.volume_percentage
|
||||
|
||||
@property
|
||||
def media_duration(self) -> int | None:
|
||||
"""Return the current track duration in seconds."""
|
||||
return self.player.last_event.track_length
|
||||
|
||||
@property
|
||||
def media_position(self) -> int | None:
|
||||
"""Return the current playback position in seconds."""
|
||||
return self.player.last_event.position
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self) -> datetime | None:
|
||||
"""Return the time the media position was last refreshed."""
|
||||
return self.player.last_event_received_at
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Return the title of the currently playing track."""
|
||||
event = self.player.last_event
|
||||
return event.track_title or event.chapter_title
|
||||
|
||||
@property
|
||||
def media_album_name(self) -> str | None:
|
||||
"""Return the title of the active card."""
|
||||
card = self._current_card()
|
||||
return card.title if card else None
|
||||
|
||||
@property
|
||||
def media_artist(self) -> str | None:
|
||||
"""Return the author of the active card."""
|
||||
card = self._current_card()
|
||||
return card.author if card else None
|
||||
|
||||
@property
|
||||
def media_image_url(self) -> str | None:
|
||||
"""Return the cover image URL of the active card."""
|
||||
card = self._current_card()
|
||||
return card.cover_image_large if card else None
|
||||
|
||||
def _current_card(self) -> Card | None:
|
||||
"""Return the cached library card for the currently active media."""
|
||||
card_id = self.player.last_event.card_id
|
||||
if not card_id:
|
||||
return None
|
||||
return self.coordinator.client.library.get(card_id)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Resume playback."""
|
||||
await self._async_run(self.coordinator.client.resume, self._player_id)
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Pause playback."""
|
||||
await self._async_run(self.coordinator.client.pause, self._player_id)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Stop playback."""
|
||||
await self._async_run(self.coordinator.client.stop, self._player_id)
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set the playback volume (0.0 - 1.0)."""
|
||||
await self._async_run(
|
||||
self.coordinator.client.set_volume,
|
||||
self._player_id,
|
||||
round(volume * 100),
|
||||
)
|
||||
|
||||
async def async_media_seek(self, position: float) -> None:
|
||||
"""Seek to ``position`` seconds in the active track."""
|
||||
await self._async_run(
|
||||
self.coordinator.client.seek, self._player_id, int(position)
|
||||
)
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Skip to the next track on the active card."""
|
||||
await self._async_run(self.coordinator.client.next_track, self._player_id)
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Skip to the previous track on the active card."""
|
||||
await self._async_run(self.coordinator.client.previous_track, self._player_id)
|
||||
|
||||
async def _async_run(
|
||||
self, func: Callable[..., Awaitable[Any]], /, *args: Any
|
||||
) -> None:
|
||||
"""Await a Yoto command and surface failures as HA errors."""
|
||||
try:
|
||||
await func(*args)
|
||||
except YotoError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="command_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
@@ -0,0 +1,85 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: This integration does not register custom service actions.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: 5 minute interval. MQTT carries live state; polling is what surfaces the online -> offline transition since the broker doesn't push disconnect events.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: This integration does not register custom service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Real-time updates are dispatched through the coordinator, not via per-entity event subscriptions.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: This integration does not register custom service actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: This integration has no options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: The integration supports local DHCP discovery (via hostname pattern), but does not implement a separate discovery update handling flow.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: Only the media_player entity ships in this PR; no diagnostic entities yet.
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Only the media_player entity ships in this PR; no entities are disabled by default.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: The media_player uses the device name; no translatable strings yet.
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: No custom icon translations are needed yet.
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: Authorization is the only configuration; reauth covers re-linking the account.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair issues are raised yet.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"step": {
|
||||
"oauth_discovery": {
|
||||
"description": "Home Assistant has found a Yoto player on your network. Press **Submit** to continue setting up Yoto."
|
||||
},
|
||||
"pick_implementation": {
|
||||
"data": {
|
||||
"implementation": "[%key:common::config_flow::data::implementation%]"
|
||||
},
|
||||
"data_description": {
|
||||
"implementation": "[%key:common::config_flow::description::implementation%]"
|
||||
},
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"command_failed": {
|
||||
"message": "Yoto command failed: {error}"
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||
},
|
||||
"update_error": {
|
||||
"message": "Error communicating with Yoto: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,5 +51,6 @@ APPLICATION_CREDENTIALS = [
|
||||
"xbox",
|
||||
"yale",
|
||||
"yolink",
|
||||
"yoto",
|
||||
"youtube",
|
||||
]
|
||||
|
||||
Generated
+1
@@ -860,6 +860,7 @@ FLOWS = {
|
||||
"yardian",
|
||||
"yeelight",
|
||||
"yolink",
|
||||
"yoto",
|
||||
"youless",
|
||||
"youtube",
|
||||
"zamg",
|
||||
|
||||
Generated
+4
@@ -1476,4 +1476,8 @@ DHCP: Final[list[dict[str, str | bool]]] = [
|
||||
"domain": "yeelight",
|
||||
"hostname": "yeelink-*",
|
||||
},
|
||||
{
|
||||
"domain": "yoto",
|
||||
"hostname": "yoto-*",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -8215,6 +8215,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"yoto": {
|
||||
"name": "Yoto",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
},
|
||||
"youless": {
|
||||
"name": "YouLess",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -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
+9
-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
|
||||
@@ -3407,6 +3407,9 @@ yeelightsunflower==0.0.10
|
||||
# homeassistant.components.yolink
|
||||
yolink-api==0.6.5
|
||||
|
||||
# homeassistant.components.yoto
|
||||
yoto-api==3.1.0
|
||||
|
||||
# homeassistant.components.youless
|
||||
youless-api==2.2.0
|
||||
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# serializer version: 1
|
||||
# name: test_binary_sensor_platform[binary_sensor.hostname_camera_in_use-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.hostname_camera_in_use',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Camera in use',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Camera in use',
|
||||
'platform': 'system_bridge',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'camera_in_use',
|
||||
'unique_id': 'hostname_camera_in_use',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensor_platform[binary_sensor.hostname_camera_in_use-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'hostname Camera in use',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.hostname_camera_in_use',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensor_platform[binary_sensor.hostname_charging-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.hostname_charging',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Charging',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.BATTERY_CHARGING: 'battery_charging'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Charging',
|
||||
'platform': 'system_bridge',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'hostname_battery_is_charging',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensor_platform[binary_sensor.hostname_charging-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'battery_charging',
|
||||
'friendly_name': 'hostname Charging',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.hostname_charging',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensor_platform[binary_sensor.hostname_pending_reboot-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.hostname_pending_reboot',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Pending reboot',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Pending reboot',
|
||||
'platform': 'system_bridge',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'pending_reboot',
|
||||
'unique_id': 'hostname_pending_reboot',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensor_platform[binary_sensor.hostname_pending_reboot-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'hostname Pending reboot',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.hostname_pending_reboot',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensor_platform[binary_sensor.hostname_update-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.hostname_update',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Update',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.UPDATE: 'update'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Update',
|
||||
'platform': 'system_bridge',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'hostname_version_available',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensor_platform[binary_sensor.hostname_update-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'update',
|
||||
'friendly_name': 'hostname Update',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.hostname_update',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
# serializer version: 1
|
||||
# name: test_update_platform[update.hostname-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'update',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'update.hostname',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'system_bridge',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'hostname_update',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_update_platform[update.hostname-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'auto_update': False,
|
||||
'display_precision': 0,
|
||||
'entity_picture': '/api/brands/integration/system_bridge/icon.png',
|
||||
'friendly_name': 'hostname',
|
||||
'in_progress': False,
|
||||
'installed_version': '1.0.0',
|
||||
'latest_version': '4.99.0',
|
||||
'release_summary': None,
|
||||
'release_url': 'https://github.com/timmo001/system-bridge/releases/tag/4.99.0',
|
||||
'skipped_version': None,
|
||||
'supported_features': <UpdateEntityFeature: 0>,
|
||||
'title': 'System Bridge',
|
||||
'update_percentage': None,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'update.hostname',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for the System Bridge binary sensor platform."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def binary_sensor_only() -> Generator[None]:
|
||||
"""Enable only the binary sensor platform."""
|
||||
with patch(
|
||||
"homeassistant.components.system_bridge.PLATFORMS",
|
||||
[Platform.BINARY_SENSOR],
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_version", "mock_websocket_client", "entity_registry_enabled_by_default"
|
||||
)
|
||||
async def test_binary_sensor_platform(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test setup of the binary sensor platform."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for the System Bridge sensor platform."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def sensor_only() -> Generator[None]:
|
||||
"""Enable only the sensor platform."""
|
||||
with patch(
|
||||
"homeassistant.components.system_bridge.PLATFORMS",
|
||||
[Platform.SENSOR],
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_version", "mock_websocket_client", "entity_registry_enabled_by_default"
|
||||
)
|
||||
@pytest.mark.freeze_time("1970-01-01 00:00:00")
|
||||
async def test_sensor_platform(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test setup of the sensor platform."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for the System Bridge update platform."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def update_only() -> Generator[None]:
|
||||
"""Enable only the update platform."""
|
||||
with patch(
|
||||
"homeassistant.components.system_bridge.PLATFORMS",
|
||||
[Platform.UPDATE],
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_version", "mock_websocket_client", "entity_registry_enabled_by_default"
|
||||
)
|
||||
async def test_update_platform(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test setup of the update platform."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
@@ -117,7 +117,7 @@ async def test_invalid_trigger_raises(
|
||||
)
|
||||
|
||||
# Test invalid device id
|
||||
with pytest.raises(InvalidDeviceAutomationConfig):
|
||||
with pytest.raises(InvalidDeviceAutomationConfig) as exc_info:
|
||||
await device_trigger.async_validate_trigger_config(
|
||||
hass,
|
||||
{
|
||||
@@ -127,13 +127,23 @@ async def test_invalid_trigger_raises(
|
||||
"device_id": "invalid_device_id",
|
||||
},
|
||||
)
|
||||
assert exc_info.value.translation_domain == DOMAIN
|
||||
assert exc_info.value.translation_key == "device_not_valid"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("domain", "entry_state"),
|
||||
("domain", "entry_state", "expected_translation_key"),
|
||||
[
|
||||
(DOMAIN, ConfigEntryState.NOT_LOADED),
|
||||
("fake", ConfigEntryState.LOADED),
|
||||
(
|
||||
DOMAIN,
|
||||
ConfigEntryState.NOT_LOADED,
|
||||
"device_config_entry_not_loaded",
|
||||
),
|
||||
(
|
||||
"fake",
|
||||
ConfigEntryState.LOADED,
|
||||
"device_not_valid",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_invalid_entry_raises(
|
||||
@@ -142,6 +152,7 @@ async def test_invalid_entry_raises(
|
||||
client,
|
||||
domain: str,
|
||||
entry_state: ConfigEntryState,
|
||||
expected_translation_key: str,
|
||||
) -> None:
|
||||
"""Test device id not loaded or from another domain raises."""
|
||||
await setup_webostv(hass)
|
||||
@@ -162,5 +173,7 @@ async def test_invalid_entry_raises(
|
||||
}
|
||||
|
||||
# Test that device id from non webostv domain raises exception
|
||||
with pytest.raises(InvalidDeviceAutomationConfig):
|
||||
with pytest.raises(InvalidDeviceAutomationConfig) as exc_info:
|
||||
await device_trigger.async_validate_trigger_config(hass, config)
|
||||
assert exc_info.value.translation_domain == DOMAIN
|
||||
assert exc_info.value.translation_key == expected_translation_key
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for the Yoto integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
|
||||
"""Set up the Yoto integration for testing."""
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Fixtures for the Yoto integration tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import UTC, datetime
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from yoto_api import (
|
||||
Card,
|
||||
Device,
|
||||
PlaybackEvent,
|
||||
PlaybackStatus,
|
||||
PlayerInfo,
|
||||
PlayerStatus,
|
||||
YotoPlayer,
|
||||
)
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
DOMAIN as APPLICATION_CREDENTIALS_DOMAIN,
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.yoto.const import DOMAIN, YOTO_SCOPES
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
USER_ID = "auth0|user-test"
|
||||
PLAYER_ID = "player-test"
|
||||
CARD_ID = "card-test"
|
||||
SCOPES = " ".join(YOTO_SCOPES)
|
||||
ACCESS_TOKEN = jwt.encode({"sub": USER_ID}, "test-secret-long-enough-for-hmac-sha256")
|
||||
|
||||
|
||||
def _build_card() -> Card:
|
||||
"""Build a representative Yoto library card."""
|
||||
return Card(
|
||||
id=CARD_ID,
|
||||
title="Outer Space",
|
||||
author="Ladybird Audio Adventures",
|
||||
cover_image_large="https://example.test/cover.jpg",
|
||||
)
|
||||
|
||||
|
||||
def _build_player() -> YotoPlayer:
|
||||
"""Build a representative Yoto player for tests."""
|
||||
now = datetime(2026, 5, 8, 12, 0, tzinfo=UTC)
|
||||
player = YotoPlayer(
|
||||
device=Device(
|
||||
device_id=PLAYER_ID,
|
||||
name="Nursery Yoto",
|
||||
device_type="v3",
|
||||
device_family="v3",
|
||||
generation="gen3",
|
||||
),
|
||||
devices_refreshed_at=now,
|
||||
info_refreshed_at=now,
|
||||
last_event_received_at=now,
|
||||
)
|
||||
player.info = PlayerInfo(
|
||||
device_id=PLAYER_ID,
|
||||
firmware_version="v2.17.5",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
)
|
||||
player.status = PlayerStatus(device_id=PLAYER_ID, is_online=True)
|
||||
player.last_event = PlaybackEvent(
|
||||
player_id=PLAYER_ID,
|
||||
playback_status=PlaybackStatus.PLAYING,
|
||||
volume=8,
|
||||
volume_max=16,
|
||||
track_length=300,
|
||||
position=120,
|
||||
card_id=CARD_ID,
|
||||
chapter_key="01",
|
||||
chapter_title="Chapter 1",
|
||||
track_key="01-INT",
|
||||
track_title="Introduction",
|
||||
)
|
||||
return player
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_token_hex() -> Generator[MagicMock]:
|
||||
"""Pin the access token used for proxy URLs to keep snapshots stable."""
|
||||
with patch("secrets.token_hex", return_value="abcdef") as token:
|
||||
yield token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Bypass the integration setup so the config flow can be tested in isolation."""
|
||||
with patch(
|
||||
"homeassistant.components.yoto.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
yield mock_setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_yoto_client() -> Generator[MagicMock]:
|
||||
"""Patch YotoClient used by the runtime to a configurable mock."""
|
||||
with patch(
|
||||
"homeassistant.components.yoto.coordinator.YotoClient", autospec=True
|
||||
) as client_class:
|
||||
client = client_class.return_value
|
||||
client.players = {PLAYER_ID: _build_player()}
|
||||
client.library = {CARD_ID: _build_card()}
|
||||
client.token = MagicMock(refresh_token="mock-refresh-token")
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture(name="expires_at")
|
||||
def mock_expires_at() -> float:
|
||||
"""Fixture to set the OAuth token expiration time."""
|
||||
return time.time() + 3600
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(expires_at: float) -> MockConfigEntry:
|
||||
"""Return a Yoto OAuth2 config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Yoto",
|
||||
unique_id=USER_ID,
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
"access_token": ACCESS_TOKEN,
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"expires_at": expires_at,
|
||||
"expires_in": 3600,
|
||||
"token_type": "Bearer",
|
||||
"scope": SCOPES,
|
||||
},
|
||||
},
|
||||
entry_id="01J5TX5A0FF6G5V0QJX6HBC94T",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_credentials(hass: HomeAssistant) -> None:
|
||||
"""Register fake OAuth2 client credentials for the Yoto integration."""
|
||||
assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {})
|
||||
await async_import_client_credential(
|
||||
hass,
|
||||
DOMAIN,
|
||||
ClientCredential("CLIENT_ID", "CLIENT_SECRET"),
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
# serializer version: 1
|
||||
# name: test_entity_state[media_player.nursery_yoto-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'media_player',
|
||||
'entity_category': None,
|
||||
'entity_id': 'media_player.nursery_yoto',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'yoto',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 21559>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'player-test',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entity_state[media_player.nursery_yoto-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'entity_picture': 'https://example.test/cover.jpg',
|
||||
'entity_picture_local': '/api/media_player_proxy/media_player.nursery_yoto?token=abcdef&cache=1cbba102718cbf3f',
|
||||
'friendly_name': 'Nursery Yoto',
|
||||
'media_album_name': 'Outer Space',
|
||||
'media_artist': 'Ladybird Audio Adventures',
|
||||
'media_duration': 300,
|
||||
'media_position': 120,
|
||||
'media_position_updated_at': datetime.datetime(2026, 5, 8, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'media_title': 'Introduction',
|
||||
'supported_features': <MediaPlayerEntityFeature: 21559>,
|
||||
'volume_level': 0.5,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'media_player.nursery_yoto',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'playing',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Tests for the Yoto config flow."""
|
||||
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.yoto.const import DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES
|
||||
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .conftest import ACCESS_TOKEN, USER_ID
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
REDIRECT_URI = "https://example.com/auth/external/callback"
|
||||
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
|
||||
|
||||
|
||||
async def _initiate_user_flow(hass: HomeAssistant) -> dict:
|
||||
"""Start the OAuth2 user flow and return the EXTERNAL_STEP result."""
|
||||
return await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
|
||||
async def _complete_callback(
|
||||
hass: HomeAssistant,
|
||||
result: dict,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
*,
|
||||
refresh_token: str = "mock-refresh-token",
|
||||
access_token: str = ACCESS_TOKEN,
|
||||
) -> dict:
|
||||
"""Drive the OAuth2 callback through the token exchange."""
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{"flow_id": result["flow_id"], "redirect_uri": REDIRECT_URI},
|
||||
)
|
||||
client = await hass_client_no_auth()
|
||||
response = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert response.status == HTTPStatus.OK
|
||||
|
||||
aioclient_mock.clear_requests()
|
||||
aioclient_mock.post(
|
||||
TOKEN_URL,
|
||||
json={
|
||||
"refresh_token": refresh_token,
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def test_abort_if_no_credentials(hass: HomeAssistant) -> None:
|
||||
"""The flow aborts when no application credentials are configured."""
|
||||
result = await _initiate_user_flow(hass)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "missing_credentials"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"current_request_with_host", "setup_credentials", "mock_setup_entry"
|
||||
)
|
||||
async def test_full_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Walk a happy-path OAuth2 flow end to end."""
|
||||
result = await _initiate_user_flow(hass)
|
||||
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||
|
||||
parsed = urlparse(result["url"])
|
||||
query = {key: value[0] for key, value in parse_qs(parsed.query).items()}
|
||||
assert parsed.scheme == "https"
|
||||
assert parsed.netloc == "login.yotoplay.com"
|
||||
assert parsed.path == "/authorize"
|
||||
assert query["audience"] == YOTO_AUDIENCE
|
||||
assert query["scope"] == " ".join(YOTO_SCOPES)
|
||||
assert query["client_id"] == "CLIENT_ID"
|
||||
assert query["redirect_uri"] == REDIRECT_URI
|
||||
|
||||
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Yoto"
|
||||
assert result["result"].unique_id == USER_ID
|
||||
assert result["data"]["auth_implementation"] == DOMAIN
|
||||
assert result["data"]["token"]["access_token"] == ACCESS_TOKEN
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"current_request_with_host", "setup_credentials", "mock_setup_entry"
|
||||
)
|
||||
async def test_dhcp_discovery_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""A Yoto player found on the LAN walks through OAuth to a new entry."""
|
||||
discovery = DhcpServiceInfo(
|
||||
ip="10.0.0.42",
|
||||
hostname="yoto-player",
|
||||
macaddress="6825dd39c3fc",
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_DHCP}, data=discovery
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "oauth_discovery"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result["type"] is FlowResultType.EXTERNAL_STEP
|
||||
|
||||
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].unique_id == USER_ID
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
|
||||
async def test_already_configured(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Re-authorizing the same account aborts as already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await _initiate_user_flow(hass)
|
||||
await _complete_callback(hass, result, hass_client_no_auth, aioclient_mock)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_token",
|
||||
[
|
||||
"not-a-jwt",
|
||||
jwt.encode({"foo": "bar"}, "test-secret-long-enough-for-hmac-sha256"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("current_request_with_host", "setup_credentials")
|
||||
async def test_invalid_access_token(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
access_token: str,
|
||||
) -> None:
|
||||
"""The flow aborts when the access token is not a usable JWT."""
|
||||
result = await _initiate_user_flow(hass)
|
||||
await _complete_callback(
|
||||
hass, result, hass_client_no_auth, aioclient_mock, access_token=access_token
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "oauth_unauthorized"
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Tests for the Yoto integration setup."""
|
||||
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import aiohttp
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from yoto_api import YotoAPIError, YotoError
|
||||
|
||||
from homeassistant.components.yoto.const import (
|
||||
DOMAIN,
|
||||
SCAN_INTERVAL,
|
||||
STATUS_PUSH_INTERVAL,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import OAuth2TokenRequestError
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("setup_credentials")
|
||||
|
||||
|
||||
async def test_setup_unload(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""The integration loads and unloads cleanly."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
mock_yoto_client.disconnect_events.assert_called_once()
|
||||
|
||||
|
||||
async def test_setup_retries_on_api_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""A non-auth API failure surfaces as a setup retry."""
|
||||
mock_yoto_client.refresh.side_effect = YotoAPIError("boom")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_mqtt_event_updates_entity(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""An MQTT event published by the broker refreshes the entity state."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
state_before = hass.states.get("media_player.nursery_yoto")
|
||||
assert state_before is not None
|
||||
|
||||
# connect_events(device_ids, on_update) — invoke the registered on_update callback
|
||||
on_update = mock_yoto_client.connect_events.call_args.args[1]
|
||||
player = next(iter(mock_yoto_client.players.values()))
|
||||
player.last_event.volume = 12
|
||||
on_update(player)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state_after = hass.states.get("media_player.nursery_yoto")
|
||||
assert state_after is not None
|
||||
assert state_after.attributes["volume_level"] == 12 / 16
|
||||
assert state_after.last_updated > state_before.last_updated
|
||||
|
||||
|
||||
async def test_status_push_tick(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""The status-push timer publishes a request every 60 s."""
|
||||
mock_yoto_client.is_mqtt_connected = True
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_yoto_client.request_status_push.reset_mock()
|
||||
|
||||
freezer.tick(STATUS_PUSH_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_yoto_client.request_status_push.assert_called_once_with("player-test")
|
||||
|
||||
|
||||
async def test_status_push_skipped_when_mqtt_disconnected(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""The status-push timer is a no-op while MQTT is reconnecting."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_yoto_client.request_status_push.reset_mock()
|
||||
mock_yoto_client.is_mqtt_connected = False
|
||||
|
||||
freezer.tick(STATUS_PUSH_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_yoto_client.request_status_push.assert_not_called()
|
||||
|
||||
|
||||
async def test_periodic_poll_refreshes_players(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""The coordinator refreshes the player list on every tick."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_yoto_client.refresh.reset_mock()
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_yoto_client.refresh.assert_called_once()
|
||||
|
||||
|
||||
async def test_setup_retries_when_implementation_missing(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Missing OAuth2 implementation defers setup as not-ready."""
|
||||
with patch(
|
||||
"homeassistant.components.yoto.async_get_config_entry_implementation",
|
||||
side_effect=ImplementationUnavailableError("gone"),
|
||||
):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect",
|
||||
[
|
||||
aiohttp.ClientError("boom"),
|
||||
OAuth2TokenRequestError(request_info=Mock(), domain=DOMAIN),
|
||||
],
|
||||
)
|
||||
async def test_setup_retries_on_token_validation_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
) -> None:
|
||||
"""A failure refreshing the OAuth token defers setup."""
|
||||
with patch(
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
|
||||
side_effect=side_effect,
|
||||
):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_retries_when_mqtt_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""MQTT connect failure surfaces as a setup retry."""
|
||||
mock_yoto_client.connect_events.side_effect = YotoError("mqtt down")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_succeeds_without_card_library(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""A library load failure doesn't block setup; titles and artwork stay empty."""
|
||||
mock_yoto_client.update_library.side_effect = YotoError("library down")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect",
|
||||
[
|
||||
aiohttp.ClientError("boom"),
|
||||
OAuth2TokenRequestError(request_info=Mock(), domain=DOMAIN),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_yoto_client")
|
||||
async def test_periodic_poll_fails_on_token_validation_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
side_effect: Exception,
|
||||
) -> None:
|
||||
"""A failure refreshing the OAuth token marks the coordinator failed."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
with patch(
|
||||
"homeassistant.helpers.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid",
|
||||
side_effect=side_effect,
|
||||
):
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = mock_config_entry.runtime_data
|
||||
assert coordinator.last_update_success is False
|
||||
|
||||
|
||||
async def test_periodic_poll_fails_on_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""A non-auth API error during periodic refresh marks the coordinator failed."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_yoto_client.refresh.side_effect = YotoAPIError("boom")
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator = mock_config_entry.runtime_data
|
||||
assert coordinator.last_update_success is False
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for the Yoto media player platform."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from yoto_api import YotoError
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_SEEK_POSITION,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_NEXT_TRACK,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PREVIOUS_TRACK,
|
||||
SERVICE_MEDIA_SEEK,
|
||||
SERVICE_MEDIA_STOP,
|
||||
SERVICE_VOLUME_SET,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
ENTITY_ID = "media_player.nursery_yoto"
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("setup_credentials")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_token_hex", "mock_yoto_client")
|
||||
async def test_entity_state(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Snapshot the media player entity state."""
|
||||
freezer.move_to("2026-05-08T12:00:00+00:00")
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "method"),
|
||||
[
|
||||
(SERVICE_MEDIA_PLAY, "resume"),
|
||||
(SERVICE_MEDIA_PAUSE, "pause"),
|
||||
(SERVICE_MEDIA_STOP, "stop"),
|
||||
(SERVICE_MEDIA_NEXT_TRACK, "next_track"),
|
||||
(SERVICE_MEDIA_PREVIOUS_TRACK, "previous_track"),
|
||||
],
|
||||
)
|
||||
async def test_playback_commands(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
service: str,
|
||||
method: str,
|
||||
) -> None:
|
||||
"""Playback service calls reach the client."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
getattr(mock_yoto_client, method).assert_called_once_with("player-test")
|
||||
|
||||
|
||||
async def test_set_volume(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Volume is forwarded as an integer 0-100."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_VOLUME_SET,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_yoto_client.set_volume.assert_called_once_with("player-test", 50)
|
||||
|
||||
|
||||
async def test_seek(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Seek delegates to the client with the integer position."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_SEEK,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_MEDIA_SEEK_POSITION: 30},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_yoto_client.seek.assert_called_once_with("player-test", 30)
|
||||
|
||||
|
||||
async def test_state_unavailable_when_offline(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""When the player reports offline the entity is unavailable."""
|
||||
player = next(iter(mock_yoto_client.players.values()))
|
||||
player.status.is_online = False
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_no_card_metadata_when_card_id_missing(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Card metadata properties return None when no card is active."""
|
||||
player = next(iter(mock_yoto_client.players.values()))
|
||||
player.last_event.card_id = None
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert "media_album_name" not in state.attributes
|
||||
assert "media_artist" not in state.attributes
|
||||
assert "entity_picture" not in state.attributes
|
||||
|
||||
|
||||
async def test_state_idle_before_first_event(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""A freshly-online player with no playback event yet reports IDLE."""
|
||||
player = next(iter(mock_yoto_client.players.values()))
|
||||
player.last_event.playback_status = None
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "idle"
|
||||
|
||||
|
||||
async def test_command_error_raises(
|
||||
hass: HomeAssistant,
|
||||
mock_yoto_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Yoto command failures surface as HomeAssistantError."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
mock_yoto_client.pause.side_effect = YotoError("nope")
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_MEDIA_PAUSE,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
)
|
||||
Reference in New Issue
Block a user