mirror of
https://github.com/home-assistant/core.git
synced 2026-05-25 02:05:11 +02:00
Compare commits
26 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 |
@@ -15,7 +15,6 @@ Dockerfile.dev linguist-language=Dockerfile
|
||||
# Generated files
|
||||
CODEOWNERS linguist-generated=true
|
||||
homeassistant/generated/*.py linguist-generated=true
|
||||
pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true
|
||||
machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
|
||||
@@ -281,7 +281,7 @@ jobs:
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
|
||||
env:
|
||||
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
@@ -302,7 +302,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3
|
||||
with:
|
||||
extra-args: --all-files zizmor
|
||||
|
||||
@@ -917,49 +917,12 @@ jobs:
|
||||
key: >-
|
||||
${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}
|
||||
- name: Restore pytest test counts cache
|
||||
id: cache-pytest-counts
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
# Primary key is a sentinel; restore-keys pick the most recent
|
||||
# prefix match since the real (content-addressed) key isn't
|
||||
# known until split_tests.py runs below.
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}-restore-sentinel
|
||||
restore-keys: |
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }}-
|
||||
- name: Run split_tests.py
|
||||
env:
|
||||
TEST_GROUP_COUNT: ${{ needs.info.outputs.test_group_count }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests \
|
||||
--cache pytest_test_counts.json \
|
||||
${TEST_GROUP_COUNT} tests
|
||||
- name: Hash pytest test counts cache
|
||||
id: cache-pytest-counts-hash
|
||||
run: |
|
||||
echo "hash=$(sha256sum pytest_test_counts.json | cut -d' ' -f1)" \
|
||||
>> "$GITHUB_OUTPUT"
|
||||
- name: Save pytest test counts cache
|
||||
# Content-addressed key: identical content reuses the same entry.
|
||||
# Skip the save when the restore already matched that hash.
|
||||
if: >-
|
||||
!endsWith(
|
||||
steps.cache-pytest-counts.outputs.cache-matched-key,
|
||||
steps.cache-pytest-counts-hash.outputs.hash
|
||||
)
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: pytest_test_counts.json
|
||||
key: >-
|
||||
pytest-counts-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
steps.python.outputs.python-version }}-${{
|
||||
needs.info.outputs.python_cache_key }}-${{
|
||||
steps.cache-pytest-counts-hash.outputs.hash }}
|
||||
python -m script.split_tests ${TEST_GROUP_COUNT} tests
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"service": "mdi:dialpad"
|
||||
},
|
||||
"alarm_toggle_chime": {
|
||||
"service": "mdi:bell-ring"
|
||||
"service": "mdi:abc"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotAuthenticate as err:
|
||||
# pylint: disable-next=home-assistant-exception-translation-key-missing
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
|
||||
@@ -102,9 +102,6 @@
|
||||
"entry_not_loaded": {
|
||||
"message": "Entry not loaded: {entry}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Invalid authentication credentials: {error}"
|
||||
},
|
||||
"invalid_device_id": {
|
||||
"message": "Invalid device ID specified: {device_id}"
|
||||
},
|
||||
|
||||
@@ -65,9 +65,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except aiocomelit_exceptions.CannotAuthenticate as err:
|
||||
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
|
||||
raise InvalidAuth(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_authenticate",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
finally:
|
||||
await api.logout()
|
||||
|
||||
@@ -61,7 +61,7 @@ class CurrencylayerSensor(SensorEntity):
|
||||
"""Implementing the Currencylayer sensor."""
|
||||
|
||||
_attr_attribution = "Data provided by currencylayer.com"
|
||||
_attr_icon = "mdi:currency-usd"
|
||||
_attr_icon = "mdi:currency"
|
||||
|
||||
def __init__(self, rest: CurrencylayerData, base: str, quote: str) -> None:
|
||||
"""Initialize the sensor."""
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"service": "mdi:refresh"
|
||||
},
|
||||
"set_dhw_override": {
|
||||
"service": "mdi:water-boiler"
|
||||
"service": "mdi:water-heater"
|
||||
},
|
||||
"set_system_mode": {
|
||||
"service": "mdi:pencil"
|
||||
|
||||
@@ -16,7 +16,7 @@ class DeviceType(Enum):
|
||||
GAME_CONSOLE = "mdi:nintendo-game-boy"
|
||||
STREAMING_DONGLE = "mdi:cast"
|
||||
LOUDSPEAKER = SOUND_SYSTEM = STB = SATELLITE = MUSIC = "mdi:speaker"
|
||||
DISC_PLAYER = "mdi:disc-player"
|
||||
DISC_PLAYER = "mdi:disk-player"
|
||||
REMOTE_CONTROL = "mdi:remote-tv"
|
||||
RADIO = "mdi:radio"
|
||||
PHOTO_CAMERA = PHOTOS = "mdi:camera"
|
||||
|
||||
@@ -10,6 +10,7 @@ from .coordinator import FloDeviceDataUpdateCoordinator
|
||||
class FloEntity(Entity):
|
||||
"""A base class for Flo entities."""
|
||||
|
||||
_attr_force_update = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"entity": {
|
||||
"button": {
|
||||
"sync_clock": {
|
||||
"default": "mdi:clock-check"
|
||||
"default": "mdi:clock-sync"
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODE = "mode"
|
||||
ATTR_TIME_PERIOD = "time_period"
|
||||
ATTR_ONOFF = "on_off"
|
||||
CONF_CODE = "2fa"
|
||||
|
||||
@@ -12,12 +12,12 @@ from homeassistant.components.light import (
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
)
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import HiveConfigEntry, refresh_system
|
||||
from .const import ATTR_MODE
|
||||
from .entity import HiveEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -6,11 +6,12 @@ from typing import Any
|
||||
from apyhiveapi import Hive
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import ATTR_MODE, EntityCategory
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HiveConfigEntry, refresh_system
|
||||
from .const import ATTR_MODE
|
||||
from .entity import HiveEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
@@ -19,9 +19,7 @@ EXPECTED_ENTRY_VERSION = (
|
||||
@callback
|
||||
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
|
||||
"""Return board info."""
|
||||
entries = hass.config_entries.async_entries(
|
||||
DOMAIN, include_ignore=False, include_disabled=False
|
||||
)
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
return [
|
||||
HardwareInfo(
|
||||
board=None,
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==10.1.0"],
|
||||
"requirements": ["python-homewizard-energy==10.0.1"],
|
||||
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
|
||||
@@ -65,6 +66,13 @@ def to_percentage(value: float | None) -> float | None:
|
||||
return value * 100 if value is not None else None
|
||||
|
||||
|
||||
def uptime_to_datetime(value: int) -> datetime:
|
||||
"""Convert seconds to datetime timestamp."""
|
||||
return utcnow().replace(microsecond=0) - timedelta(seconds=value)
|
||||
|
||||
|
||||
uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
|
||||
|
||||
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="smr_version",
|
||||
@@ -635,7 +643,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="uptime",
|
||||
translation_key="uptime",
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
has_fn=(
|
||||
@@ -643,7 +651,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
),
|
||||
value_fn=(
|
||||
lambda data: (
|
||||
utcnow() - timedelta(seconds=data.system.uptime_s)
|
||||
uptime_to_stable_datetime(data.system.uptime_s)
|
||||
if data.system is not None and data.system.uptime_s is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -61,14 +61,13 @@
|
||||
},
|
||||
"select": {
|
||||
"battery_group_mode": {
|
||||
"name": "Battery group charging strategy",
|
||||
"name": "Battery group mode",
|
||||
"state": {
|
||||
"predictive": "Smart charging",
|
||||
"standby": "Standby",
|
||||
"to_full": "One-time full charge",
|
||||
"zero": "Net zero",
|
||||
"zero_charge_only": "Net zero (charge only)",
|
||||
"zero_discharge_only": "Net zero (discharge only)"
|
||||
"to_full": "Manual charge mode",
|
||||
"zero": "Zero mode",
|
||||
"zero_charge_only": "Zero mode (charge only)",
|
||||
"zero_discharge_only": "Zero mode (discharge only)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,16 +31,15 @@ activate_scene:
|
||||
dynamic:
|
||||
selector:
|
||||
boolean:
|
||||
scene_customization:
|
||||
collapsed: true
|
||||
fields:
|
||||
speed:
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
brightness:
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 255
|
||||
speed:
|
||||
advanced: true
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 100
|
||||
brightness:
|
||||
advanced: true
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 255
|
||||
|
||||
@@ -184,12 +184,7 @@
|
||||
"name": "Transition"
|
||||
}
|
||||
},
|
||||
"name": "Activate Hue scene",
|
||||
"sections": {
|
||||
"scene_customization": {
|
||||
"name": "Scene customization"
|
||||
}
|
||||
}
|
||||
"name": "Activate Hue scene"
|
||||
},
|
||||
"hue_activate_scene": {
|
||||
"description": "Activates a Hue scene stored in the Hue hub.",
|
||||
|
||||
@@ -87,8 +87,6 @@ def async_get_triggers(
|
||||
|
||||
# Get Hue device id from device identifier
|
||||
hue_dev_id = get_hue_device_id(device_entry)
|
||||
if hue_dev_id is None or hue_dev_id not in api.devices:
|
||||
return []
|
||||
# extract triggers from all button resources of this Hue device
|
||||
triggers: list[dict[str, Any]] = []
|
||||
model_id = api.devices[hue_dev_id].product_data.product_name
|
||||
|
||||
@@ -118,8 +118,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
device = devices.add_x10_device(housecode, unitcode, x10_type, steps)
|
||||
|
||||
create_insteon_device(hass, devices.modem, entry.entry_id)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, INSTEON_PLATFORMS)
|
||||
|
||||
for address in devices:
|
||||
@@ -133,6 +131,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
register_new_device_callback(hass)
|
||||
async_setup_services(hass)
|
||||
|
||||
create_insteon_device(hass, devices.modem, entry.entry_id)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass, async_get_device_config(hass, entry), "insteon-get-device-config"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"erev_shabbat_hag": { "default": "mdi:candle" },
|
||||
"erev_shabbat_hag": { "default": "mdi:candle-light" },
|
||||
"issur_melacha_in_effect": { "default": "mdi:power-plug-off" },
|
||||
"motzei_shabbat_hag": { "default": "mdi:fire" }
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"service": "mdi:lock-open"
|
||||
},
|
||||
"disable": {
|
||||
"service": "mdi:flash-off"
|
||||
"service": "mdi:fash-off"
|
||||
},
|
||||
"enable": {
|
||||
"service": "mdi:flash"
|
||||
|
||||
@@ -28,25 +28,25 @@
|
||||
"ice_maker": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-off-outline"
|
||||
"off": "mdi:cube-outline-off"
|
||||
}
|
||||
},
|
||||
"ice_maker_bottom_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-off-outline"
|
||||
"off": "mdi:cube-outline-off"
|
||||
}
|
||||
},
|
||||
"ice_maker_middle_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-off-outline"
|
||||
"off": "mdi:cube-outline-off"
|
||||
}
|
||||
},
|
||||
"ice_maker_top_zone": {
|
||||
"default": "mdi:cube-outline",
|
||||
"state": {
|
||||
"off": "mdi:cube-off-outline"
|
||||
"off": "mdi:cube-outline-off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -241,7 +241,7 @@ def preprocess_turn_on_alternatives(
|
||||
|
||||
if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None:
|
||||
try:
|
||||
params[ATTR_RGB_COLOR] = tuple(color_util.color_name_to_rgb(color_name))
|
||||
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Got unknown color %s, falling back to white", color_name)
|
||||
params[ATTR_RGB_COLOR] = (255, 255, 255)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED, BridgeResponseError
|
||||
from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -131,11 +131,7 @@ class LutronCasetaBatterySensor(LutronCasetaEntity, BinarySensorEntity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Fetch the latest battery status from the bridge."""
|
||||
try:
|
||||
status = await self._smartbridge.get_battery_status(self.device_id)
|
||||
except BridgeResponseError:
|
||||
self._attr_is_on = None
|
||||
return
|
||||
status = await self._smartbridge.get_battery_status(self.device_id)
|
||||
normalized_status = status.strip().casefold() if status else None
|
||||
if normalized_status == BATTERY_STATUS_LOW:
|
||||
self._attr_is_on = True
|
||||
|
||||
@@ -60,7 +60,7 @@ def get_matter_device_info(
|
||||
return None
|
||||
|
||||
return MatterDeviceInfo(
|
||||
unique_id=node.device_info.uniqueID or "",
|
||||
unique_id=node.device_info.uniqueID,
|
||||
vendor_id=hex(node.device_info.vendorID),
|
||||
product_id=hex(node.device_info.productID),
|
||||
)
|
||||
|
||||
@@ -6,14 +6,13 @@ from typing import Any
|
||||
from chip.clusters import Objects
|
||||
from matter_server.common.helpers.util import dataclass_to_dict, parse_attribute_path
|
||||
|
||||
from homeassistant.components.diagnostics import REDACTED, async_redact_data
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .helpers import MatterConfigEntry, get_matter, get_node_from_device_entry
|
||||
|
||||
ATTRIBUTES_TO_REDACT = {Objects.BasicInformation.Attributes.Location}
|
||||
SERVER_INFO_TO_REDACT = {"wifi_ssid"}
|
||||
|
||||
|
||||
def redact_matter_attributes(node_data: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -45,7 +44,6 @@ async def async_get_config_entry_diagnostics(
|
||||
matter = get_matter(hass)
|
||||
server_diagnostics = await matter.matter_client.get_diagnostics()
|
||||
data = dataclass_to_dict(server_diagnostics)
|
||||
data["info"] = async_redact_data(data["info"], SERVER_INFO_TO_REDACT)
|
||||
nodes = [redact_matter_attributes(node_data) for node_data in data["nodes"]]
|
||||
data["nodes"] = nodes
|
||||
|
||||
@@ -61,9 +59,7 @@ async def async_get_device_diagnostics(
|
||||
node = get_node_from_device_entry(hass, device)
|
||||
|
||||
return {
|
||||
"server_info": async_redact_data(
|
||||
dataclass_to_dict(server_diagnostics.info), SERVER_INFO_TO_REDACT
|
||||
),
|
||||
"server_info": dataclass_to_dict(server_diagnostics.info),
|
||||
"node": redact_matter_attributes(
|
||||
remove_serialization_type(dataclass_to_dict(node.node_data) if node else {})
|
||||
),
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"default": "mdi:home-lightning-bolt"
|
||||
},
|
||||
"eve_weather_trend": {
|
||||
"default": "mdi:weather-cloudy",
|
||||
"default": "mdi:weather",
|
||||
"state": {
|
||||
"cloudy": "mdi:weather-cloudy",
|
||||
"rainy": "mdi:weather-rainy",
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["matter-python-client==0.7.1"],
|
||||
"requirements": ["matter-python-client==0.6.0"],
|
||||
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ ABBREVIATIONS = {
|
||||
"mode_stat_t": "mode_state_topic",
|
||||
"mode_stat_tpl": "mode_state_template",
|
||||
"modes": "modes",
|
||||
"msg_exp_int": "message_expiry_interval",
|
||||
"name": "name",
|
||||
"o": "origin",
|
||||
"off_dly": "off_delay",
|
||||
|
||||
@@ -120,8 +120,6 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
DurationSelector,
|
||||
DurationSelectorConfig,
|
||||
FileSelector,
|
||||
FileSelectorConfig,
|
||||
NumberSelector,
|
||||
@@ -229,7 +227,6 @@ from .const import (
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_MAX,
|
||||
CONF_MAX_KELVIN,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_MIN,
|
||||
CONF_MIN_KELVIN,
|
||||
CONF_MODE_COMMAND_TEMPLATE,
|
||||
@@ -3724,11 +3721,6 @@ MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
default=DEFAULT_QOS,
|
||||
section="mqtt_settings",
|
||||
),
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL: PlatformField(
|
||||
selector=DurationSelector(DurationSelectorConfig(enable_day=True)),
|
||||
required=False,
|
||||
section="mqtt_settings",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ CONF_IMAGE_TOPIC = "image_topic"
|
||||
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
|
||||
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
|
||||
CONF_KEEPALIVE = "keepalive"
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL = "message_expiry_interval"
|
||||
CONF_ORIGIN = "origin"
|
||||
CONF_QOS = ATTR_QOS
|
||||
CONF_RETAIN = ATTR_RETAIN
|
||||
|
||||
@@ -17,7 +17,7 @@ from .models import DATA_MQTT, PublishPayloadType
|
||||
STORED_MESSAGES = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass
|
||||
class TimestampedPublishMessage:
|
||||
"""MQTT Message."""
|
||||
|
||||
@@ -26,8 +26,6 @@ class TimestampedPublishMessage:
|
||||
qos: int
|
||||
retain: bool
|
||||
timestamp: float
|
||||
encoding: str | None
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
def log_message(
|
||||
@@ -37,8 +35,6 @@ def log_message(
|
||||
payload: PublishPayloadType,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
encoding: str | None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Log an outgoing MQTT message."""
|
||||
entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault(
|
||||
@@ -49,13 +45,7 @@ def log_message(
|
||||
"messages": deque(maxlen=STORED_MESSAGES),
|
||||
}
|
||||
msg = TimestampedPublishMessage(
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
timestamp=time.monotonic(),
|
||||
encoding=encoding,
|
||||
kwargs=kwargs,
|
||||
topic, payload, qos, retain, timestamp=time.monotonic()
|
||||
)
|
||||
entity_info["transmitted"][topic]["messages"].append(msg)
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ from .const import (
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
CONF_JSON_ATTRS_TOPIC,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_QOS,
|
||||
@@ -95,6 +94,7 @@ from .const import (
|
||||
CONF_SW_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_VIA_DEVICE,
|
||||
DEFAULT_ENCODING,
|
||||
DOMAIN,
|
||||
MQTT_CONNECTION_STATE,
|
||||
)
|
||||
@@ -153,8 +153,6 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"unit_of_measurement",
|
||||
}
|
||||
|
||||
PUBLISH_KWARGS = (CONF_MESSAGE_EXPIRY_INTERVAL,)
|
||||
|
||||
|
||||
@callback
|
||||
def async_handle_schema_error(
|
||||
@@ -1541,20 +1539,36 @@ class MqttEntity(
|
||||
await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self)
|
||||
debug_info.remove_entity_data(self.hass, self.entity_id)
|
||||
|
||||
async def async_publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: PublishPayloadType,
|
||||
qos: int = 0,
|
||||
retain: bool = False,
|
||||
encoding: str | None = DEFAULT_ENCODING,
|
||||
) -> None:
|
||||
"""Publish message to an MQTT topic."""
|
||||
log_message(self.hass, self.entity_id, topic, payload, qos, retain)
|
||||
await async_publish(
|
||||
self.hass,
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
encoding,
|
||||
)
|
||||
|
||||
async def async_publish_with_config(
|
||||
self, topic: str, payload: PublishPayloadType
|
||||
) -> None:
|
||||
"""Publish payload to a topic using config."""
|
||||
kwargs: dict[str, Any] = {
|
||||
key: value for key, value in self._config.items() if key in PUBLISH_KWARGS
|
||||
}
|
||||
qos: int = self._config[CONF_QOS]
|
||||
retain: bool = self._config[CONF_RETAIN]
|
||||
encoding: str = self._config[CONF_ENCODING]
|
||||
log_message(
|
||||
self.hass, self.entity_id, topic, payload, qos, retain, encoding, **kwargs
|
||||
await self.async_publish(
|
||||
topic,
|
||||
payload,
|
||||
self._config[CONF_QOS],
|
||||
self._config[CONF_RETAIN],
|
||||
self._config[CONF_ENCODING],
|
||||
)
|
||||
await async_publish(self.hass, topic, payload, qos, retain, encoding, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
|
||||
@@ -509,20 +509,10 @@ class MqttComponentConfig:
|
||||
discovery_payload: MQTTDiscoveryPayload
|
||||
|
||||
|
||||
class MessageExpiryInterval(TypedDict, total=False):
|
||||
"""Hold the Message Expiry Interval."""
|
||||
|
||||
days: float
|
||||
hours: float
|
||||
minutes: float
|
||||
seconds: float
|
||||
|
||||
|
||||
class DeviceMqttOptions(TypedDict, total=False):
|
||||
"""Hold the shared MQTT specific options for an MQTT device."""
|
||||
|
||||
qos: int
|
||||
message_expiry_interval: MessageExpiryInterval
|
||||
|
||||
|
||||
class MqttDeviceData(TypedDict, total=False):
|
||||
|
||||
@@ -40,7 +40,6 @@ from .const import (
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
CONF_JSON_ATTRS_TOPIC,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_ORIGIN,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
@@ -67,7 +66,6 @@ SHARED_OPTIONS = [
|
||||
CONF_AVAILABILITY_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_STATE_TOPIC,
|
||||
@@ -163,14 +161,6 @@ MQTT_ORIGIN_INFO_SCHEMA = vol.All(
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def valid_message_expiry_interval(value: Any) -> int:
|
||||
"""Return Message Expiry Interval in seconds."""
|
||||
if isinstance(value, int):
|
||||
return cv.positive_int(value) # type: ignore[no-any-return]
|
||||
return int(cv.positive_time_period_dict(value).total_seconds())
|
||||
|
||||
|
||||
MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
|
||||
@@ -182,7 +172,6 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
|
||||
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
@@ -214,7 +203,6 @@ DEVICE_DISCOVERY_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA,
|
||||
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
|
||||
vol.Optional(CONF_QOS): valid_qos_schema,
|
||||
vol.Optional(CONF_ENCODING): cv.string,
|
||||
}
|
||||
|
||||
@@ -197,11 +197,9 @@
|
||||
},
|
||||
"mqtt_settings": {
|
||||
"data": {
|
||||
"message_expiry_interval": "Message Expiry Interval",
|
||||
"qos": "QoS"
|
||||
},
|
||||
"data_description": {
|
||||
"message_expiry_interval": "Retention time interval for published message.",
|
||||
"qos": "The Quality of Service value the device's entities should use."
|
||||
},
|
||||
"name": "MQTT settings"
|
||||
|
||||
@@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import ATTR_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -15,6 +14,7 @@ from .const import (
|
||||
ATTR_DESCRIPTION,
|
||||
ATTR_EXPIRES,
|
||||
ATTR_HEADLINE,
|
||||
ATTR_ID,
|
||||
ATTR_RECOMMENDED_ACTIONS,
|
||||
ATTR_SENDER,
|
||||
ATTR_SENT,
|
||||
|
||||
@@ -29,6 +29,8 @@ ATTR_SEVERITY: str = "severity"
|
||||
ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions"
|
||||
ATTR_AFFECTED_AREAS: str = "affected_areas"
|
||||
ATTR_WEB: str = "web"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_ID: str = "id"
|
||||
ATTR_SENT: str = "sent"
|
||||
ATTR_START: str = "start"
|
||||
ATTR_EXPIRES: str = "expires"
|
||||
|
||||
@@ -595,8 +595,8 @@ class OpenAIBaseLLMEntity(Entity):
|
||||
)
|
||||
)
|
||||
|
||||
if not model_args["model"].startswith("o"):
|
||||
# o-series models handle this correctly with just a prompt
|
||||
if "reasoning" not in model_args:
|
||||
# Reasoning models handle this correctly with just a prompt
|
||||
remove_citations = True
|
||||
|
||||
tools.append(web_search)
|
||||
|
||||
@@ -37,15 +37,11 @@ class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring")
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
|
||||
udn = discovery_info.upnp[ATTR_UPNP_UDN]
|
||||
if isinstance(udn, list):
|
||||
if not udn:
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
udn = udn[0]
|
||||
_LOGGER.debug(
|
||||
"async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN]
|
||||
)
|
||||
|
||||
_LOGGER.debug("async_step_ssdp: setting unique id %s", udn)
|
||||
|
||||
await self.async_set_unique_id(udn)
|
||||
await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
|
||||
self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location})
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -29,29 +29,29 @@
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"flow_sensor_clicks_cubic_meter": {
|
||||
"default": "mdi:water-pump"
|
||||
"translation_key_0": {
|
||||
"default": "mdi:abc"
|
||||
},
|
||||
"flow_sensor_consumed_liters": {
|
||||
"default": "mdi:water-pump"
|
||||
"translation_key_1": {
|
||||
"default": "mdi:abc"
|
||||
},
|
||||
"flow_sensor_leak_clicks": {
|
||||
"default": "mdi:pipe-leak"
|
||||
"translation_key_2": {
|
||||
"default": "mdi:abc"
|
||||
},
|
||||
"flow_sensor_leak_volume": {
|
||||
"default": "mdi:pipe-leak"
|
||||
"translation_key_3": {
|
||||
"default": "mdi:abc"
|
||||
},
|
||||
"flow_sensor_start_index": {
|
||||
"default": "mdi:water-pump"
|
||||
"translation_key_4": {
|
||||
"default": "mdi:abc"
|
||||
},
|
||||
"flow_sensor_watering_clicks": {
|
||||
"default": "mdi:water-pump"
|
||||
"translation_key_5": {
|
||||
"default": "mdi:abc"
|
||||
},
|
||||
"last_leak_detected": {
|
||||
"default": "mdi:pipe-leak"
|
||||
"translation_key_6": {
|
||||
"default": "mdi:abc"
|
||||
},
|
||||
"rain_sensor_rain_start": {
|
||||
"default": "mdi:weather-pouring"
|
||||
"translation_key_7": {
|
||||
"default": "mdi:abc"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.20.0"]
|
||||
"requirements": ["reolink-aio==0.19.1"]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
)
|
||||
from homeassistant.const import CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -38,6 +38,7 @@ from . import RoborockConfigEntry
|
||||
from .const import (
|
||||
CONF_BASE_URL,
|
||||
CONF_ENTRY_CODE,
|
||||
CONF_REGION,
|
||||
CONF_SHOW_BACKGROUND,
|
||||
CONF_SHOW_ROOMS,
|
||||
CONF_SHOW_WALLS,
|
||||
|
||||
@@ -13,6 +13,8 @@ CONF_USER_DATA = "user_data"
|
||||
CONF_SHOW_BACKGROUND = "show_background"
|
||||
CONF_SHOW_WALLS = "show_walls"
|
||||
CONF_SHOW_ROOMS = "show_rooms"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
CONF_REGION = "region"
|
||||
REGION_OPTIONS = ["auto", "us", "eu", "ru", "cn"]
|
||||
|
||||
# Option Flow steps
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""The System Bridge integration."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from systembridgeconnector.exceptions import (
|
||||
AuthenticationException,
|
||||
@@ -9,34 +11,71 @@ from systembridgeconnector.exceptions import (
|
||||
ConnectionErrorException,
|
||||
DataMissingException,
|
||||
)
|
||||
from systembridgeconnector.models.keyboard_key import KeyboardKey
|
||||
from systembridgeconnector.models.keyboard_text import KeyboardText
|
||||
from systembridgeconnector.models.modules.processes import Process
|
||||
from systembridgeconnector.models.open_path import OpenPath
|
||||
from systembridgeconnector.models.open_url import OpenUrl
|
||||
from systembridgeconnector.version import Version
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_COMMAND,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PATH,
|
||||
CONF_PORT,
|
||||
CONF_TOKEN,
|
||||
CONF_URL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .config_flow import SystemBridgeConfigFlow
|
||||
from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES
|
||||
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, entry_id: str
|
||||
) -> SystemBridgeDataUpdateCoordinator:
|
||||
"""Return the coordinator for a config entry id."""
|
||||
entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
entry_id
|
||||
)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": entry_id},
|
||||
)
|
||||
return entry.runtime_data
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.MEDIA_PLAYER,
|
||||
@@ -45,12 +84,26 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
CONF_BRIDGE = "bridge"
|
||||
CONF_KEY = "key"
|
||||
CONF_TEXT = "text"
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the System Bridge services."""
|
||||
SERVICE_GET_PROCESS_BY_ID = "get_process_by_id"
|
||||
SERVICE_GET_PROCESSES_BY_NAME = "get_processes_by_name"
|
||||
SERVICE_OPEN_PATH = "open_path"
|
||||
SERVICE_POWER_COMMAND = "power_command"
|
||||
SERVICE_OPEN_URL = "open_url"
|
||||
SERVICE_SEND_KEYPRESS = "send_keypress"
|
||||
SERVICE_SEND_TEXT = "send_text"
|
||||
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
POWER_COMMAND_MAP = {
|
||||
"hibernate": "power_hibernate",
|
||||
"lock": "power_lock",
|
||||
"logout": "power_logout",
|
||||
"restart": "power_restart",
|
||||
"shutdown": "power_shutdown",
|
||||
"sleep": "power_sleep",
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -178,6 +231,219 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL):
|
||||
return True
|
||||
|
||||
def valid_device(device: str) -> str:
|
||||
"""Check device is valid."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device)
|
||||
if device_entry is not None:
|
||||
try:
|
||||
return next(
|
||||
entry.entry_id
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
)
|
||||
except StopIteration as exception:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device},
|
||||
) from exception
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device},
|
||||
)
|
||||
|
||||
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the get process by id service call."""
|
||||
_LOGGER.debug("Get process by id: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
processes: list[Process] = coordinator.data.processes
|
||||
|
||||
# Find process.id from list, raise ServiceValidationError if not found
|
||||
try:
|
||||
return asdict(
|
||||
next(
|
||||
process
|
||||
for process in processes
|
||||
if process.id == service_call.data[CONF_ID]
|
||||
)
|
||||
)
|
||||
except StopIteration as exception:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="process_not_found",
|
||||
translation_placeholders={"id": service_call.data[CONF_ID]},
|
||||
) from exception
|
||||
|
||||
async def handle_get_processes_by_name(
|
||||
service_call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handle the get process by name service call."""
|
||||
_LOGGER.debug("Get process by name: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
|
||||
# Find processes from list
|
||||
items: list[dict[str, Any]] = [
|
||||
asdict(process)
|
||||
for process in coordinator.data.processes
|
||||
if process.name is not None
|
||||
and service_call.data[CONF_NAME].lower() in process.name.lower()
|
||||
]
|
||||
|
||||
return {
|
||||
"count": len(items),
|
||||
"processes": list(items),
|
||||
}
|
||||
|
||||
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open path service call."""
|
||||
_LOGGER.debug("Open path: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_path(
|
||||
OpenPath(path=service_call.data[CONF_PATH])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the power command service call."""
|
||||
_LOGGER.debug("Power command: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await getattr(
|
||||
coordinator.websocket_client,
|
||||
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
|
||||
)()
|
||||
return asdict(response)
|
||||
|
||||
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open url service call."""
|
||||
_LOGGER.debug("Open URL: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_url(
|
||||
OpenUrl(url=service_call.data[CONF_URL])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_keypress(
|
||||
KeyboardKey(key=service_call.data[CONF_KEY])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_text(
|
||||
KeyboardText(text=service_call.data[CONF_TEXT])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_PROCESS_BY_ID,
|
||||
handle_get_process_by_id,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_ID): cv.positive_int,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_PROCESSES_BY_NAME,
|
||||
handle_get_processes_by_name,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_OPEN_PATH,
|
||||
handle_open_path,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_PATH): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_POWER_COMMAND,
|
||||
handle_power_command,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_OPEN_URL,
|
||||
handle_open_url,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_URL): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_KEYPRESS,
|
||||
handle_send_keypress,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_KEY): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
description_placeholders={
|
||||
"syntax_keys_documentation_url": "http://robotjs.io/docs/syntax#keys"
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_TEXT,
|
||||
handle_send_text,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_TEXT): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# Reload entry when its updated.
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
|
||||
@@ -188,7 +454,9 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: SystemBridgeConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
|
||||
)
|
||||
if unload_ok:
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
"""Service registration for System Bridge integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from systembridgeconnector.models.keyboard_key import KeyboardKey
|
||||
from systembridgeconnector.models.keyboard_text import KeyboardText
|
||||
from systembridgeconnector.models.modules.processes import Process
|
||||
from systembridgeconnector.models.open_path import OpenPath
|
||||
from systembridgeconnector.models.open_url import OpenUrl
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
service,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_BRIDGE = "bridge"
|
||||
CONF_KEY = "key"
|
||||
CONF_TEXT = "text"
|
||||
|
||||
POWER_COMMAND_MAP = {
|
||||
"hibernate": "power_hibernate",
|
||||
"lock": "power_lock",
|
||||
"logout": "power_logout",
|
||||
"restart": "power_restart",
|
||||
"shutdown": "power_shutdown",
|
||||
"sleep": "power_sleep",
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for System Bridge integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_process_by_id",
|
||||
handle_get_process_by_id,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_ID): cv.positive_int,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_processes_by_name",
|
||||
handle_get_processes_by_name,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"open_path",
|
||||
handle_open_path,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_PATH): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"power_command",
|
||||
handle_power_command,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"open_url",
|
||||
handle_open_url,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_URL): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"send_keypress",
|
||||
handle_send_keypress,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_KEY): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
description_placeholders={
|
||||
"syntax_keys_documentation_url": "https://robotjs.dev/docs/syntax#keys"
|
||||
},
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"send_text",
|
||||
handle_send_text,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_TEXT): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> SystemBridgeDataUpdateCoordinator:
|
||||
"""Return the coordinator for a device id."""
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device_id},
|
||||
)
|
||||
try:
|
||||
entry_id = next(
|
||||
entry.entry_id
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device_id},
|
||||
) from e
|
||||
entry: SystemBridgeConfigEntry = service.async_get_config_entry(
|
||||
hass, DOMAIN, entry_id
|
||||
)
|
||||
return entry.runtime_data
|
||||
|
||||
|
||||
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the get process by id service call."""
|
||||
_LOGGER.debug("Get process by id: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
processes: list[Process] = coordinator.data.processes
|
||||
|
||||
# Find process.id from list, raise ServiceValidationError if not found
|
||||
try:
|
||||
return asdict(
|
||||
next(
|
||||
process
|
||||
for process in processes
|
||||
if process.id == service_call.data[CONF_ID]
|
||||
)
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="process_not_found",
|
||||
translation_placeholders={"id": service_call.data[CONF_ID]},
|
||||
) from e
|
||||
|
||||
|
||||
async def handle_get_processes_by_name(
|
||||
service_call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handle the get process by name service call."""
|
||||
_LOGGER.debug("Get process by name: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
|
||||
# Find processes from list
|
||||
items: list[dict[str, Any]] = [
|
||||
asdict(process)
|
||||
for process in coordinator.data.processes
|
||||
if process.name is not None
|
||||
and service_call.data[CONF_NAME].lower() in process.name.lower()
|
||||
]
|
||||
|
||||
return {
|
||||
"count": len(items),
|
||||
"processes": list(items),
|
||||
}
|
||||
|
||||
|
||||
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open path service call."""
|
||||
_LOGGER.debug("Open path: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_path(
|
||||
OpenPath(path=service_call.data[CONF_PATH])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the power command service call."""
|
||||
_LOGGER.debug("Power command: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await getattr(
|
||||
coordinator.websocket_client,
|
||||
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
|
||||
)()
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open url service call."""
|
||||
_LOGGER.debug("Open URL: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_url(
|
||||
OpenUrl(url=service_call.data[CONF_URL])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_keypress(
|
||||
KeyboardKey(key=service_call.data[CONF_KEY])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_text service call."""
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_text(
|
||||
KeyboardText(text=service_call.data[CONF_TEXT])
|
||||
)
|
||||
return asdict(response)
|
||||
@@ -89,4 +89,3 @@ power_command:
|
||||
- "restart"
|
||||
- "shutdown"
|
||||
- "sleep"
|
||||
translation_key: "power_command"
|
||||
|
||||
@@ -178,18 +178,6 @@
|
||||
"title": "System Bridge upgrade required"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"power_command": {
|
||||
"options": {
|
||||
"hibernate": "Hibernate",
|
||||
"lock": "Lock",
|
||||
"logout": "Logout",
|
||||
"restart": "[%key:common::action::restart%]",
|
||||
"shutdown": "Shutdown",
|
||||
"sleep": "Sleep"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_process_by_id": {
|
||||
"description": "Gets a process by the ID.",
|
||||
|
||||
@@ -58,7 +58,7 @@ send_message:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -101,7 +101,7 @@ send_chat_action:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -195,7 +195,7 @@ send_photo:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -287,7 +287,7 @@ send_media_group:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -372,7 +372,7 @@ send_sticker:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -466,7 +466,7 @@ send_animation:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -560,7 +560,7 @@ send_video:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -645,7 +645,7 @@ send_voice:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -739,7 +739,7 @@ send_document:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -804,7 +804,7 @@ send_location:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -861,7 +861,7 @@ send_poll:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -913,7 +913,7 @@ edit_message:
|
||||
["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
|
||||
selector:
|
||||
object:
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -991,7 +991,7 @@ edit_message_media:
|
||||
["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
|
||||
selector:
|
||||
object:
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -1028,7 +1028,7 @@ edit_caption:
|
||||
["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
|
||||
selector:
|
||||
object:
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -1061,7 +1061,7 @@ edit_replymarkup:
|
||||
["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
|
||||
selector:
|
||||
object:
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -1108,7 +1108,7 @@ delete_message:
|
||||
example: "{{ trigger.event.data.message.message_id }}"
|
||||
selector:
|
||||
text:
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -1129,7 +1129,7 @@ leave_chat:
|
||||
filter:
|
||||
domain: notify
|
||||
integration: telegram_bot
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -1164,7 +1164,7 @@ set_message_reaction:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
@@ -1233,7 +1233,7 @@ send_message_draft:
|
||||
- "markdownv2"
|
||||
- "plain_text"
|
||||
translation_key: "parse_mode"
|
||||
additional_fields:
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
config_entry_id:
|
||||
|
||||
@@ -367,8 +367,8 @@
|
||||
},
|
||||
"name": "Delete message",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -425,8 +425,8 @@
|
||||
},
|
||||
"name": "Edit caption",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -472,8 +472,8 @@
|
||||
},
|
||||
"name": "Edit message",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -535,8 +535,8 @@
|
||||
},
|
||||
"name": "Edit message media",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
},
|
||||
"url_options": {
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
|
||||
@@ -569,8 +569,8 @@
|
||||
},
|
||||
"name": "Edit reply markup",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -592,8 +592,8 @@
|
||||
},
|
||||
"name": "Leave chat",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -671,8 +671,8 @@
|
||||
},
|
||||
"name": "Send animation",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
},
|
||||
"url_options": {
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
|
||||
@@ -705,8 +705,8 @@
|
||||
},
|
||||
"name": "Send chat action",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -784,8 +784,8 @@
|
||||
},
|
||||
"name": "Send document",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
},
|
||||
"url_options": {
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
|
||||
@@ -842,8 +842,8 @@
|
||||
},
|
||||
"name": "Send location",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -889,8 +889,8 @@
|
||||
},
|
||||
"name": "Send media group",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -952,8 +952,8 @@
|
||||
},
|
||||
"name": "Send message",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "Additional options"
|
||||
"advanced": {
|
||||
"name": "Advanced"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -991,8 +991,8 @@
|
||||
},
|
||||
"name": "Send message draft",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1070,8 +1070,8 @@
|
||||
},
|
||||
"name": "Send photo",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "Advanced"
|
||||
},
|
||||
"url_options": {
|
||||
"name": "URL options"
|
||||
@@ -1128,8 +1128,8 @@
|
||||
},
|
||||
"name": "Send poll",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1203,8 +1203,8 @@
|
||||
},
|
||||
"name": "Send sticker",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
},
|
||||
"url_options": {
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
|
||||
@@ -1285,8 +1285,8 @@
|
||||
},
|
||||
"name": "Send video",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
},
|
||||
"url_options": {
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
|
||||
@@ -1363,8 +1363,8 @@
|
||||
},
|
||||
"name": "Send voice",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
},
|
||||
"url_options": {
|
||||
"name": "[%key:component::telegram_bot::services::send_photo::sections::url_options::name%]"
|
||||
@@ -1401,8 +1401,8 @@
|
||||
},
|
||||
"name": "Set message reaction",
|
||||
"sections": {
|
||||
"additional_fields": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::additional_fields::name%]"
|
||||
"advanced": {
|
||||
"name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,7 +497,7 @@
|
||||
"default": "mdi:battery-clock"
|
||||
},
|
||||
"forward_collision_warning": {
|
||||
"default": "mdi:car-emergency",
|
||||
"default": "mdi:car-crash",
|
||||
"state": {
|
||||
"average": "mdi:alert-circle",
|
||||
"early": "mdi:alert-octagon",
|
||||
@@ -634,7 +634,7 @@
|
||||
"default": "mdi:key"
|
||||
},
|
||||
"pedal_position": {
|
||||
"default": "mdi:gauge"
|
||||
"default": "mdi:pedestal"
|
||||
},
|
||||
"powershare_hours_left": {
|
||||
"default": "mdi:clock-time-eight-outline"
|
||||
@@ -794,7 +794,7 @@
|
||||
"service": "mdi:calendar-plus"
|
||||
},
|
||||
"add_precondition_schedule": {
|
||||
"service": "mdi:hvac"
|
||||
"service": "mdi:hvac-outline"
|
||||
},
|
||||
"navigation_gps_request": {
|
||||
"service": "mdi:crosshairs-gps"
|
||||
@@ -803,7 +803,7 @@
|
||||
"service": "mdi:calendar-minus"
|
||||
},
|
||||
"remove_precondition_schedule": {
|
||||
"service": "mdi:hvac-off"
|
||||
"service": "mdi:hvac-off-outline"
|
||||
},
|
||||
"set_scheduled_charging": {
|
||||
"service": "mdi:timeline-clock-outline"
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry)
|
||||
"""Set up UptimeRobot from a config entry."""
|
||||
key: str = entry.data[CONF_API_KEY]
|
||||
if key.startswith(("ur", "m")):
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_key_wrong_type",
|
||||
"Wrong API key type detected, use the 'main' API key"
|
||||
)
|
||||
uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass))
|
||||
|
||||
|
||||
@@ -48,16 +48,11 @@ class UptimeRobotDataUpdateCoordinator(
|
||||
try:
|
||||
response = await self.api.async_get_monitors()
|
||||
except UptimeRobotAuthenticationException as exception:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_authentication_exception",
|
||||
) from exception
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise ConfigEntryAuthFailed(exception) from exception
|
||||
except UptimeRobotException as exception:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_generic_exception",
|
||||
translation_placeholders={"error": "Generic UptimeRobot exception"},
|
||||
) from exception
|
||||
# pylint: disable-next=home-assistant-exception-not-translated
|
||||
raise UpdateFailed(exception) from exception
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(response.data, list)
|
||||
|
||||
@@ -57,16 +57,7 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_authentication_exception": {
|
||||
"message": "API authentication failed, please check your API key"
|
||||
},
|
||||
"api_generic_exception": {
|
||||
"message": "API error: {error}"
|
||||
},
|
||||
"api_key_wrong_type": {
|
||||
"message": "Wrong API key type detected, use the 'main' API key"
|
||||
},
|
||||
"api_switch_exception": {
|
||||
"api_exception": {
|
||||
"message": "Could not turn on/off monitoring: {error}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ def uptimerobot_api_call[_T: UptimeRobotEntity, **_P](
|
||||
except UptimeRobotException as exception:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_switch_exception",
|
||||
translation_key="api_exception",
|
||||
translation_placeholders={"error": "Generic UptimeRobot exception"},
|
||||
) from exception
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"state": {
|
||||
"lightning": "mdi:weather-lightning-rainy",
|
||||
"rain": "mdi:weather-rainy",
|
||||
"rain_snow": "mdi:weather-snowy-rainy",
|
||||
"rain_snow": "mdi:weather-snoy-rainy",
|
||||
"snow": "mdi:weather-snowy"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,7 +28,7 @@ from miio.integrations.humidifier.zhimi.airhumidifier_miot import (
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL, EntityCategory
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
@@ -65,6 +65,8 @@ from .typing import XiaomiMiioConfigEntry
|
||||
ATTR_DISPLAY_ORIENTATION = "display_orientation"
|
||||
ATTR_LED_BRIGHTNESS = "led_brightness"
|
||||
ATTR_PTC_LEVEL = "ptc_level"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODE = "mode"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from homeassistant.components.switch import (
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_MODE,
|
||||
ATTR_MODEL,
|
||||
ATTR_TEMPERATURE,
|
||||
CONF_DEVICE,
|
||||
CONF_HOST,
|
||||
@@ -150,6 +149,8 @@ ATTR_LED = "led"
|
||||
ATTR_IONIZER = "ionizer"
|
||||
ATTR_ANION = "anion"
|
||||
ATTR_LOAD_POWER = "load_power"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_MODEL = "model"
|
||||
ATTR_POWER = "power"
|
||||
ATTR_POWER_MODE = "power_mode"
|
||||
ATTR_POWER_PRICE = "power_price"
|
||||
|
||||
@@ -21,4 +21,4 @@ CONF_INSTANCE_ID = "instance_id"
|
||||
# Polling interval (seconds)
|
||||
DEFAULT_SCAN_INTERVAL = 1800
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.LOCK, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SWITCH]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Lock platform for Xthings Cloud."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.lock import LockEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import XthingsCloudConfigEntry
|
||||
from .entity import XthingsCloudEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: XthingsCloudConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up lock platform."""
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
XthingsCloudLock(coordinator, device_id, device_data)
|
||||
for device_id, device_data in coordinator.data.items()
|
||||
if device_data["type"] == "lock"
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class XthingsCloudLock(XthingsCloudEntity, LockEntity):
|
||||
"""Xthings Cloud lock entity."""
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool | None:
|
||||
"""Return true if lock is locked."""
|
||||
return self.device_data["status"].get("locked")
|
||||
|
||||
@property
|
||||
def is_jammed(self) -> bool | None:
|
||||
"""Return true if lock is jammed."""
|
||||
return self.device_data["status"].get("jammed")
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the device."""
|
||||
await self.coordinator.client.async_lock_lock(self._device_id)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the device."""
|
||||
await self.coordinator.client.async_lock_unlock(self._device_id)
|
||||
@@ -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,113 +0,0 @@
|
||||
"""Checker for invalid MDI icon references.
|
||||
|
||||
Validates that ``mdi:`` icon references in integration code and
|
||||
``icons.json`` files refer to icons that actually exist in the
|
||||
Material Design Icons set.
|
||||
|
||||
- ``E7409``: MDI icon reference not found in Python code
|
||||
- ``E7410``: MDI icon reference not found in icons.json
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from astroid import nodes
|
||||
from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.generated.mdi_icons import MDI_ICONS
|
||||
from pylint_home_assistant.helpers.icons import collect_mdi_icons, load_icons
|
||||
from pylint_home_assistant.helpers.module_info import parse_module
|
||||
|
||||
# Matches strings that look like intentional icon name attempts
|
||||
# (letters, digits, hyphens, underscores). Rejects format templates
|
||||
# (%s, {}, {name}), empty names, and other dynamic patterns.
|
||||
_LOOKS_LIKE_ICON_NAME = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*[a-zA-Z0-9]$")
|
||||
|
||||
|
||||
class MdiIconsChecker(BaseChecker):
|
||||
"""Checker for invalid MDI icon references."""
|
||||
|
||||
name = "home_assistant_mdi_icons"
|
||||
priority = -1
|
||||
msgs = {
|
||||
"E7409": (
|
||||
"MDI icon '%s' does not exist in the Material Design Icons set",
|
||||
"home-assistant-mdi-icon-not-found",
|
||||
"Used when an integration references an MDI icon in Python "
|
||||
"code that does not exist. Check the icon name at "
|
||||
"https://pictogrammers.com/library/mdi/",
|
||||
),
|
||||
"E7410": (
|
||||
"MDI icon '%s' in icons.json does not exist in the "
|
||||
"Material Design Icons set",
|
||||
"home-assistant-mdi-icon-json-not-found",
|
||||
"Used when an integration's icons.json references an MDI "
|
||||
"icon that does not exist. Check the icon name at "
|
||||
"https://pictogrammers.com/library/mdi/",
|
||||
),
|
||||
}
|
||||
options = ()
|
||||
|
||||
_in_integration: bool
|
||||
_checked_icons_json: set[str]
|
||||
|
||||
def open(self) -> None:
|
||||
"""Initialize per-run state."""
|
||||
self._checked_icons_json = set()
|
||||
|
||||
def visit_module(self, node: nodes.Module) -> None:
|
||||
"""Check icons.json and track integration context."""
|
||||
parsed = parse_module(node.name)
|
||||
self._in_integration = parsed is not None
|
||||
if parsed is None:
|
||||
return
|
||||
|
||||
# Only check icons.json once per integration
|
||||
if parsed.domain in self._checked_icons_json:
|
||||
return
|
||||
self._checked_icons_json.add(parsed.domain)
|
||||
|
||||
icons_data = load_icons(node)
|
||||
if icons_data is None:
|
||||
return
|
||||
|
||||
mdi_refs = collect_mdi_icons(icons_data)
|
||||
for icon_ref in sorted(mdi_refs):
|
||||
icon_name = icon_ref[4:] # Strip "mdi:" prefix
|
||||
if icon_name not in MDI_ICONS:
|
||||
self.add_message(
|
||||
"home-assistant-mdi-icon-json-not-found",
|
||||
node=node,
|
||||
args=(icon_ref,),
|
||||
)
|
||||
|
||||
def visit_const(self, node: nodes.Const) -> None:
|
||||
"""Check string constants for invalid MDI icon references."""
|
||||
if not self._in_integration:
|
||||
return
|
||||
|
||||
if not isinstance(node.value, str):
|
||||
return
|
||||
|
||||
if not node.value.startswith("mdi:"):
|
||||
return
|
||||
|
||||
icon_name = node.value[4:] # Strip "mdi:" prefix
|
||||
|
||||
# Only check names that look like intentional icon name attempts.
|
||||
# This skips f-string fragments, format templates (%s, {}),
|
||||
# partial names, and other dynamic patterns.
|
||||
if not _LOOKS_LIKE_ICON_NAME.match(icon_name):
|
||||
return
|
||||
|
||||
if icon_name not in MDI_ICONS:
|
||||
self.add_message(
|
||||
"home-assistant-mdi-icon-not-found",
|
||||
node=node,
|
||||
args=(node.value,),
|
||||
)
|
||||
|
||||
|
||||
def register(linter: PyLinter) -> None:
|
||||
"""Register the checker."""
|
||||
linter.register_checker(MdiIconsChecker(linter))
|
||||
@@ -1 +0,0 @@
|
||||
"""Generated files for the pylint Home Assistant plugin."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
"""Helpers for reading integration icon files."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from astroid import nodes
|
||||
import orjson
|
||||
|
||||
from .integration import get_integration_dir
|
||||
|
||||
_icons_cache: dict[str, dict | None] = {}
|
||||
|
||||
|
||||
def clear_icons_cache() -> None:
|
||||
"""Clear the icons cache (used by tests)."""
|
||||
_icons_cache.clear()
|
||||
|
||||
|
||||
def load_icons(module: nodes.Module) -> dict | None:
|
||||
"""Load and cache the icons.json for the current integration.
|
||||
|
||||
Returns the parsed JSON as a dict, or ``None`` if not found.
|
||||
"""
|
||||
integration_dir = get_integration_dir(module)
|
||||
if integration_dir is None:
|
||||
return None
|
||||
|
||||
cache_key = str(integration_dir)
|
||||
if cache_key in _icons_cache:
|
||||
return _icons_cache[cache_key]
|
||||
|
||||
icons_path = integration_dir / "icons.json"
|
||||
result: dict | None = None
|
||||
if icons_path.exists():
|
||||
with contextlib.suppress(orjson.JSONDecodeError, OSError):
|
||||
parsed = orjson.loads(icons_path.read_bytes())
|
||||
if isinstance(parsed, dict):
|
||||
result = parsed
|
||||
|
||||
_icons_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
|
||||
def collect_mdi_icons(
|
||||
data: dict | list | str, icons: set[str] | None = None
|
||||
) -> set[str]:
|
||||
"""Recursively collect all mdi: icon references from a data structure."""
|
||||
if icons is None:
|
||||
icons = set()
|
||||
|
||||
if isinstance(data, str):
|
||||
if data.startswith("mdi:"):
|
||||
icons.add(data)
|
||||
elif isinstance(data, dict):
|
||||
for value in data.values():
|
||||
collect_mdi_icons(value, icons)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
collect_mdi_icons(item, icons)
|
||||
|
||||
return icons
|
||||
Generated
+6
-3
@@ -1516,7 +1516,7 @@ lxml==6.0.1
|
||||
matrix-nio==0.25.2
|
||||
|
||||
# homeassistant.components.matter
|
||||
matter-python-client==0.7.1
|
||||
matter-python-client==0.6.0
|
||||
|
||||
# homeassistant.components.maxcube
|
||||
maxcube-api==0.4.3
|
||||
@@ -2641,7 +2641,7 @@ python-google-weather-api==0.0.6
|
||||
python-homeassistant-analytics==0.9.0
|
||||
|
||||
# homeassistant.components.homewizard
|
||||
python-homewizard-energy==10.1.0
|
||||
python-homewizard-energy==10.0.1
|
||||
|
||||
# homeassistant.components.hp_ilo
|
||||
python-hpilo==4.4.3
|
||||
@@ -2871,7 +2871,7 @@ renault-api==0.5.10
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.20.0
|
||||
reolink-aio==0.19.1
|
||||
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==3.2.0
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from . import (
|
||||
json,
|
||||
labs,
|
||||
manifest,
|
||||
mdi_icons,
|
||||
metadata,
|
||||
mqtt,
|
||||
mypy_config,
|
||||
@@ -66,7 +65,6 @@ INTEGRATION_PLUGINS = [
|
||||
HASS_PLUGINS = [
|
||||
core_files,
|
||||
docker,
|
||||
mdi_icons,
|
||||
mypy_config,
|
||||
metadata,
|
||||
]
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Generate MDI icons file for the pylint plugin."""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from importlib.resources import files
|
||||
import json
|
||||
|
||||
from .model import Config, Integration
|
||||
from .serializer import format_python_namespace
|
||||
|
||||
_TARGET = "pylint/plugins/pylint_home_assistant/generated/mdi_icons.py"
|
||||
|
||||
|
||||
def _get_frontend_version() -> str | None:
|
||||
"""Get the installed home-assistant-frontend version."""
|
||||
try:
|
||||
return version("home-assistant-frontend")
|
||||
except PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _load_mdi_icons() -> set[str]:
|
||||
"""Load the MDI icon names from the frontend package."""
|
||||
try:
|
||||
mdi_dir = files("hass_frontend") / "static" / "mdi"
|
||||
icon_list_path = mdi_dir / "iconList.json"
|
||||
data = json.loads(icon_list_path.read_text(encoding="utf-8"))
|
||||
return {icon["name"] for icon in data}
|
||||
except ImportError, FileNotFoundError, json.JSONDecodeError, KeyError:
|
||||
return set()
|
||||
|
||||
|
||||
def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Validate the generated MDI icons file is up to date."""
|
||||
frontend_version = _get_frontend_version()
|
||||
if frontend_version is None:
|
||||
return
|
||||
|
||||
icons = _load_mdi_icons()
|
||||
if not icons:
|
||||
config.add_error(
|
||||
"mdi_icons",
|
||||
"Could not load MDI icons from home-assistant-frontend",
|
||||
)
|
||||
return
|
||||
|
||||
content = format_python_namespace(
|
||||
{
|
||||
"FRONTEND_VERSION": frontend_version,
|
||||
"MDI_ICONS": icons,
|
||||
},
|
||||
annotations={
|
||||
"FRONTEND_VERSION": "Final[str]",
|
||||
"MDI_ICONS": "Final[set[str]]",
|
||||
},
|
||||
)
|
||||
|
||||
config.cache["mdi_icons_content"] = content
|
||||
|
||||
if config.specific_integrations:
|
||||
return
|
||||
|
||||
target_path = config.root / _TARGET
|
||||
if not target_path.exists() or target_path.read_text() != content:
|
||||
config.add_error(
|
||||
"mdi_icons",
|
||||
f"File {_TARGET} is not up to date. Run python3 -m script.hassfest",
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
|
||||
def generate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Generate MDI icons file."""
|
||||
if "mdi_icons_content" not in config.cache:
|
||||
return
|
||||
target_path = config.root / _TARGET
|
||||
target_path.write_text(config.cache["mdi_icons_content"])
|
||||
+62
-402
@@ -2,14 +2,9 @@
|
||||
"""Helper script to split test into n buckets."""
|
||||
|
||||
import argparse
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field, replace
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from math import ceil
|
||||
from operator import attrgetter, itemgetter
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
@@ -20,21 +15,13 @@ from typing import Final
|
||||
# place to subdivide to keep each pytest invocation roughly equal in size.
|
||||
_FAN_OUT_DIRS: Final = frozenset({"components"})
|
||||
|
||||
# Cache file format version; bump on any incompatible schema change so old
|
||||
# caches are ignored rather than misread.
|
||||
_CACHE_VERSION: Final = 3
|
||||
|
||||
# Fall back from file-level to directory-level pytest collection when
|
||||
# misses make up more than this fraction of the tree; past that point
|
||||
# the per-file argv overhead pytest pays outweighs the cost of letting
|
||||
# it re-walk dirs and re-collect the hits.
|
||||
_DIR_LEVEL_MISS_RATIO: Final = 0.3
|
||||
|
||||
|
||||
class Bucket:
|
||||
"""Class to hold bucket."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
):
|
||||
"""Initialize bucket."""
|
||||
self.total_tests = 0
|
||||
self._paths: list[str] = []
|
||||
@@ -60,56 +47,43 @@ class BucketHolder:
|
||||
self._buckets: list[Bucket] = [Bucket() for _ in range(bucket_count)]
|
||||
|
||||
def split_tests(self, test_folder: TestFolder) -> None:
|
||||
"""Place atomic units via best-fit; oversized ones go to the smallest bucket."""
|
||||
"""Split tests into buckets."""
|
||||
digits = len(str(test_folder.total_tests))
|
||||
by_load = attrgetter("total_tests")
|
||||
units = sorted(self._atomic_units(test_folder), key=itemgetter(0), reverse=True)
|
||||
for size, items in units:
|
||||
for item in items:
|
||||
tag = " (same bucket)" if item is not items[0] else ""
|
||||
print(f"{item.total_tests:>{digits}} tests in {item.path}{tag}")
|
||||
fits = [
|
||||
b
|
||||
for b in self._buckets
|
||||
if b.total_tests + size <= self._tests_per_bucket
|
||||
]
|
||||
bucket = max(fits, key=by_load) if fits else min(self._buckets, key=by_load)
|
||||
for item in items:
|
||||
bucket.add(item)
|
||||
sorted_tests = sorted(
|
||||
test_folder.get_all_flatten(), reverse=True, key=lambda x: x.total_tests
|
||||
)
|
||||
for tests in sorted_tests:
|
||||
if tests.added_to_bucket:
|
||||
# Already added to bucket
|
||||
continue
|
||||
|
||||
print(f"{tests.total_tests:>{digits}} tests in {tests.path}")
|
||||
smallest_bucket = min(self._buckets, key=lambda x: x.total_tests)
|
||||
is_file = isinstance(tests, TestFile)
|
||||
if (
|
||||
smallest_bucket.total_tests + tests.total_tests < self._tests_per_bucket
|
||||
) or is_file:
|
||||
smallest_bucket.add(tests)
|
||||
# Ensure all files from the same folder are in the same bucket
|
||||
# to ensure that syrupy correctly identifies unused snapshots
|
||||
if is_file:
|
||||
for other_test in tests.parent.children.values():
|
||||
if other_test is tests or isinstance(other_test, TestFolder):
|
||||
continue
|
||||
print(
|
||||
f"{other_test.total_tests:>{digits}}"
|
||||
f" tests in {other_test.path}"
|
||||
" (same bucket)"
|
||||
)
|
||||
smallest_bucket.add(other_test)
|
||||
|
||||
# verify that all tests are added to a bucket
|
||||
if not test_folder.added_to_bucket:
|
||||
raise ValueError("Not all tests are added to a bucket")
|
||||
|
||||
def _atomic_units(
|
||||
self, folder: TestFolder
|
||||
) -> Iterator[tuple[int, list[TestFolder | TestFile]]]:
|
||||
"""Yield ``(size, items)`` placement units.
|
||||
|
||||
A folder that fits is one unit; otherwise same-dir files form
|
||||
a unit only when the folder has syrupy snapshots, else each
|
||||
file stands alone. Sub-folders recurse independently.
|
||||
"""
|
||||
if folder.total_tests <= self._tests_per_bucket:
|
||||
yield folder.total_tests, [folder]
|
||||
return
|
||||
|
||||
sibling_files = [c for c in folder.children.values() if isinstance(c, TestFile)]
|
||||
if sibling_files:
|
||||
if _has_snapshots(folder.path):
|
||||
yield (
|
||||
sum(f.total_tests for f in sibling_files),
|
||||
list(sibling_files),
|
||||
)
|
||||
else:
|
||||
for file in sibling_files:
|
||||
yield file.total_tests, [file]
|
||||
for child in folder.children.values():
|
||||
if isinstance(child, TestFolder):
|
||||
yield from self._atomic_units(child)
|
||||
|
||||
def create_output_file(self) -> None:
|
||||
def create_ouput_file(self) -> None:
|
||||
"""Create output file."""
|
||||
with Path("pytest_buckets.txt").open("w", encoding="utf-8") as file:
|
||||
with Path("pytest_buckets.txt").open("w") as file:
|
||||
for idx, bucket in enumerate(self._buckets):
|
||||
print(f"Bucket {idx + 1} has {bucket.total_tests} tests")
|
||||
file.write(bucket.get_paths_line())
|
||||
@@ -196,15 +170,6 @@ class TestFolder:
|
||||
return result
|
||||
|
||||
|
||||
def _has_snapshots(folder_path: Path) -> bool:
|
||||
"""Return True when ``folder_path/snapshots`` holds ``.ambr`` files.
|
||||
|
||||
Same-dir tests must share a pytest run so syrupy can spot unused
|
||||
snapshots; without snapshots that constraint doesn't apply.
|
||||
"""
|
||||
return any((folder_path / "snapshots").glob("*.ambr"))
|
||||
|
||||
|
||||
def _collect_batch(paths: list[Path]) -> tuple[str, str, int]:
|
||||
"""Run pytest --collect-only on a batch of paths."""
|
||||
result = subprocess.run(
|
||||
@@ -251,343 +216,44 @@ def _enumerate_batch_paths(path: Path) -> list[Path]:
|
||||
return paths
|
||||
|
||||
|
||||
def _hash_file(path: Path) -> str:
|
||||
"""Return a short content hash for ``path``."""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
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)]
|
||||
|
||||
|
||||
def _walk_test_tree(root: Path) -> tuple[list[Path], list[Path]]:
|
||||
"""Walk ``root`` once and return (test files, fixture files).
|
||||
|
||||
Fixtures are every non-``test_*.py`` ``.py``: conftests and helpers
|
||||
like ``common.py`` that drive parametrize imports. Uses ``os.walk``
|
||||
(~2x faster than ``Path.rglob`` on this tree) and prunes ``.``/``_``
|
||||
subdirs.
|
||||
"""
|
||||
test_files: list[Path] = []
|
||||
fixtures: list[Path] = []
|
||||
for dirpath, dirnames, filenames in os.walk(root):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith((".", "_"))]
|
||||
base = Path(dirpath)
|
||||
for name in filenames:
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
if name.startswith("test_"):
|
||||
test_files.append(base / name)
|
||||
else:
|
||||
fixtures.append(base / name)
|
||||
test_files.sort()
|
||||
fixtures.sort()
|
||||
return test_files, fixtures
|
||||
|
||||
|
||||
_PROJECT_ROOT_MARKERS: Final = frozenset(
|
||||
{"pyproject.toml", "setup.py", "setup.cfg", "pytest.ini", "tox.ini"}
|
||||
)
|
||||
|
||||
|
||||
def _find_ancestor_fixtures(root: Path) -> list[Path]:
|
||||
"""Return non-``test_*.py`` Python files above ``root``, up to the project root.
|
||||
|
||||
Includes conftests and helper modules (eg ``common.py``); subtree
|
||||
runs need both so shared ancestor helpers like
|
||||
``tests/components/common.py`` still invalidate descendants.
|
||||
Stops at the first ancestor containing a project-root marker so we
|
||||
don't read unrelated ``.py`` files outside the repo or trip on
|
||||
dirs we can't list.
|
||||
"""
|
||||
fixtures: list[Path] = []
|
||||
current = root.resolve().parent
|
||||
while True:
|
||||
with suppress(OSError):
|
||||
fixtures.extend(
|
||||
entry
|
||||
for entry in current.glob("*.py")
|
||||
if not entry.name.startswith("test_")
|
||||
)
|
||||
if any((current / marker).exists() for marker in _PROJECT_ROOT_MARKERS):
|
||||
break
|
||||
if current == current.parent:
|
||||
break
|
||||
current = current.parent
|
||||
return fixtures
|
||||
|
||||
|
||||
def _build_fixtures_by_dir(
|
||||
root: Path, descendants: list[Path]
|
||||
) -> dict[Path, list[Path]]:
|
||||
"""Bucket descendants plus ancestor fixtures by resolved parent dir."""
|
||||
by_dir: dict[Path, list[Path]] = {}
|
||||
for fixture in (*_find_ancestor_fixtures(root), *descendants):
|
||||
by_dir.setdefault(fixture.parent.resolve(), []).append(fixture)
|
||||
return by_dir
|
||||
|
||||
|
||||
def _file_fixture_hash(
|
||||
test_file: Path,
|
||||
root: Path,
|
||||
fixtures_by_dir: dict[Path, list[Path]],
|
||||
blob_cache: dict[Path, bytes] | None = None,
|
||||
dir_cache: dict[Path, str] | None = None,
|
||||
) -> str:
|
||||
"""Hash every ``.py`` fixture on the test file's ancestor path.
|
||||
|
||||
Catches conftests and helper modules (``common.py`` etc.) at any
|
||||
level so parametrize imports from shared helpers invalidate
|
||||
descendants, while sibling subtrees stay warm. Pass shared
|
||||
``blob_cache``/``dir_cache`` dicts to memoize across many files.
|
||||
"""
|
||||
test_dir = test_file.parent.resolve()
|
||||
if dir_cache is not None and (cached := dir_cache.get(test_dir)) is not None:
|
||||
return cached
|
||||
relevant: list[Path] = []
|
||||
current = test_dir
|
||||
while True:
|
||||
relevant.extend(fixtures_by_dir.get(current, ()))
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
relevant.sort()
|
||||
digest = hashlib.sha256()
|
||||
for fixture in relevant:
|
||||
blob = blob_cache.get(fixture) if blob_cache is not None else None
|
||||
if blob is None:
|
||||
# relpath keeps the hash machine-stable across ancestor paths.
|
||||
blob = (
|
||||
os.path.relpath(fixture, root).encode()
|
||||
+ b"\0"
|
||||
+ fixture.read_bytes()
|
||||
+ b"\0"
|
||||
)
|
||||
if blob_cache is not None:
|
||||
blob_cache[fixture] = blob
|
||||
digest.update(blob)
|
||||
result = digest.hexdigest()
|
||||
if dir_cache is not None:
|
||||
dir_cache[test_dir] = result
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
"""Cached test count plus its scope hash for a single file."""
|
||||
|
||||
hash: str
|
||||
fixture_hash: str
|
||||
count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Cache:
|
||||
"""Mapping of test file path → cached entry."""
|
||||
|
||||
entries: dict[str, _CacheEntry]
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> _Cache:
|
||||
"""Load cache; any drift (missing, bad, version, malformed) returns empty."""
|
||||
try:
|
||||
raw = json.loads(path.read_bytes())
|
||||
except OSError, ValueError:
|
||||
raw = None
|
||||
if not (
|
||||
isinstance(raw, dict)
|
||||
and raw.get("version") == _CACHE_VERSION
|
||||
and isinstance(raw.get("files"), dict)
|
||||
):
|
||||
return cls(entries={})
|
||||
entries: dict[str, _CacheEntry] = {}
|
||||
for key, value in raw["files"].items():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
hash_value = value.get("hash")
|
||||
fixture_hash = value.get("fixture_hash")
|
||||
count = value.get("count")
|
||||
# bool is an int subclass; reject true/false and negatives so
|
||||
# corrupted JSON can't feed bucket sizing a bogus weight.
|
||||
if (
|
||||
not isinstance(hash_value, str)
|
||||
or not isinstance(fixture_hash, str)
|
||||
or not isinstance(count, int)
|
||||
or isinstance(count, bool)
|
||||
or count < 0
|
||||
):
|
||||
continue
|
||||
entries[key] = _CacheEntry(
|
||||
hash=hash_value, fixture_hash=fixture_hash, count=count
|
||||
)
|
||||
return cls(entries=entries)
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Write the cache to ``path``, creating parent dirs as needed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": _CACHE_VERSION,
|
||||
"files": {
|
||||
key: {
|
||||
"hash": entry.hash,
|
||||
"fixture_hash": entry.fixture_hash,
|
||||
"count": entry.count,
|
||||
}
|
||||
for key, entry in sorted(self.entries.items())
|
||||
},
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_entries(
|
||||
test_files: list[Path],
|
||||
cache: _Cache,
|
||||
root: Path,
|
||||
fixtures_by_dir: dict[Path, list[Path]],
|
||||
) -> tuple[dict[Path, _CacheEntry], list[Path]]:
|
||||
"""Build an entry for every file; return ``(entries, misses)``.
|
||||
|
||||
Hits reuse the stored entry; misses get fresh hashes with a
|
||||
count=0 placeholder for the caller to fill in after pytest runs.
|
||||
Shared caches memoize fixture blobs and per-dir hashes so each
|
||||
fixture file is read once and each unique dir hashed once.
|
||||
"""
|
||||
blob_cache: dict[Path, bytes] = {}
|
||||
dir_cache: dict[Path, str] = {}
|
||||
entries: dict[Path, _CacheEntry] = {}
|
||||
misses: list[Path] = []
|
||||
for file in test_files:
|
||||
file_hash = _hash_file(file)
|
||||
fixture_hash = _file_fixture_hash(
|
||||
file, root, fixtures_by_dir, blob_cache, dir_cache
|
||||
)
|
||||
cached = cache.entries.get(str(file.relative_to(root)))
|
||||
if (
|
||||
cached is not None
|
||||
and cached.hash == file_hash
|
||||
and cached.fixture_hash == fixture_hash
|
||||
):
|
||||
entries[file] = cached
|
||||
else:
|
||||
entries[file] = _CacheEntry(
|
||||
hash=file_hash, fixture_hash=fixture_hash, count=0
|
||||
)
|
||||
misses.append(file)
|
||||
return entries, misses
|
||||
|
||||
|
||||
def _run_collect_batches(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
"""Run pytest --collect-only across ``paths`` using a process pool."""
|
||||
workers = min(len(paths), os.cpu_count() or 1) or 1
|
||||
batches = [paths[i::workers] for i in range(workers)]
|
||||
if workers == 1:
|
||||
return [_collect_batch(batches[0])]
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
return list(executor.map(_collect_batch, batches))
|
||||
results = [_collect_batch(batches[0])]
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
results = list(executor.map(_collect_batch, batches))
|
||||
|
||||
|
||||
def _parse_collect_output(stdout: str) -> dict[Path, int]:
|
||||
"""Parse ``pytest --collect-only -qq`` output into ``{path: count}``."""
|
||||
counts: dict[Path, int] = {}
|
||||
for line in stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not file_path or not total_tests:
|
||||
raise ValueError(f"Unexpected line: {line}")
|
||||
counts[Path(file_path)] = int(total_tests)
|
||||
return counts
|
||||
|
||||
|
||||
def _run_pytest_collect(paths: list[Path]) -> dict[Path, int]:
|
||||
"""Run pytest --collect-only across ``paths`` and parse the output."""
|
||||
counts: dict[Path, int] = {}
|
||||
for stdout, stderr, returncode in _run_collect_batches(paths):
|
||||
folder = TestFolder(path)
|
||||
for stdout, stderr, returncode in results:
|
||||
if returncode != 0:
|
||||
print("Failed to collect tests:")
|
||||
print(stderr)
|
||||
print(stdout)
|
||||
sys.exit(1)
|
||||
# Surface stderr from successful runs too; pytest puts deprecation
|
||||
# and import warnings here that would otherwise vanish.
|
||||
if stderr.strip():
|
||||
sys.stderr.write(stderr)
|
||||
try:
|
||||
counts.update(_parse_collect_output(stdout))
|
||||
except ValueError as err:
|
||||
print(err)
|
||||
sys.exit(1)
|
||||
return counts
|
||||
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)
|
||||
|
||||
def _build_folder(root: Path, counts: dict[Path, int]) -> TestFolder:
|
||||
"""Build a ``TestFolder`` from ``{path: count}``; zero-count files are skipped."""
|
||||
folder = TestFolder(root)
|
||||
for file_path, count in counts.items():
|
||||
if count:
|
||||
folder.add_test_file(TestFile(count, file_path))
|
||||
return folder
|
||||
|
||||
|
||||
def _exit_if_empty(paths: list[Path], root: Path) -> None:
|
||||
"""Exit with a clear message when no eligible test paths were found."""
|
||||
if not paths:
|
||||
print(f"No eligible test paths found under {root}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _collect_tests_uncached(path: Path) -> TestFolder:
|
||||
"""Hand pytest the top-level dirs; the pre-cache path when ``--cache`` is unset."""
|
||||
batch_paths = _enumerate_batch_paths(path)
|
||||
_exit_if_empty(batch_paths, path)
|
||||
return _build_folder(path, _run_pytest_collect(batch_paths))
|
||||
|
||||
|
||||
def _collect_tests_cached(path: Path, cache_path: Path) -> TestFolder:
|
||||
"""Collect tests using an on-disk cache for incremental updates."""
|
||||
all_test_files, fixtures = _walk_test_tree(path)
|
||||
_exit_if_empty(all_test_files, path)
|
||||
|
||||
fixtures_by_dir = _build_fixtures_by_dir(path, fixtures)
|
||||
cache = _Cache.load(cache_path)
|
||||
entries, misses = _resolve_entries(all_test_files, cache, path, fixtures_by_dir)
|
||||
hits = len(all_test_files) - len(misses)
|
||||
print(f"Cache: {hits} hits / {len(misses)} misses / {len(all_test_files)} total")
|
||||
|
||||
if misses:
|
||||
# Past _DIR_LEVEL_MISS_RATIO the per-file argv overhead beats
|
||||
# re-walking the dirs, so fall back to dir-level collection.
|
||||
if not hits or len(misses) > len(all_test_files) * _DIR_LEVEL_MISS_RATIO:
|
||||
collect_paths = _enumerate_batch_paths(path)
|
||||
else:
|
||||
collect_paths = misses
|
||||
new_counts = _run_pytest_collect(collect_paths)
|
||||
# Files pytest returned no count for stay at 0; cached so they
|
||||
# aren't re-collected next run.
|
||||
for file in misses:
|
||||
entries[file] = replace(entries[file], count=new_counts.get(file, 0))
|
||||
|
||||
_Cache(entries={str(f.relative_to(path)): e for f, e in entries.items()}).save(
|
||||
cache_path
|
||||
)
|
||||
return _build_folder(path, {f: e.count for f, e in entries.items()})
|
||||
|
||||
|
||||
def collect_tests(path: Path, cache_path: Path | None = None) -> TestFolder:
|
||||
"""Collect all tests, using an on-disk cache when ``cache_path`` is set."""
|
||||
if cache_path is None:
|
||||
return _collect_tests_uncached(path)
|
||||
if path.is_file():
|
||||
# No fixture tree to scope against; bypass cache to avoid stale hits.
|
||||
print(f"--cache ignored: {path} is a single file")
|
||||
return _collect_tests_uncached(path)
|
||||
return _collect_tests_cached(path, cache_path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Execute script."""
|
||||
parser = argparse.ArgumentParser(description="Split tests into n buckets.")
|
||||
@@ -610,17 +276,11 @@ def main() -> None:
|
||||
help="Path to the test files to split into buckets",
|
||||
type=Path,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache",
|
||||
help="Path to a JSON file used to cache per-file test counts",
|
||||
type=Path,
|
||||
default=None,
|
||||
)
|
||||
|
||||
arguments = parser.parse_args()
|
||||
|
||||
print("Collecting tests...")
|
||||
tests = collect_tests(arguments.path, arguments.cache)
|
||||
tests = collect_tests(arguments.path)
|
||||
tests_per_bucket = ceil(tests.total_tests / arguments.bucket_count)
|
||||
|
||||
bucket_holder = BucketHolder(tests_per_bucket, arguments.bucket_count)
|
||||
@@ -630,7 +290,7 @@ def main() -> None:
|
||||
print(f"Total tests: {tests.total_tests}")
|
||||
print(f"Estimated tests per bucket: {tests_per_bucket}")
|
||||
|
||||
bucket_holder.create_output_file()
|
||||
bucket_holder.create_ouput_file()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -102,37 +102,37 @@ async def test_login(hass: HomeAssistant) -> None:
|
||||
|
||||
provider = hass.auth.auth_providers[0]
|
||||
result = await hass.auth.login_flow.async_init((provider.type, provider.id))
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "incorrect-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "test-user", "password": "incorrect-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["data_schema"].schema.get("pin") is str
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"pin": "invalid-code"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_code"
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"pin": "123456"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"].id == "mock-id"
|
||||
|
||||
|
||||
@@ -149,9 +149,9 @@ async def test_setup_flow(hass: HomeAssistant) -> None:
|
||||
flow = await auth_module.async_setup_flow("new-user")
|
||||
|
||||
result = await flow.async_step_init()
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
result = await flow.async_step_init({"pin": "abcdefg"})
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert auth_module._data[1]["user_id"] == "new-user"
|
||||
assert auth_module._data[1]["pin"] == "abcdefg"
|
||||
|
||||
@@ -137,25 +137,25 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
provider = hass.auth.auth_providers[0]
|
||||
|
||||
result = await hass.auth.login_flow.async_init((provider.type, provider.id))
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "incorrect-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "test-user", "password": "incorrect-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
with patch("pyotp.HOTP.at", return_value=MOCK_CODE):
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["data_schema"].schema.get("code") is str
|
||||
|
||||
@@ -173,7 +173,7 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"code": "invalid-code"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["errors"]["base"] == "invalid_code"
|
||||
|
||||
@@ -191,7 +191,7 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"code": "invalid-code"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["errors"]["base"] == "invalid_code"
|
||||
|
||||
@@ -199,7 +199,7 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"code": "invalid-code"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.ABORT
|
||||
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||
assert result["reason"] == "too_many_retry"
|
||||
|
||||
# wait service call finished
|
||||
@@ -207,13 +207,13 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
|
||||
# restart login
|
||||
result = await hass.auth.login_flow.async_init((provider.type, provider.id))
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
with patch("pyotp.HOTP.at", return_value=MOCK_CODE):
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["data_schema"].schema.get("code") is str
|
||||
|
||||
@@ -231,7 +231,7 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"code": MOCK_CODE}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"].id == "mock-id"
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ async def test_setup_user_notify_service(hass: HomeAssistant) -> None:
|
||||
|
||||
flow = await notify_auth_module.async_setup_flow("test-user")
|
||||
step = await flow.async_step_init()
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert step["step_id"] == "init"
|
||||
schema = step["data_schema"]
|
||||
schema({"notify_service": "test2"})
|
||||
@@ -277,7 +277,7 @@ async def test_setup_user_notify_service(hass: HomeAssistant) -> None:
|
||||
|
||||
with patch("pyotp.HOTP.at", return_value=MOCK_CODE):
|
||||
step = await flow.async_step_init({"notify_service": "test1"})
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert step["step_id"] == "setup"
|
||||
|
||||
# wait service call finished
|
||||
@@ -357,7 +357,7 @@ async def test_setup_user_no_notify_service(hass: HomeAssistant) -> None:
|
||||
|
||||
flow = await notify_auth_module.async_setup_flow("test-user")
|
||||
step = await flow.async_step_init()
|
||||
assert step["type"] is data_entry_flow.FlowResultType.ABORT
|
||||
assert step["type"] == data_entry_flow.FlowResultType.ABORT
|
||||
assert step["reason"] == "no_available_service"
|
||||
|
||||
|
||||
@@ -394,13 +394,13 @@ async def test_not_raise_exception_when_service_not_exist(hass: HomeAssistant) -
|
||||
provider = hass.auth.auth_providers[0]
|
||||
|
||||
result = await hass.auth.login_flow.async_init((provider.type, provider.id))
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
with patch("pyotp.HOTP.at", return_value=MOCK_CODE):
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.ABORT
|
||||
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||
assert result["reason"] == "unknown_error"
|
||||
|
||||
# wait service call finished
|
||||
|
||||
@@ -95,24 +95,24 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
provider = hass.auth.auth_providers[0]
|
||||
|
||||
result = await hass.auth.login_flow.async_init((provider.type, provider.id))
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "incorrect-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "test-user", "password": "incorrect-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["data_schema"].schema.get("code") is str
|
||||
|
||||
@@ -120,7 +120,7 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"code": "invalid-code"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["step_id"] == "mfa"
|
||||
assert result["errors"]["base"] == "invalid_code"
|
||||
|
||||
@@ -128,7 +128,7 @@ async def test_login_flow_validates_mfa(hass: HomeAssistant) -> None:
|
||||
result = await hass.auth.login_flow.async_configure(
|
||||
result["flow_id"], {"code": MOCK_CODE}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"].id == "mock-id"
|
||||
|
||||
|
||||
|
||||
@@ -139,18 +139,18 @@ async def test_login_flow_validates(
|
||||
"""Test login flow."""
|
||||
flow = await provider.async_login_flow({})
|
||||
result = await flow.async_step_init()
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"username": "bad-user", "password": "bad-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"username": "good-user", "password": "good-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"]["username"] == "good-user"
|
||||
|
||||
|
||||
@@ -160,5 +160,5 @@ async def test_strip_username(provider: command_line.CommandLineAuthProvider) ->
|
||||
result = await flow.async_step_init(
|
||||
{"username": "\t\ngood-user ", "password": "good-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"]["username"] == "good-user"
|
||||
|
||||
@@ -161,24 +161,24 @@ async def test_login_flow_validates(data: hass_auth.Data, hass: HomeAssistant) -
|
||||
)
|
||||
flow = await provider.async_login_flow({})
|
||||
result = await flow.async_step_init()
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"username": "incorrect-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"username": "TEST-user ", "password": "incorrect-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"username": "test-USER", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"]["username"] == "test-USER"
|
||||
|
||||
|
||||
@@ -260,24 +260,24 @@ async def test_legacy_login_flow_validates(
|
||||
)
|
||||
flow = await provider.async_login_flow({})
|
||||
result = await flow.async_step_init()
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"username": "incorrect-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"username": "test-user", "password": "incorrect-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_auth"
|
||||
|
||||
result = await flow.async_step_init(
|
||||
{"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result["data"]["username"] == "test-user"
|
||||
|
||||
|
||||
|
||||
+15
-15
@@ -172,12 +172,12 @@ async def test_create_new_user(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
step = await manager.login_flow.async_init(("insecure_example", None))
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
step = await manager.login_flow.async_configure(
|
||||
step["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
assert step["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert step["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
credential = step["result"]
|
||||
assert credential is not None
|
||||
|
||||
@@ -241,12 +241,12 @@ async def test_login_as_existing_user(mock_hass) -> None:
|
||||
)
|
||||
|
||||
step = await manager.login_flow.async_init(("insecure_example", None))
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
step = await manager.login_flow.async_configure(
|
||||
step["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
assert step["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert step["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
|
||||
credential = step["result"]
|
||||
user = await manager.async_get_user_by_credentials(credential)
|
||||
@@ -840,14 +840,14 @@ async def test_login_with_auth_module(mock_hass) -> None:
|
||||
)
|
||||
|
||||
step = await manager.login_flow.async_init(("insecure_example", None))
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
step = await manager.login_flow.async_configure(
|
||||
step["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
|
||||
# After auth_provider validated, request auth module input form
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert step["step_id"] == "mfa"
|
||||
|
||||
step = await manager.login_flow.async_configure(
|
||||
@@ -855,7 +855,7 @@ async def test_login_with_auth_module(mock_hass) -> None:
|
||||
)
|
||||
|
||||
# Invalid code error
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert step["step_id"] == "mfa"
|
||||
assert step["errors"] == {"base": "invalid_code"}
|
||||
|
||||
@@ -864,7 +864,7 @@ async def test_login_with_auth_module(mock_hass) -> None:
|
||||
)
|
||||
|
||||
# Finally passed, get credential
|
||||
assert step["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert step["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert step["result"]
|
||||
assert step["result"].id == "mock-id"
|
||||
|
||||
@@ -915,21 +915,21 @@ async def test_login_with_multi_auth_module(mock_hass) -> None:
|
||||
)
|
||||
|
||||
step = await manager.login_flow.async_init(("insecure_example", None))
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
step = await manager.login_flow.async_configure(
|
||||
step["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
|
||||
# After auth_provider validated, request select auth module
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert step["step_id"] == "select_mfa_module"
|
||||
|
||||
step = await manager.login_flow.async_configure(
|
||||
step["flow_id"], {"multi_factor_auth_module": "module2"}
|
||||
)
|
||||
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert step["step_id"] == "mfa"
|
||||
|
||||
step = await manager.login_flow.async_configure(
|
||||
@@ -937,7 +937,7 @@ async def test_login_with_multi_auth_module(mock_hass) -> None:
|
||||
)
|
||||
|
||||
# Finally passed, get credential
|
||||
assert step["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert step["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert step["result"]
|
||||
assert step["result"].id == "mock-id"
|
||||
|
||||
@@ -983,13 +983,13 @@ async def test_auth_module_expired_session(mock_hass) -> None:
|
||||
)
|
||||
|
||||
step = await manager.login_flow.async_init(("insecure_example", None))
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
step = await manager.login_flow.async_configure(
|
||||
step["flow_id"], {"username": "test-user", "password": "test-pass"}
|
||||
)
|
||||
|
||||
assert step["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert step["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert step["step_id"] == "mfa"
|
||||
|
||||
with freeze_time(dt_util.utcnow() + MFA_SESSION_EXPIRATION):
|
||||
@@ -997,7 +997,7 @@ async def test_auth_module_expired_session(mock_hass) -> None:
|
||||
step["flow_id"], {"pin": "test-pin"}
|
||||
)
|
||||
# login flow abort due session timeout
|
||||
assert step["type"] is data_entry_flow.FlowResultType.ABORT
|
||||
assert step["type"] == data_entry_flow.FlowResultType.ABORT
|
||||
assert step["reason"] == "login_expired"
|
||||
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ async def test_reauth_flow_scenario(
|
||||
data=mock_config_entry.data,
|
||||
)
|
||||
|
||||
assert flow["type"] is FlowResultType.FORM
|
||||
assert flow["type"] == FlowResultType.FORM
|
||||
assert flow["step_id"] == REAUTH_STEP
|
||||
|
||||
fw_major = int(ap_status_fixture.host.fwversion.lstrip("v").split(".", 1)[0])
|
||||
@@ -305,7 +305,7 @@ async def test_reauth_flow_scenarios(
|
||||
data=mock_config_entry.data,
|
||||
)
|
||||
|
||||
assert flow["type"] is FlowResultType.FORM
|
||||
assert flow["type"] == FlowResultType.FORM
|
||||
assert flow["step_id"] == REAUTH_STEP
|
||||
|
||||
with patch(
|
||||
@@ -337,7 +337,7 @@ async def test_reauth_flow_scenarios(
|
||||
user_input={CONF_PASSWORD: NEW_PASSWORD},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
|
||||
@@ -284,7 +284,7 @@ async def test_setup_entry_failure(
|
||||
|
||||
result = await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
assert result is False
|
||||
assert mock_config_entry.state is state
|
||||
assert mock_config_entry.state == state
|
||||
|
||||
|
||||
async def test_fetch_airos_data_auth_error(mock_airos_client: MagicMock) -> None:
|
||||
|
||||
@@ -138,4 +138,4 @@ async def test_migrate_future_version_returns_false(
|
||||
|
||||
await setup_integration(hass, config_entry)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
|
||||
assert config_entry.state == ConfigEntryState.MIGRATION_ERROR
|
||||
|
||||
@@ -164,7 +164,7 @@ async def test_error_handling(
|
||||
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == "unknown", result
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ async def test_template_error(
|
||||
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == "unknown", result
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ async def test_template_variables(
|
||||
hass, "hello", None, context, agent_id="conversation.claude_conversation"
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "Okay, let me take care of that for you."
|
||||
@@ -382,7 +382,7 @@ async def test_function_call(
|
||||
system_text = " ".join(block["text"] for block in system if "text" in block)
|
||||
assert "You are a voice assistant for Home Assistant." in system_text
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "I have successfully called the function"
|
||||
@@ -457,7 +457,7 @@ async def test_function_exception(
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ACTION_DONE
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "There was an error calling the function"
|
||||
@@ -638,7 +638,7 @@ async def test_refusal(
|
||||
agent_id="conversation.claude_conversation",
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == "unknown"
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
@@ -670,7 +670,7 @@ async def test_stream_wrong_type(
|
||||
agent_id="conversation.claude_conversation",
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == "unknown"
|
||||
assert result.response.speech["plain"]["speech"] == "Expected a stream of messages"
|
||||
|
||||
@@ -700,7 +700,7 @@ async def test_double_system_messages(
|
||||
agent_id="conversation.claude_conversation",
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == "unknown"
|
||||
assert (
|
||||
result.response.speech["plain"]["speech"]
|
||||
|
||||
@@ -42,7 +42,7 @@ async def test_auth_error_handling(
|
||||
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == "unknown", result
|
||||
|
||||
await hass.async_block_till_done()
|
||||
@@ -86,7 +86,7 @@ async def test_connection_error_handling(
|
||||
hass, "hello", None, Context(), agent_id="conversation.claude_conversation"
|
||||
)
|
||||
|
||||
assert result.response.response_type is intent.IntentResponseType.ERROR
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
assert result.response.error_code == "unknown", result
|
||||
|
||||
# Check new state
|
||||
|
||||
@@ -190,7 +190,7 @@ async def test_device_trigger_reauth_flow(
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_flow_init.assert_called_once()
|
||||
assert config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert config_entry.state == ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_shutdown(config_entry_data: MappingProxyType[str, Any]) -> None:
|
||||
@@ -235,4 +235,4 @@ async def test_get_axis_api_errors(
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state is state
|
||||
assert config_entry.state == state
|
||||
|
||||
@@ -26,7 +26,7 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) ->
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
@@ -34,7 +34,7 @@ async def test_config_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) ->
|
||||
BASE_CONFIG.copy(),
|
||||
)
|
||||
|
||||
assert result2["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert (
|
||||
result2["title"]
|
||||
== "cluster.region.kusto.windows.net / test-database-name (test-table-name)"
|
||||
@@ -61,7 +61,7 @@ async def test_config_flow_errors(
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
data=None,
|
||||
)
|
||||
assert result["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
# Test error handling with error
|
||||
@@ -71,7 +71,7 @@ async def test_config_flow_errors(
|
||||
result["flow_id"],
|
||||
BASE_CONFIG.copy(),
|
||||
)
|
||||
assert result2["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result2["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result2["errors"] == {"base": expected}
|
||||
|
||||
schema = result2["data_schema"]
|
||||
@@ -99,7 +99,7 @@ async def test_config_flow_errors(
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is data_entry_flow.FlowResultType.FORM
|
||||
assert result2["type"] == data_entry_flow.FlowResultType.FORM
|
||||
|
||||
# Retest error handling if error is corrected and connection is successful
|
||||
|
||||
@@ -112,4 +112,4 @@ async def test_config_flow_errors(
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result3["type"] is data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user