mirror of
https://github.com/home-assistant/core.git
synced 2026-05-24 17:55:24 +02:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf48806ee9 | |||
| 2c25c5ad26 | |||
| ff1177dde4 | |||
| 4306863729 | |||
| 1cc91cd3b6 | |||
| ecac38a359 | |||
| ba2f66e751 | |||
| 8301addc94 | |||
| 77bc932cf0 | |||
| 11903ac62e | |||
| 878761cb41 | |||
| d7bf7df59f | |||
| e5890172a0 | |||
| e9a58cdd20 | |||
| cab7c41a7f | |||
| 277a2d847a | |||
| 7835a4992a | |||
| 94581d8ab6 | |||
| 9dc37a2f46 | |||
| 7534c438c1 | |||
| 7d6ec7fc58 | |||
| 69efa8ee1a | |||
| f49de3548e | |||
| 305b5d6e00 | |||
| d94226260b | |||
| ecc8e52f3e | |||
| 5771b0c86c | |||
| 3e289da366 | |||
| 944fb1ef67 | |||
| 49ab42d3a2 | |||
| 383f6142f0 | |||
| 2f120cf604 | |||
| 1b6e9f5094 | |||
| 37288849b3 | |||
| b2257caeb7 | |||
| 0ec0ea30ac | |||
| 584b32c8b3 | |||
| aa8659f507 | |||
| 4033a8b83a | |||
| add8a5f799 | |||
| 40c0d79d1d | |||
| 7c137b5c73 | |||
| bef8632d78 | |||
| f00decfaa3 | |||
| 42e7add026 | |||
| 263aa3f16e | |||
| 03b364dcf0 | |||
| 3b1aaf39af | |||
| 4a6c5b5a22 | |||
| b82ba43fa4 | |||
| d81ef5593c | |||
| 5c5e50f024 | |||
| e796d9c467 | |||
| 342f23526f | |||
| 1009ce4180 | |||
| 22fb68b7a1 | |||
| 81e06539e6 | |||
| 7c18b67b2e | |||
| a8bc244a7a | |||
| 5975f4b179 | |||
| 9ed16b63a3 | |||
| 8dadaa2f9e | |||
| 4f98c71586 |
@@ -15,6 +15,7 @@ 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
|
||||
|
||||
@@ -917,12 +917,49 @@ 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 ${TEST_GROUP_COUNT} tests
|
||||
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 }}
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ EXPECTED_ENTRY_VERSION = (
|
||||
@callback
|
||||
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
|
||||
"""Return board info."""
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
entries = hass.config_entries.async_entries(
|
||||
DOMAIN, include_ignore=False, include_disabled=False
|
||||
)
|
||||
return [
|
||||
HardwareInfo(
|
||||
board=None,
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==10.0.1"],
|
||||
"requirements": ["python-homewizard-energy==10.1.0"],
|
||||
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
@@ -66,13 +65,6 @@ 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",
|
||||
@@ -643,7 +635,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="uptime",
|
||||
translation_key="uptime",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
has_fn=(
|
||||
@@ -651,7 +643,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
),
|
||||
value_fn=(
|
||||
lambda data: (
|
||||
uptime_to_stable_datetime(data.system.uptime_s)
|
||||
utcnow() - timedelta(seconds=data.system.uptime_s)
|
||||
if data.system is not None and data.system.uptime_s is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -61,13 +61,14 @@
|
||||
},
|
||||
"select": {
|
||||
"battery_group_mode": {
|
||||
"name": "Battery group mode",
|
||||
"name": "Battery group charging strategy",
|
||||
"state": {
|
||||
"predictive": "Smart charging",
|
||||
"standby": "Standby",
|
||||
"to_full": "Manual charge mode",
|
||||
"zero": "Zero mode",
|
||||
"zero_charge_only": "Zero mode (charge only)",
|
||||
"zero_discharge_only": "Zero mode (discharge only)"
|
||||
"to_full": "One-time full charge",
|
||||
"zero": "Net zero",
|
||||
"zero_charge_only": "Net zero (charge only)",
|
||||
"zero_discharge_only": "Net zero (discharge only)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -87,6 +87,8 @@ 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,6 +118,8 @@ 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:
|
||||
@@ -131,8 +133,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -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] = color_util.color_name_to_rgb(color_name)
|
||||
params[ATTR_RGB_COLOR] = tuple(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)
|
||||
|
||||
@@ -60,7 +60,7 @@ def get_matter_device_info(
|
||||
return None
|
||||
|
||||
return MatterDeviceInfo(
|
||||
unique_id=node.device_info.uniqueID,
|
||||
unique_id=node.device_info.uniqueID or "",
|
||||
vendor_id=hex(node.device_info.vendorID),
|
||||
product_id=hex(node.device_info.productID),
|
||||
)
|
||||
|
||||
@@ -6,13 +6,14 @@ 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
|
||||
from homeassistant.components.diagnostics import REDACTED, async_redact_data
|
||||
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]:
|
||||
@@ -44,6 +45,7 @@ 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
|
||||
|
||||
@@ -59,7 +61,9 @@ async def async_get_device_diagnostics(
|
||||
node = get_node_from_device_entry(hass, device)
|
||||
|
||||
return {
|
||||
"server_info": dataclass_to_dict(server_diagnostics.info),
|
||||
"server_info": async_redact_data(
|
||||
dataclass_to_dict(server_diagnostics.info), SERVER_INFO_TO_REDACT
|
||||
),
|
||||
"node": redact_matter_attributes(
|
||||
remove_serialization_type(dataclass_to_dict(node.node_data) if node else {})
|
||||
),
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["matter-python-client==0.6.0"],
|
||||
"requirements": ["matter-python-client==0.7.1"],
|
||||
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ 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,6 +120,8 @@ 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,
|
||||
@@ -227,6 +229,7 @@ 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,
|
||||
@@ -3721,6 +3724,11 @@ 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,6 +49,7 @@ 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
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TimestampedPublishMessage:
|
||||
"""MQTT Message."""
|
||||
|
||||
@@ -26,6 +26,8 @@ class TimestampedPublishMessage:
|
||||
qos: int
|
||||
retain: bool
|
||||
timestamp: float
|
||||
encoding: str | None
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
def log_message(
|
||||
@@ -35,6 +37,8 @@ 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(
|
||||
@@ -45,7 +49,13 @@ def log_message(
|
||||
"messages": deque(maxlen=STORED_MESSAGES),
|
||||
}
|
||||
msg = TimestampedPublishMessage(
|
||||
topic, payload, qos, retain, timestamp=time.monotonic()
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
timestamp=time.monotonic(),
|
||||
encoding=encoding,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
entity_info["transmitted"][topic]["messages"].append(msg)
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ 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,
|
||||
@@ -94,7 +95,6 @@ from .const import (
|
||||
CONF_SW_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_VIA_DEVICE,
|
||||
DEFAULT_ENCODING,
|
||||
DOMAIN,
|
||||
MQTT_CONNECTION_STATE,
|
||||
)
|
||||
@@ -153,6 +153,8 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"unit_of_measurement",
|
||||
}
|
||||
|
||||
PUBLISH_KWARGS = (CONF_MESSAGE_EXPIRY_INTERVAL,)
|
||||
|
||||
|
||||
@callback
|
||||
def async_handle_schema_error(
|
||||
@@ -1539,36 +1541,20 @@ 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."""
|
||||
await self.async_publish(
|
||||
topic,
|
||||
payload,
|
||||
self._config[CONF_QOS],
|
||||
self._config[CONF_RETAIN],
|
||||
self._config[CONF_ENCODING],
|
||||
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 async_publish(self.hass, topic, payload, qos, retain, encoding, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
|
||||
@@ -509,10 +509,20 @@ 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,6 +40,7 @@ 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,
|
||||
@@ -66,6 +67,7 @@ SHARED_OPTIONS = [
|
||||
CONF_AVAILABILITY_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_STATE_TOPIC,
|
||||
@@ -161,6 +163,14 @@ 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,
|
||||
@@ -172,6 +182,7 @@ 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,
|
||||
}
|
||||
)
|
||||
@@ -203,6 +214,7 @@ 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,9 +197,11 @@
|
||||
},
|
||||
"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,6 +6,7 @@ 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
|
||||
|
||||
@@ -14,7 +15,6 @@ from .const import (
|
||||
ATTR_DESCRIPTION,
|
||||
ATTR_EXPIRES,
|
||||
ATTR_HEADLINE,
|
||||
ATTR_ID,
|
||||
ATTR_RECOMMENDED_ACTIONS,
|
||||
ATTR_SENDER,
|
||||
ATTR_SENT,
|
||||
|
||||
@@ -29,8 +29,6 @@ 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"
|
||||
|
||||
@@ -37,11 +37,15 @@ class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring")
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
|
||||
_LOGGER.debug(
|
||||
"async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN]
|
||||
)
|
||||
udn = discovery_info.upnp[ATTR_UPNP_UDN]
|
||||
if isinstance(udn, list):
|
||||
if not udn:
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
udn = udn[0]
|
||||
|
||||
await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
|
||||
_LOGGER.debug("async_step_ssdp: setting unique id %s", udn)
|
||||
|
||||
await self.async_set_unique_id(udn)
|
||||
self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location})
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.19.1"]
|
||||
"requirements": ["reolink-aio==0.20.0"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""The System Bridge integration."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from systembridgeconnector.exceptions import (
|
||||
AuthenticationException,
|
||||
@@ -11,71 +9,34 @@ 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, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
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,
|
||||
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.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.MEDIA_PLAYER,
|
||||
@@ -84,26 +45,12 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
CONF_BRIDGE = "bridge"
|
||||
CONF_KEY = "key"
|
||||
CONF_TEXT = "text"
|
||||
|
||||
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 def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the System Bridge services."""
|
||||
|
||||
POWER_COMMAND_MAP = {
|
||||
"hibernate": "power_hibernate",
|
||||
"lock": "power_lock",
|
||||
"logout": "power_logout",
|
||||
"restart": "power_restart",
|
||||
"shutdown": "power_shutdown",
|
||||
"sleep": "power_sleep",
|
||||
}
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -231,219 +178,6 @@ 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))
|
||||
|
||||
@@ -454,9 +188,7 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: SystemBridgeConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
|
||||
)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""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,3 +89,4 @@ power_command:
|
||||
- "restart"
|
||||
- "shutdown"
|
||||
- "sleep"
|
||||
translation_key: "power_command"
|
||||
|
||||
@@ -178,6 +178,18 @@
|
||||
"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.",
|
||||
|
||||
@@ -21,4 +21,4 @@ CONF_INSTANCE_ID = "instance_id"
|
||||
# Polling interval (seconds)
|
||||
DEFAULT_SCAN_INTERVAL = 1800
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.LOCK, Platform.SWITCH]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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,113 @@
|
||||
"""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))
|
||||
@@ -0,0 +1 @@
|
||||
"""Generated files for the pylint Home Assistant plugin."""
|
||||
+7458
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
"""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
+3
-3
@@ -1516,7 +1516,7 @@ lxml==6.0.1
|
||||
matrix-nio==0.25.2
|
||||
|
||||
# homeassistant.components.matter
|
||||
matter-python-client==0.6.0
|
||||
matter-python-client==0.7.1
|
||||
|
||||
# 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.0.1
|
||||
python-homewizard-energy==10.1.0
|
||||
|
||||
# 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.19.1
|
||||
reolink-aio==0.20.0
|
||||
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==3.2.0
|
||||
|
||||
@@ -23,6 +23,7 @@ from . import (
|
||||
json,
|
||||
labs,
|
||||
manifest,
|
||||
mdi_icons,
|
||||
metadata,
|
||||
mqtt,
|
||||
mypy_config,
|
||||
@@ -65,6 +66,7 @@ INTEGRATION_PLUGINS = [
|
||||
HASS_PLUGINS = [
|
||||
core_files,
|
||||
docker,
|
||||
mdi_icons,
|
||||
mypy_config,
|
||||
metadata,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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"])
|
||||
+402
-62
@@ -2,9 +2,14 @@
|
||||
"""Helper script to split test into n buckets."""
|
||||
|
||||
import argparse
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field, replace
|
||||
import hashlib
|
||||
import json
|
||||
from math import ceil
|
||||
from operator import attrgetter, itemgetter
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
@@ -15,13 +20,21 @@ 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,
|
||||
):
|
||||
def __init__(self) -> None:
|
||||
"""Initialize bucket."""
|
||||
self.total_tests = 0
|
||||
self._paths: list[str] = []
|
||||
@@ -47,43 +60,56 @@ class BucketHolder:
|
||||
self._buckets: list[Bucket] = [Bucket() for _ in range(bucket_count)]
|
||||
|
||||
def split_tests(self, test_folder: TestFolder) -> None:
|
||||
"""Split tests into buckets."""
|
||||
"""Place atomic units via best-fit; oversized ones go to the smallest bucket."""
|
||||
digits = len(str(test_folder.total_tests))
|
||||
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
|
||||
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)
|
||||
|
||||
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 create_ouput_file(self) -> None:
|
||||
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:
|
||||
"""Create output file."""
|
||||
with Path("pytest_buckets.txt").open("w") as file:
|
||||
with Path("pytest_buckets.txt").open("w", encoding="utf-8") 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())
|
||||
@@ -170,6 +196,15 @@ 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(
|
||||
@@ -216,44 +251,343 @@ def _enumerate_batch_paths(path: Path) -> list[Path]:
|
||||
return paths
|
||||
|
||||
|
||||
def collect_tests(path: Path) -> TestFolder:
|
||||
"""Collect all tests."""
|
||||
batch_paths = _enumerate_batch_paths(path)
|
||||
if not batch_paths:
|
||||
print(f"No eligible test paths found under {path}")
|
||||
sys.exit(1)
|
||||
workers = min(len(batch_paths), os.cpu_count() or 1) or 1
|
||||
# Round-robin chunking keeps batches roughly balanced when path
|
||||
# ordering correlates with test size.
|
||||
batches = [batch_paths[i::workers] for i in range(workers)]
|
||||
def _hash_file(path: Path) -> str:
|
||||
"""Return a short content hash for ``path``."""
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
|
||||
|
||||
|
||||
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:
|
||||
results = [_collect_batch(batches[0])]
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
results = list(executor.map(_collect_batch, batches))
|
||||
return [_collect_batch(batches[0])]
|
||||
with ProcessPoolExecutor(max_workers=workers) as executor:
|
||||
return list(executor.map(_collect_batch, batches))
|
||||
|
||||
folder = TestFolder(path)
|
||||
for stdout, stderr, returncode in results:
|
||||
|
||||
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):
|
||||
if returncode != 0:
|
||||
print("Failed to collect tests:")
|
||||
print(stderr)
|
||||
print(stdout)
|
||||
sys.exit(1)
|
||||
for line in stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
file_path, _, total_tests = line.partition(": ")
|
||||
if not file_path or not total_tests:
|
||||
print(f"Unexpected line: {line}")
|
||||
sys.exit(1)
|
||||
# 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
|
||||
|
||||
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.")
|
||||
@@ -276,11 +610,17 @@ 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)
|
||||
tests = collect_tests(arguments.path, arguments.cache)
|
||||
tests_per_bucket = ceil(tests.total_tests / arguments.bucket_count)
|
||||
|
||||
bucket_holder = BucketHolder(tests_per_bucket, arguments.bucket_count)
|
||||
@@ -290,7 +630,7 @@ def main() -> None:
|
||||
print(f"Total tests: {tests.total_tests}")
|
||||
print(f"Estimated tests per bucket: {tests_per_bucket}")
|
||||
|
||||
bucket_holder.create_ouput_file()
|
||||
bucket_holder.create_output_file()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from homeassistant.components.homeassistant_connect_zbt2.const import DOMAIN
|
||||
from homeassistant.components.usb import DOMAIN as USB_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -65,3 +66,66 @@ async def test_hardware_info(
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def test_hardware_info_ignored_entry(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, addon_store_info
|
||||
) -> None:
|
||||
"""Test ignored discovery entries don't crash hardware info.
|
||||
|
||||
Regression test for https://github.com/home-assistant/core/issues/170270
|
||||
"""
|
||||
assert await async_setup_component(hass, USB_DOMAIN, {})
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||
|
||||
# Setup the normal entry so the hardware platform is loaded
|
||||
normal_entry = MockConfigEntry(
|
||||
data=CONFIG_ENTRY_DATA,
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Home Assistant Connect ZBT-2",
|
||||
unique_id="normal_1",
|
||||
version=1,
|
||||
minor_version=1,
|
||||
)
|
||||
normal_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(normal_entry.entry_id)
|
||||
|
||||
# Setup an ignored config entry without USB data
|
||||
ignored_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Home Assistant Connect ZBT-2",
|
||||
unique_id="ignored_1",
|
||||
version=1,
|
||||
minor_version=2,
|
||||
source="ignore",
|
||||
)
|
||||
ignored_entry.add_to_hass(hass)
|
||||
assert ignored_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json({"id": 1, "type": "hardware/info"})
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert msg["id"] == 1
|
||||
assert msg["success"]
|
||||
assert msg["result"] == {
|
||||
"hardware": [
|
||||
{
|
||||
"board": None,
|
||||
"config_entries": [normal_entry.entry_id],
|
||||
"dongle": {
|
||||
"vid": "303A",
|
||||
"pid": "4001",
|
||||
"serial_number": "80B54EEFAE18",
|
||||
"manufacturer": "Nabu Casa",
|
||||
"description": "ZBT-2",
|
||||
},
|
||||
"name": "Home Assistant Connect ZBT-2",
|
||||
"url": "https://support.nabucasa.com/hc/en-us/categories/24734620813469-Home-Assistant-Connect-ZBT-1",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2363,6 +2363,11 @@ async def test_reload(mock_port_available: MagicMock, hass: HomeAssistant) -> No
|
||||
devices=[],
|
||||
)
|
||||
|
||||
# Unload while async_port_is_available is still patched so the hass fixture
|
||||
# teardown does not block on the real port check loop in async_unload_entry.
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_async_zeroconf")
|
||||
async def test_homekit_start_in_accessory_mode(
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mode": "zero",
|
||||
"permissions": ["charge_allowed", "discharge_allowed"],
|
||||
"battery_count": 2,
|
||||
"power_w": -404,
|
||||
"target_power_w": -400,
|
||||
"max_consumption_w": 1600,
|
||||
"max_production_w": 800
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"wifi_ssid": "My Wi-Fi",
|
||||
"wifi_strength": 100,
|
||||
"smr_version": 50,
|
||||
"meter_model": "ISKRA 2M550T-101",
|
||||
"unique_id": "00112233445566778899AABBCCDDEEFF",
|
||||
"active_tariff": 2,
|
||||
"total_power_import_kwh": 13779.338,
|
||||
"total_power_import_t1_kwh": 10830.511,
|
||||
"total_power_import_t2_kwh": 2948.827,
|
||||
"total_power_import_t3_kwh": 2948.827,
|
||||
"total_power_import_t4_kwh": 2948.827,
|
||||
"total_power_export_kwh": 13086.777,
|
||||
"total_power_export_t1_kwh": 4321.333,
|
||||
"total_power_export_t2_kwh": 8765.444,
|
||||
"total_power_export_t3_kwh": 8765.444,
|
||||
"total_power_export_t4_kwh": 8765.444,
|
||||
"active_power_w": -123,
|
||||
"active_power_l1_w": -123,
|
||||
"active_power_l2_w": 456,
|
||||
"active_power_l3_w": 123.456,
|
||||
"active_voltage_l1_v": 230.111,
|
||||
"active_voltage_l2_v": 230.222,
|
||||
"active_voltage_l3_v": 230.333,
|
||||
"active_current_l1_a": -4,
|
||||
"active_current_l2_a": 2,
|
||||
"active_current_l3_a": 0,
|
||||
"active_frequency_hz": 50,
|
||||
"voltage_sag_l1_count": 1,
|
||||
"voltage_sag_l2_count": 2,
|
||||
"voltage_sag_l3_count": 3,
|
||||
"voltage_swell_l1_count": 4,
|
||||
"voltage_swell_l2_count": 5,
|
||||
"voltage_swell_l3_count": 6,
|
||||
"any_power_fail_count": 4,
|
||||
"long_power_fail_count": 5,
|
||||
"total_gas_m3": 1122.333,
|
||||
"gas_timestamp": 210314112233,
|
||||
"gas_unique_id": "01FFEEDDCCBBAA99887766554433221100",
|
||||
"active_power_average_w": 123.0,
|
||||
"montly_power_peak_w": 1111.0,
|
||||
"montly_power_peak_timestamp": 230101080010,
|
||||
"active_liter_lpm": 12.345,
|
||||
"total_liter_m3": 1234.567,
|
||||
"external": [
|
||||
{
|
||||
"unique_id": "47303031",
|
||||
"type": "gas_meter",
|
||||
"timestamp": 230125220957,
|
||||
"value": 111.111,
|
||||
"unit": "m3"
|
||||
},
|
||||
{
|
||||
"unique_id": "57303031",
|
||||
"type": "water_meter",
|
||||
"timestamp": 230125220957,
|
||||
"value": 222.222,
|
||||
"unit": "m3"
|
||||
},
|
||||
{
|
||||
"unique_id": "5757303031",
|
||||
"type": "warm_water_meter",
|
||||
"timestamp": 230125220957,
|
||||
"value": 333.333,
|
||||
"unit": "m3"
|
||||
},
|
||||
{
|
||||
"unique_id": "48303031",
|
||||
"type": "heat_meter",
|
||||
"timestamp": 230125220957,
|
||||
"value": 444.444,
|
||||
"unit": "GJ"
|
||||
},
|
||||
{
|
||||
"unique_id": "4948303031",
|
||||
"type": "inlet_heat_meter",
|
||||
"timestamp": 230125220957,
|
||||
"value": 555.555,
|
||||
"unit": "m3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"product_type": "HWE-P1",
|
||||
"product_name": "P1 meter",
|
||||
"serial": "5c2fafabcdef",
|
||||
"firmware_version": "4.19",
|
||||
"api_version": "2.3.0"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"cloud_enabled": true
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# serializer version: 1
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode]
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Device Battery group mode',
|
||||
'friendly_name': 'Device Battery group charging strategy',
|
||||
'options': list([
|
||||
'standby',
|
||||
'to_full',
|
||||
@@ -10,14 +10,14 @@
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.device_battery_group_mode',
|
||||
'entity_id': 'select.device_battery_group_charging_strategy',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'zero',
|
||||
})
|
||||
# ---
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].1
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy].1
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
@@ -37,7 +37,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.device_battery_group_mode',
|
||||
'entity_id': 'select.device_battery_group_charging_strategy',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -45,12 +45,12 @@
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Battery group mode',
|
||||
'object_id_base': 'Battery group charging strategy',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Battery group mode',
|
||||
'original_name': 'Battery group charging strategy',
|
||||
'platform': 'homewizard',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -60,7 +60,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].2
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy].2
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
|
||||
@@ -798,7 +798,7 @@
|
||||
'object_id_base': 'Uptime',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Uptime',
|
||||
'platform': 'homewizard',
|
||||
@@ -813,7 +813,7 @@
|
||||
# name: test_sensors[HWE-BAT-entity_ids10][sensor.device_uptime:state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'timestamp',
|
||||
'device_class': 'uptime',
|
||||
'friendly_name': 'Device Uptime',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -33,31 +33,31 @@ pytestmark = [
|
||||
(
|
||||
"HWE-WTR",
|
||||
[
|
||||
"select.device_battery_group_mode",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
],
|
||||
),
|
||||
(
|
||||
"SDM230",
|
||||
[
|
||||
"select.device_battery_group_mode",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
],
|
||||
),
|
||||
(
|
||||
"SDM630",
|
||||
[
|
||||
"select.device_battery_group_mode",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
],
|
||||
),
|
||||
(
|
||||
"HWE-KWH1",
|
||||
[
|
||||
"select.device_battery_group_mode",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
],
|
||||
),
|
||||
(
|
||||
"HWE-KWH3",
|
||||
[
|
||||
"select.device_battery_group_mode",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -74,7 +74,7 @@ async def test_entities_not_created_for_device(
|
||||
@pytest.mark.parametrize(
|
||||
("device_fixture", "entity_id"),
|
||||
[
|
||||
("HWE-P1", "select.device_battery_group_mode"),
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy"),
|
||||
],
|
||||
)
|
||||
async def test_select_entity_snapshots(
|
||||
@@ -101,17 +101,28 @@ async def test_select_entity_snapshots(
|
||||
[
|
||||
(
|
||||
"HWE-P1",
|
||||
"select.device_battery_group_mode",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"standby",
|
||||
Batteries.Mode.STANDBY,
|
||||
),
|
||||
(
|
||||
"HWE-P1",
|
||||
"select.device_battery_group_mode",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"to_full",
|
||||
Batteries.Mode.TO_FULL,
|
||||
),
|
||||
("HWE-P1", "select.device_battery_group_mode", "zero", Batteries.Mode.ZERO),
|
||||
(
|
||||
"HWE-P1",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"zero",
|
||||
Batteries.Mode.ZERO,
|
||||
),
|
||||
(
|
||||
"HWE-P1-predictive",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"predictive",
|
||||
Batteries.Mode.PREDICTIVE,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_select_set_option(
|
||||
@@ -134,12 +145,19 @@ async def test_select_set_option(
|
||||
mock_homewizardenergy.batteries.assert_called_with(mode=expected_mode)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["HWE-P1-predictive"])
|
||||
async def test_select_predictive_mode_is_available(hass: HomeAssistant) -> None:
|
||||
"""Test that predictive mode is available when supported by the device."""
|
||||
assert (state := hass.states.get("select.device_battery_group_charging_strategy"))
|
||||
assert "predictive" in state.attributes["options"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_fixture", "entity_id", "option"),
|
||||
[
|
||||
("HWE-P1", "select.device_battery_group_mode", "zero"),
|
||||
("HWE-P1", "select.device_battery_group_mode", "standby"),
|
||||
("HWE-P1", "select.device_battery_group_mode", "to_full"),
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy", "zero"),
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy", "standby"),
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy", "to_full"),
|
||||
],
|
||||
)
|
||||
async def test_select_request_error(
|
||||
@@ -168,7 +186,7 @@ async def test_select_request_error(
|
||||
@pytest.mark.parametrize(
|
||||
("device_fixture", "entity_id", "option"),
|
||||
[
|
||||
("HWE-P1", "select.device_battery_group_mode", "to_full"),
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy", "to_full"),
|
||||
],
|
||||
)
|
||||
async def test_select_unauthorized_error(
|
||||
@@ -203,7 +221,7 @@ async def test_select_unauthorized_error(
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "method"),
|
||||
[
|
||||
("select.device_battery_group_mode", "combined"),
|
||||
("select.device_battery_group_charging_strategy", "combined"),
|
||||
],
|
||||
)
|
||||
async def test_select_unreachable(
|
||||
@@ -226,7 +244,7 @@ async def test_select_unreachable(
|
||||
@pytest.mark.parametrize(
|
||||
("device_fixture", "entity_id"),
|
||||
[
|
||||
("HWE-P1", "select.device_battery_group_mode"),
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy"),
|
||||
],
|
||||
)
|
||||
async def test_select_multiple_state_changes(
|
||||
@@ -275,7 +293,7 @@ async def test_select_multiple_state_changes(
|
||||
(
|
||||
"HWE-P1-no-batteries",
|
||||
[
|
||||
"select.device_battery_group_mode",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -116,3 +116,30 @@ async def test_get_triggers(
|
||||
]
|
||||
|
||||
assert triggers == unordered(expected_triggers)
|
||||
|
||||
|
||||
async def test_get_triggers_for_removed_device(
|
||||
hass: HomeAssistant,
|
||||
mock_bridge_v2: Mock,
|
||||
v2_resources_test_data: JsonArrayType,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test triggers for a device removed from the bridge.
|
||||
|
||||
Regression test for https://github.com/home-assistant/core/issues/152937
|
||||
"""
|
||||
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
|
||||
await setup_platform(
|
||||
hass, mock_bridge_v2, [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
)
|
||||
|
||||
# Create a device entry with a Hue ID that doesn't exist on the bridge
|
||||
orphaned_device = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_bridge_v2.config_entry.entry_id,
|
||||
identifiers={(hue.DOMAIN, "non-existent-hue-device-id")},
|
||||
)
|
||||
|
||||
triggers = await async_get_device_automations(
|
||||
hass, DeviceAutomationType.TRIGGER, orphaned_device.id
|
||||
)
|
||||
assert triggers == []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The tests for the Light component."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
@@ -1708,42 +1709,50 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 3451}
|
||||
|
||||
|
||||
async def test_light_service_call_color_conversion_named_tuple(
|
||||
@pytest.mark.parametrize(
|
||||
"color_input",
|
||||
[
|
||||
pytest.param(
|
||||
{"color_name": "maroon"},
|
||||
id="color_name",
|
||||
),
|
||||
pytest.param(
|
||||
{"rgb_color": color_util.RGBColor(128, 0, 0)},
|
||||
id="rgb_color_named_tuple",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_light_turn_on_rgb_color_is_plain_tuple(
|
||||
hass: HomeAssistant,
|
||||
color_input: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test a named tuple (RGBColor) is handled correctly."""
|
||||
"""Test that rgb_color passed to entity turn_on is always a plain tuple.
|
||||
|
||||
Covers two input paths that both resolve to the same RGB value (128, 0, 0):
|
||||
- color_name: goes through color_name_to_rgb (returns RGBColor NamedTuple),
|
||||
bypassing the service schema vol.Coerce(tuple) coercion.
|
||||
- rgb_color: RGBColor NamedTuple passed directly, converted by the schema.
|
||||
"""
|
||||
entities = [
|
||||
MockLight("Test_hs", STATE_ON),
|
||||
MockLight("Test_rgb", STATE_ON),
|
||||
MockLight("Test_xy", STATE_ON),
|
||||
MockLight("Test_all", STATE_ON),
|
||||
MockLight("Test_rgbw", STATE_ON),
|
||||
MockLight("Test_rgbww", STATE_ON),
|
||||
MockLight("Test_hs", STATE_ON, supported_color_modes={light.ColorMode.HS}),
|
||||
MockLight("Test_rgb", STATE_ON, supported_color_modes={light.ColorMode.RGB}),
|
||||
MockLight("Test_xy", STATE_ON, supported_color_modes={light.ColorMode.XY}),
|
||||
MockLight(
|
||||
"Test_all",
|
||||
STATE_ON,
|
||||
supported_color_modes={
|
||||
light.ColorMode.HS,
|
||||
light.ColorMode.RGB,
|
||||
light.ColorMode.XY,
|
||||
},
|
||||
),
|
||||
MockLight("Test_rgbw", STATE_ON, supported_color_modes={light.ColorMode.RGBW}),
|
||||
MockLight(
|
||||
"Test_rgbww", STATE_ON, supported_color_modes={light.ColorMode.RGBWW}
|
||||
),
|
||||
]
|
||||
setup_test_component_platform(hass, light.DOMAIN, entities)
|
||||
|
||||
entity0 = entities[0]
|
||||
entity0.supported_color_modes = {light.ColorMode.HS}
|
||||
|
||||
entity1 = entities[1]
|
||||
entity1.supported_color_modes = {light.ColorMode.RGB}
|
||||
|
||||
entity2 = entities[2]
|
||||
entity2.supported_color_modes = {light.ColorMode.XY}
|
||||
|
||||
entity3 = entities[3]
|
||||
entity3.supported_color_modes = {
|
||||
light.ColorMode.HS,
|
||||
light.ColorMode.RGB,
|
||||
light.ColorMode.XY,
|
||||
}
|
||||
|
||||
entity4 = entities[4]
|
||||
entity4.supported_color_modes = {light.ColorMode.RGBW}
|
||||
|
||||
entity5 = entities[5]
|
||||
entity5.supported_color_modes = {light.ColorMode.RGBWW}
|
||||
|
||||
assert await async_setup_component(hass, "light", {"light": {"platform": "test"}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -1751,30 +1760,25 @@ async def test_light_service_call_color_conversion_named_tuple(
|
||||
"light",
|
||||
"turn_on",
|
||||
{
|
||||
"entity_id": [
|
||||
entity0.entity_id,
|
||||
entity1.entity_id,
|
||||
entity2.entity_id,
|
||||
entity3.entity_id,
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
],
|
||||
"entity_id": [entity.entity_id for entity in entities],
|
||||
"brightness_pct": 25,
|
||||
"rgb_color": color_util.RGBColor(128, 0, 0),
|
||||
**color_input,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
_, data = entity0.last_call("turn_on")
|
||||
_, data = entities[0].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "hs_color": (0.0, 100.0)}
|
||||
_, data = entity1.last_call("turn_on")
|
||||
_, data = entities[1].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
|
||||
_, data = entity2.last_call("turn_on")
|
||||
assert type(data["rgb_color"]) is tuple
|
||||
_, data = entities[2].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "xy_color": (0.701, 0.299)}
|
||||
_, data = entity3.last_call("turn_on")
|
||||
_, data = entities[3].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert type(data["rgb_color"]) is tuple
|
||||
_, data = entities[4].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)}
|
||||
_, data = entity5.last_call("turn_on")
|
||||
_, data = entities[5].last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
"wifi_credentials_set": true,
|
||||
"thread_credentials_set": false,
|
||||
"min_supported_schema_version": 1,
|
||||
"bluetooth_enabled": false
|
||||
"bluetooth_enabled": false,
|
||||
"wifi_ssid": "test_ssid",
|
||||
"ble_proxy_enabled": false
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
"wifi_credentials_set": true,
|
||||
"thread_credentials_set": false,
|
||||
"min_supported_schema_version": 1,
|
||||
"bluetooth_enabled": false
|
||||
"bluetooth_enabled": false,
|
||||
"wifi_ssid": "**REDACTED**",
|
||||
"ble_proxy_enabled": false
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
|
||||
@@ -9,8 +9,12 @@ from matter_server.common.helpers.util import dataclass_from_dict
|
||||
from matter_server.common.models import ServerDiagnostics
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.components.matter.const import DOMAIN
|
||||
from homeassistant.components.matter.diagnostics import redact_matter_attributes
|
||||
from homeassistant.components.matter.diagnostics import (
|
||||
SERVER_INFO_TO_REDACT,
|
||||
redact_matter_attributes,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
@@ -85,7 +89,7 @@ async def test_device_diagnostics(
|
||||
"""Test the device diagnostics."""
|
||||
system_info_dict = config_entry_diagnostics["info"]
|
||||
device_diagnostics_redacted = {
|
||||
"server_info": system_info_dict,
|
||||
"server_info": async_redact_data(system_info_dict, SERVER_INFO_TO_REDACT),
|
||||
"node": redact_matter_attributes(device_diagnostics),
|
||||
}
|
||||
server_diagnostics_response = {
|
||||
|
||||
@@ -749,7 +749,18 @@ MOCK_SUBENTRY_DEVICE_DATA = {
|
||||
}
|
||||
|
||||
MOCK_NOTIFY_SUBENTRY_DATA_MULTI = {
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}},
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA
|
||||
| {
|
||||
"mqtt_settings": {
|
||||
"qos": 2.0,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 1,
|
||||
"seconds": 30,
|
||||
},
|
||||
}
|
||||
},
|
||||
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2,
|
||||
} | MOCK_SUBENTRY_AVAILABILITY_DATA
|
||||
|
||||
@@ -882,7 +893,18 @@ MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = {
|
||||
"components": MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA,
|
||||
}
|
||||
MOCK_SUBENTRY_DATA_SET_MIX = {
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}},
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA
|
||||
| {
|
||||
"mqtt_settings": {
|
||||
"qos": 0,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 1,
|
||||
"seconds": 30,
|
||||
},
|
||||
}
|
||||
},
|
||||
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1
|
||||
| MOCK_SUBENTRY_NOTIFY_COMPONENT2
|
||||
| MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT
|
||||
|
||||
@@ -88,6 +88,66 @@ async def test_sending_mqtt_commands(
|
||||
assert state.state == "2021-11-08T13:31:44+00:00"
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2021-11-08 13:31:44+00:00")
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
button.DOMAIN: {
|
||||
"command_topic": "command-topic",
|
||||
"name": "test",
|
||||
"default_entity_id": "button.test_button",
|
||||
"payload_press": "beer press",
|
||||
"qos": "2",
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 1,
|
||||
"seconds": 30,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
mqtt.DOMAIN: {
|
||||
button.DOMAIN: {
|
||||
"command_topic": "command-topic",
|
||||
"name": "test",
|
||||
"default_entity_id": "button.test_button",
|
||||
"payload_press": "beer press",
|
||||
"qos": "2",
|
||||
"message_expiry_interval": 90,
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_sending_mqtt_commands_with_message_expiry_interval(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test the sending MQTT command with message expiry interval."""
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
|
||||
state = hass.states.get("button.test_button")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "test"
|
||||
|
||||
await hass.services.async_call(
|
||||
button.DOMAIN,
|
||||
button.SERVICE_PRESS,
|
||||
{ATTR_ENTITY_ID: "button.test_button"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mqtt_mock.async_publish.assert_called_once_with(
|
||||
"command-topic", "beer press", 2, False, message_expiry_interval=90
|
||||
)
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
state = hass.states.get("button.test_button")
|
||||
assert state.state == "2021-11-08T13:31:44+00:00"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
|
||||
@@ -1502,12 +1502,13 @@ async def test_publish_error(
|
||||
|
||||
async def test_subscribe_error(
|
||||
hass: HomeAssistant,
|
||||
setup_with_birth_msg_client_mock: MqttMockPahoClient,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
mqtt_client_mock: MqttMockPahoClient,
|
||||
record_calls: MessageCallbackType,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test publish error."""
|
||||
mqtt_client_mock = setup_with_birth_msg_client_mock
|
||||
await mqtt_mock_entry()
|
||||
mqtt_client_mock.reset_mock()
|
||||
# simulate client is not connected error before subscribing
|
||||
mqtt_client_mock.subscribe.side_effect = lambda *args, **kwargs: (4, None)
|
||||
|
||||
@@ -5196,7 +5196,14 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
.schema["mqtt_settings"]
|
||||
.schema.schema.items()
|
||||
}
|
||||
assert mqtt_settings_key_descriptions == {"qos": {"suggested_value": 2}}
|
||||
assert mqtt_settings_key_descriptions == {
|
||||
"qos": {
|
||||
"suggested_value": 2,
|
||||
},
|
||||
"message_expiry_interval": {
|
||||
"suggested_value": {"days": 0, "hours": 0, "minutes": 1, "seconds": 30}
|
||||
},
|
||||
}
|
||||
assert result["data_schema"].schema["mqtt_settings"].options == {"collapsed": False}
|
||||
|
||||
# Update the device details
|
||||
@@ -5209,7 +5216,15 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
"model_id": "bn003",
|
||||
"manufacturer": "Beer Masters",
|
||||
"configuration_url": "https://example.com",
|
||||
"mqtt_settings": {"qos": 1},
|
||||
"mqtt_settings": {
|
||||
"qos": 1,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 0,
|
||||
"seconds": 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
@@ -5232,6 +5247,12 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
assert device["sw_version"] == "1.1"
|
||||
assert device["manufacturer"] == "Beer Masters"
|
||||
assert device["mqtt_settings"]["qos"] == 1
|
||||
assert device["mqtt_settings"]["message_expiry_interval"] == {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 0,
|
||||
"seconds": 30,
|
||||
}
|
||||
assert "qos" not in device
|
||||
|
||||
|
||||
|
||||
@@ -116,3 +116,54 @@ async def test_host_updated(hass: HomeAssistant) -> None:
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
assert entry.data[CONF_HOST] == MOCK_SSDP_LOCATION
|
||||
|
||||
|
||||
async def test_ssdp_udn_as_list(hass: HomeAssistant) -> None:
|
||||
"""Test SSDP discovery when UDN is a list instead of a string.
|
||||
|
||||
Regression test for https://github.com/home-assistant/core/issues/171837
|
||||
"""
|
||||
list_udn_discovery = SsdpServiceInfo(
|
||||
ssdp_usn="usn",
|
||||
ssdp_st="st",
|
||||
ssdp_location=MOCK_SSDP_LOCATION,
|
||||
upnp={
|
||||
ATTR_UPNP_FRIENDLY_NAME: MOCK_FRIENDLY_NAME,
|
||||
ATTR_UPNP_UDN: [MOCK_UDN, "uuid:other"],
|
||||
},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={CONF_SOURCE: SOURCE_SSDP},
|
||||
data=list_udn_discovery,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "confirm"
|
||||
assert result["description_placeholders"] == {CONF_NAME: MOCK_FRIENDLY_NAME}
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == MOCK_FRIENDLY_NAME
|
||||
assert result2["data"] == {CONF_HOST: MOCK_SSDP_LOCATION}
|
||||
|
||||
|
||||
async def test_ssdp_udn_as_empty_list(hass: HomeAssistant) -> None:
|
||||
"""Test SSDP discovery when UDN is an empty list."""
|
||||
empty_udn_discovery = SsdpServiceInfo(
|
||||
ssdp_usn="usn",
|
||||
ssdp_st="st",
|
||||
ssdp_location=MOCK_SSDP_LOCATION,
|
||||
upnp={
|
||||
ATTR_UPNP_FRIENDLY_NAME: MOCK_FRIENDLY_NAME,
|
||||
ATTR_UPNP_UDN: [],
|
||||
},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={CONF_SOURCE: SOURCE_SSDP},
|
||||
data=empty_udn_discovery,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "incomplete_discovery"
|
||||
|
||||
@@ -126,6 +126,36 @@ def mock_websocket_client(
|
||||
message="Data listener registered",
|
||||
data={EventKey.MODULES: register_data_listener_model.modules},
|
||||
)
|
||||
websocket_client.open_url.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.OPENED,
|
||||
message="Opened url",
|
||||
data={"url": "https://example.com"},
|
||||
)
|
||||
websocket_client.open_path.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.OPENED,
|
||||
message="Opened file",
|
||||
data={"path": "/home/user/documents"},
|
||||
)
|
||||
websocket_client.power_shutdown.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.POWER_SHUTDOWN,
|
||||
message="Shutdown",
|
||||
data={},
|
||||
)
|
||||
websocket_client.keyboard_keypress.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.KEYBOARD_KEY_PRESSED,
|
||||
message="Keyboard key pressed",
|
||||
data={"key": "backspace"},
|
||||
)
|
||||
websocket_client.keyboard_text.return_value = Response(
|
||||
id=FIXTURE_REQUEST_ID,
|
||||
type=EventType.KEYBOARD_TEXT_SENT,
|
||||
message="Keyboard text sent",
|
||||
data={"text": "Hello world"},
|
||||
)
|
||||
# Trigger callback when listener is registered
|
||||
websocket_client.listen.side_effect = mock_data_listener
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# serializer version: 1
|
||||
# name: test_get_process_services[get_process_by_id]
|
||||
dict({
|
||||
'cpu_usage': 12.3,
|
||||
'created': 12.3,
|
||||
'id': 1234,
|
||||
'memory_usage': 12.3,
|
||||
'name': 'name',
|
||||
'path': '/path',
|
||||
'status': 'running',
|
||||
'username': 'username',
|
||||
'working_directory': '/working/directory',
|
||||
})
|
||||
# ---
|
||||
# name: test_get_process_services[get_processes_by_name]
|
||||
dict({
|
||||
'count': 1,
|
||||
'processes': list([
|
||||
dict({
|
||||
'cpu_usage': 12.3,
|
||||
'created': 12.3,
|
||||
'id': 1234,
|
||||
'memory_usage': 12.3,
|
||||
'name': 'name',
|
||||
'path': '/path',
|
||||
'status': 'running',
|
||||
'username': 'username',
|
||||
'working_directory': '/working/directory',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
# name: test_services[open_path]
|
||||
dict({
|
||||
'data': dict({
|
||||
'path': '/home/user/documents',
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Opened file',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.OPENED: 'OPENED'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_services[open_url]
|
||||
dict({
|
||||
'data': dict({
|
||||
'url': 'https://example.com',
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Opened url',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.OPENED: 'OPENED'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_services[power_command_shutdown]
|
||||
dict({
|
||||
'data': dict({
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Shutdown',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.POWER_SHUTDOWN: 'POWER_SHUTDOWN'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_services[send_keypress]
|
||||
dict({
|
||||
'data': dict({
|
||||
'key': 'backspace',
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Keyboard key pressed',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.KEYBOARD_KEY_PRESSED: 'KEYBOARD_KEY_PRESSED'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_services[send_text]
|
||||
dict({
|
||||
'data': dict({
|
||||
'text': 'Hello world',
|
||||
}),
|
||||
'id': 'test',
|
||||
'message': 'Keyboard text sent',
|
||||
'module': None,
|
||||
'subtype': None,
|
||||
'type': <EventType.KEYBOARD_TEXT_SENT: 'KEYBOARD_TEXT_SENT'>,
|
||||
})
|
||||
# ---
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.system_bridge.config_flow import SystemBridgeConfigFlow
|
||||
from homeassistant.components.system_bridge.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -13,6 +15,23 @@ from . import FIXTURE_USER_INPUT, FIXTURE_UUID
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_version", "mock_websocket_client")
|
||||
async def test_entry_setup_unload(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test integration setup and unload."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_migration_minor_1_to_2(hass: HomeAssistant) -> None:
|
||||
"""Test migration."""
|
||||
config_entry = MockConfigEntry(
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Tests for System Bridge actions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from systembridgeconnector.models.keyboard_key import KeyboardKey
|
||||
from systembridgeconnector.models.keyboard_text import KeyboardText
|
||||
from systembridgeconnector.models.open_path import OpenPath
|
||||
from systembridgeconnector.models.open_url import OpenUrl
|
||||
|
||||
from homeassistant.components.system_bridge.const import DOMAIN
|
||||
from homeassistant.components.system_bridge.services import (
|
||||
CONF_BRIDGE,
|
||||
CONF_KEY,
|
||||
CONF_TEXT,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import FIXTURE_UUID
|
||||
|
||||
from tests.common import AsyncMock, MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "service_data", "call_method", "call_args"),
|
||||
[
|
||||
(
|
||||
"open_path",
|
||||
{CONF_PATH: "/home/user/documents"},
|
||||
"open_path",
|
||||
[OpenPath(path="/home/user/documents")],
|
||||
),
|
||||
(
|
||||
"open_url",
|
||||
{CONF_URL: "https://example.com"},
|
||||
"open_url",
|
||||
[OpenUrl(url="https://example.com")],
|
||||
),
|
||||
(
|
||||
"power_command",
|
||||
{CONF_COMMAND: "shutdown"},
|
||||
"power_shutdown",
|
||||
[],
|
||||
),
|
||||
(
|
||||
"send_keypress",
|
||||
{CONF_KEY: "backspace"},
|
||||
"keyboard_keypress",
|
||||
[KeyboardKey(key="backspace")],
|
||||
),
|
||||
(
|
||||
"send_text",
|
||||
{CONF_TEXT: "Hello world"},
|
||||
"keyboard_text",
|
||||
[KeyboardText(text="Hello world")],
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"open_path",
|
||||
"open_url",
|
||||
"power_command_shutdown",
|
||||
"send_keypress",
|
||||
"send_text",
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_version")
|
||||
async def test_services(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_websocket_client: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
service: str,
|
||||
service_data: dict[str, Any],
|
||||
call_method: str,
|
||||
call_args: list[Any],
|
||||
) -> None:
|
||||
"""Test System Bridge service action calls."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, FIXTURE_UUID)}
|
||||
)
|
||||
assert device_entry
|
||||
|
||||
resp = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
service,
|
||||
{
|
||||
CONF_BRIDGE: device_entry.id,
|
||||
**service_data,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
getattr(mock_websocket_client, call_method).assert_awaited_once_with(*call_args)
|
||||
assert resp == snapshot
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "service_data"),
|
||||
[
|
||||
(
|
||||
"get_process_by_id",
|
||||
{CONF_ID: 1234},
|
||||
),
|
||||
(
|
||||
"get_processes_by_name",
|
||||
{CONF_NAME: "name"},
|
||||
),
|
||||
],
|
||||
ids=["get_process_by_id", "get_processes_by_name"],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_version", "mock_websocket_client")
|
||||
async def test_get_process_services(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
service: str,
|
||||
service_data: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test System Bridge get process service action calls."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, FIXTURE_UUID)}
|
||||
)
|
||||
assert device_entry
|
||||
|
||||
resp = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
service,
|
||||
{
|
||||
CONF_BRIDGE: device_entry.id,
|
||||
**service_data,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
assert resp == snapshot
|
||||
@@ -50,6 +50,7 @@ def device_fixtures() -> list[str]:
|
||||
"XT-LT200",
|
||||
"XT-PL50",
|
||||
"XT-PL100",
|
||||
"XT-LK50",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "dev_lock_001",
|
||||
"name": "Front Door Lock",
|
||||
"type": "lock",
|
||||
"model": "XT-LK50",
|
||||
"version": "1.0.0",
|
||||
"online": true,
|
||||
"status": {
|
||||
"locked": true,
|
||||
"jammed": false,
|
||||
"battery": 85
|
||||
}
|
||||
}
|
||||
@@ -154,3 +154,34 @@
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_devices[XT-LK50]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'xthings_cloud',
|
||||
'dev_lock_001',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Xthings',
|
||||
'model': 'XT-LK50',
|
||||
'model_id': None,
|
||||
'name': 'Front Door Lock',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': '1.0.0',
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# serializer version: 1
|
||||
# name: test_locks[lock.front_door_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'lock',
|
||||
'entity_category': None,
|
||||
'entity_id': 'lock.front_door_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'xthings_cloud',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'dev_lock_001',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_locks[lock.front_door_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Front Door Lock',
|
||||
'supported_features': <LockEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'lock.front_door_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'locked',
|
||||
})
|
||||
# ---
|
||||
@@ -27,9 +27,10 @@ from .const import (
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant, mock_api_client: AsyncMock
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful user login flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -61,9 +62,9 @@ async def test_user_flow_success(
|
||||
(RuntimeError("unexpected"), "unknown"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_user_flow_error_and_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
@@ -90,9 +91,11 @@ async def test_user_flow_error_and_recover(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant, mock_api_client: AsyncMock, mock_config_entry: MockConfigEntry
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test user flow aborts if same account already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for Xthings Cloud lock platform."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.lock import (
|
||||
DOMAIN as LOCK_DOMAIN,
|
||||
SERVICE_LOCK,
|
||||
SERVICE_UNLOCK,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import get_device_by_id, setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
async def test_locks(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test lock entities are created correctly."""
|
||||
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LOCK]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, mock_config_entry.entry_id
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service", "method"),
|
||||
[
|
||||
(SERVICE_LOCK, "async_lock_lock"),
|
||||
(SERVICE_UNLOCK, "async_lock_unlock"),
|
||||
],
|
||||
)
|
||||
async def test_lock_lock_unlock(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
service: str,
|
||||
method: str,
|
||||
) -> None:
|
||||
"""Test locking and unlocking a lock."""
|
||||
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LOCK]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
LOCK_DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "lock.front_door_lock"},
|
||||
blocking=True,
|
||||
)
|
||||
getattr(mock_api_client, method).assert_called_once_with("dev_lock_001")
|
||||
|
||||
|
||||
async def test_lock_unavailable_when_offline(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test lock shows unavailable when device is offline."""
|
||||
get_device_by_id(mock_api_client, "dev_lock_001")["online"] = False
|
||||
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LOCK]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("lock.front_door_lock")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_updating_state(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_websocket: AsyncMock,
|
||||
) -> None:
|
||||
"""Test updating state."""
|
||||
with patch("homeassistant.components.xthings_cloud.PLATFORMS", [Platform.LOCK]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("lock.front_door_lock")
|
||||
assert state is not None
|
||||
assert state.state == "locked"
|
||||
|
||||
mock_websocket.call_args[1]["on_device_status"](
|
||||
"dev_lock_001",
|
||||
{
|
||||
"locked": False,
|
||||
"jammed": False,
|
||||
"battery": 80,
|
||||
},
|
||||
)
|
||||
|
||||
state = hass.states.get("lock.front_door_lock")
|
||||
assert state is not None
|
||||
assert state.state == "unlocked"
|
||||
@@ -1,52 +1,4 @@
|
||||
"""Tests for the Zeversolar integration."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from zeversolar import StatusEnum, ZeverSolarData
|
||||
|
||||
from homeassistant.components.zeversolar.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_HOST_ZEVERSOLAR = "zeversolar-fake-host"
|
||||
MOCK_PORT_ZEVERSOLAR = 10200
|
||||
MOCK_SERIAL_NUMBER = "123456778"
|
||||
|
||||
|
||||
async def init_integration(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Mock integration setup."""
|
||||
|
||||
zeverData = ZeverSolarData(
|
||||
wifi_enabled=False,
|
||||
serial_or_registry_id="EAB9615C0001",
|
||||
registry_key="WSMQKHTQ3JVYQWA9",
|
||||
hardware_version="M10",
|
||||
software_version="19703-826R+17511-707R",
|
||||
reported_datetime="19900101 23:01:45",
|
||||
communication_status=StatusEnum.OK,
|
||||
num_inverters=1,
|
||||
serial_number=MOCK_SERIAL_NUMBER,
|
||||
pac=1234,
|
||||
energy_today=123.4,
|
||||
status=StatusEnum.OK,
|
||||
meter_status=StatusEnum.OK,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("zeversolar.ZeverSolarClient.get_data", return_value=zeverData),
|
||||
):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_HOST: MOCK_HOST_ZEVERSOLAR,
|
||||
CONF_PORT: MOCK_PORT_ZEVERSOLAR,
|
||||
},
|
||||
entry_id="my_id",
|
||||
)
|
||||
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return entry
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
"""Define mocks and test objects."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from zeversolar import StatusEnum, ZeverSolarData
|
||||
|
||||
from homeassistant.components.zeversolar.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MOCK_HOST_ZEVERSOLAR, MOCK_SERIAL_NUMBER
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
MOCK_HOST_ZEVERSOLAR = "zeversolar-fake-host"
|
||||
MOCK_PORT_ZEVERSOLAR = 10200
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_entry() -> MockConfigEntry:
|
||||
"""Create a mock config entry."""
|
||||
|
||||
return MockConfigEntry(
|
||||
data={
|
||||
CONF_HOST: MOCK_HOST_ZEVERSOLAR,
|
||||
CONF_PORT: MOCK_PORT_ZEVERSOLAR,
|
||||
},
|
||||
domain=DOMAIN,
|
||||
unique_id="my_id_2",
|
||||
data={CONF_HOST: MOCK_HOST_ZEVERSOLAR},
|
||||
unique_id=MOCK_SERIAL_NUMBER,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zeversolar_data() -> ZeverSolarData:
|
||||
"""Create a ZeverSolarData structure for tests."""
|
||||
|
||||
return ZeverSolarData(
|
||||
wifi_enabled=False,
|
||||
serial_or_registry_id="1223",
|
||||
@@ -39,9 +37,39 @@ def zeversolar_data() -> ZeverSolarData:
|
||||
reported_datetime="19900101 23:00",
|
||||
communication_status=StatusEnum.OK,
|
||||
num_inverters=1,
|
||||
serial_number="123456778",
|
||||
serial_number=MOCK_SERIAL_NUMBER,
|
||||
pac=1234,
|
||||
energy_today=123,
|
||||
status=StatusEnum.OK,
|
||||
meter_status=StatusEnum.OK,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_zeversolar_client(zeversolar_data: ZeverSolarData) -> Generator[MagicMock]:
|
||||
"""Mock the ZeverSolar client."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.zeversolar.coordinator.zeversolar.ZeverSolarClient",
|
||||
autospec=True,
|
||||
) as mock_client,
|
||||
patch(
|
||||
"homeassistant.components.zeversolar.config_flow.zeversolar.ZeverSolarClient",
|
||||
new=mock_client,
|
||||
),
|
||||
):
|
||||
mock_client.return_value.get_data.return_value = zeversolar_data
|
||||
yield mock_client.return_value
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_zeversolar_client: MagicMock,
|
||||
) -> AsyncGenerator[MockConfigEntry]:
|
||||
"""Set up the Zeversolar integration for testing."""
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return config_entry
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
# name: test_entry_diagnostics
|
||||
dict({
|
||||
'communication_status': 'OK',
|
||||
'energy_today': 123.4,
|
||||
'energy_today': 123,
|
||||
'hardware_version': 'M10',
|
||||
'meter_status': 'OK',
|
||||
'num_inverters': 1,
|
||||
'pac': 1234,
|
||||
'registry_key': 'WSMQKHTQ3JVYQWA9',
|
||||
'reported_datetime': '19900101 23:01:45',
|
||||
'registry_key': 'A-2',
|
||||
'reported_datetime': '19900101 23:00',
|
||||
'serial_number': '123456778',
|
||||
'serial_or_registry_id': 'EAB9615C0001',
|
||||
'software_version': '19703-826R+17511-707R',
|
||||
'serial_or_registry_id': '1223',
|
||||
'software_version': '123-23',
|
||||
'status': 'OK',
|
||||
'wifi_enabled': False,
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '123.4',
|
||||
'state': '123',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.zeversolar_sensor_power-entry]
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant) -> None:
|
||||
async def test_form(hass: HomeAssistant, mock_zeversolar_client: MagicMock) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
@@ -26,7 +26,9 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
|
||||
await _set_up_zeversolar(hass=hass, flow_id=result["flow_id"])
|
||||
await _set_up_zeversolar(
|
||||
hass=hass, flow_id=result["flow_id"], mock_client=mock_zeversolar_client
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -52,6 +54,7 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
)
|
||||
async def test_form_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_zeversolar_client: MagicMock,
|
||||
side_effect: Exception,
|
||||
errors: dict,
|
||||
) -> None:
|
||||
@@ -60,32 +63,30 @@ async def test_form_errors(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"zeversolar.ZeverSolarClient.get_data",
|
||||
side_effect=side_effect,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
flow_id=result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOST: "test_ip",
|
||||
},
|
||||
)
|
||||
mock_zeversolar_client.get_data.side_effect = side_effect
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
flow_id=result["flow_id"],
|
||||
user_input={
|
||||
CONF_HOST: "test_ip",
|
||||
},
|
||||
)
|
||||
mock_zeversolar_client.get_data.side_effect = None
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == errors
|
||||
|
||||
await _set_up_zeversolar(hass=hass, flow_id=result["flow_id"])
|
||||
|
||||
|
||||
async def test_abort_already_configured(hass: HomeAssistant) -> None:
|
||||
"""Test we abort when the device is already configured."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Zeversolar",
|
||||
data={CONF_HOST: "test_ip"},
|
||||
unique_id="test_serial",
|
||||
await _set_up_zeversolar(
|
||||
hass=hass, flow_id=result["flow_id"], mock_client=mock_zeversolar_client
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
|
||||
async def test_abort_already_configured(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_zeversolar_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test we abort when the device is already configured."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
@@ -94,14 +95,9 @@ async def test_abort_already_configured(hass: HomeAssistant) -> None:
|
||||
assert result.get("errors") is None
|
||||
assert "flow_id" in result
|
||||
|
||||
mock_data = MagicMock()
|
||||
mock_data.serial_number = "test_serial"
|
||||
with (
|
||||
patch("zeversolar.ZeverSolarClient.get_data", return_value=mock_data),
|
||||
patch(
|
||||
"homeassistant.components.zeversolar.async_setup_entry",
|
||||
) as mock_setup_entry,
|
||||
):
|
||||
with patch(
|
||||
"homeassistant.components.zeversolar.async_setup_entry",
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
flow_id=result["flow_id"],
|
||||
user_input={
|
||||
@@ -115,17 +111,15 @@ async def test_abort_already_configured(hass: HomeAssistant) -> None:
|
||||
assert len(mock_setup_entry.mock_calls) == 0
|
||||
|
||||
|
||||
async def _set_up_zeversolar(hass: HomeAssistant, flow_id: str) -> None:
|
||||
async def _set_up_zeversolar(
|
||||
hass: HomeAssistant, flow_id: str, mock_client: MagicMock
|
||||
) -> None:
|
||||
"""Reusable successful setup of Zeversolar sensor."""
|
||||
mock_data = MagicMock()
|
||||
mock_data.serial_number = "test_serial"
|
||||
with (
|
||||
patch("zeversolar.ZeverSolarClient.get_data", return_value=mock_data),
|
||||
patch(
|
||||
"homeassistant.components.zeversolar.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry,
|
||||
):
|
||||
mock_client.get_data.return_value.serial_number = "test_serial"
|
||||
with patch(
|
||||
"homeassistant.components.zeversolar.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
flow_id=flow_id,
|
||||
user_input={
|
||||
|
||||
@@ -6,8 +6,9 @@ from homeassistant.components.zeversolar.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import MOCK_SERIAL_NUMBER, init_integration
|
||||
from . import MOCK_SERIAL_NUMBER
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import (
|
||||
get_diagnostics_for_config_entry,
|
||||
get_diagnostics_for_device,
|
||||
@@ -19,12 +20,13 @@ async def test_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
snapshot: SnapshotAssertion,
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config entry diagnostics."""
|
||||
|
||||
entry = await init_integration(hass)
|
||||
|
||||
assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot
|
||||
assert (
|
||||
await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
|
||||
== snapshot
|
||||
)
|
||||
|
||||
|
||||
async def test_device_diagnostics(
|
||||
@@ -32,15 +34,14 @@ async def test_device_diagnostics(
|
||||
hass_client: ClientSessionGenerator,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test device diagnostics."""
|
||||
|
||||
entry = await init_integration(hass)
|
||||
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, MOCK_SERIAL_NUMBER)}
|
||||
)
|
||||
|
||||
assert (
|
||||
await get_diagnostics_for_device(hass, hass_client, entry, device) == snapshot
|
||||
await get_diagnostics_for_device(hass, hass_client, init_integration, device)
|
||||
== snapshot
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Test the init file code."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from zeversolar import ZeverSolarData
|
||||
from zeversolar.exceptions import ZeverSolarTimeout
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -12,28 +11,23 @@ from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_async_setup_entry_fails(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry, zeversolar_data: ZeverSolarData
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_zeversolar_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test to load/unload the integration."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch("zeversolar.ZeverSolarClient.get_data", side_effect=ZeverSolarTimeout),
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
mock_zeversolar_client.get_data.side_effect = ZeverSolarTimeout
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
with (
|
||||
patch("homeassistant.components.zeversolar.PLATFORMS", []),
|
||||
patch("zeversolar.ZeverSolarClient.get_data", return_value=zeversolar_data),
|
||||
):
|
||||
mock_zeversolar_client.get_data.side_effect = None
|
||||
with patch("homeassistant.components.zeversolar.PLATFORMS", []):
|
||||
hass.config_entries.async_schedule_reload(config_entry.entry_id)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
with (
|
||||
patch("homeassistant.components.zeversolar.PLATFORMS", []),
|
||||
):
|
||||
with patch("homeassistant.components.zeversolar.PLATFORMS", []):
|
||||
result = await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
assert result is True
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
@@ -8,20 +8,20 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import init_integration
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
async def test_sensors(
|
||||
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test sensors."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.zeversolar.PLATFORMS",
|
||||
[Platform.SENSOR],
|
||||
):
|
||||
entry = await init_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, init_integration.entry_id
|
||||
)
|
||||
|
||||
+3
-1
@@ -1074,7 +1074,9 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]:
|
||||
|
||||
@ha.callback
|
||||
def _async_fire_mqtt_message(topic, payload, qos, retain, properties=None):
|
||||
async_fire_mqtt_message(hass, topic, payload or b"", qos, retain)
|
||||
async_fire_mqtt_message(
|
||||
hass, topic, payload or b"", qos, retain, properties=properties
|
||||
)
|
||||
mid = get_mid()
|
||||
hass.loop.call_soon(
|
||||
mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Tests for the MDI icons checker."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import astroid
|
||||
from pylint.testutils import UnittestLinter
|
||||
from pylint.utils.ast_walker import ASTWalker
|
||||
from pylint_home_assistant.checkers.mdi_icons import MdiIconsChecker
|
||||
from pylint_home_assistant.helpers.icons import clear_icons_cache
|
||||
import pytest
|
||||
|
||||
from . import assert_no_messages
|
||||
|
||||
|
||||
@pytest.fixture(name="mdi_checker")
|
||||
def mdi_checker_fixture(linter: UnittestLinter) -> MdiIconsChecker:
|
||||
"""Fixture to provide an MDI icons checker."""
|
||||
clear_icons_cache()
|
||||
checker = MdiIconsChecker(linter)
|
||||
checker.open()
|
||||
return checker
|
||||
|
||||
|
||||
def _make_integration(tmp_path: Path, icons: dict | None = None) -> Path:
|
||||
"""Create a fake integration with optional icons.json."""
|
||||
integration_dir = tmp_path / "homeassistant" / "components" / "test_int"
|
||||
integration_dir.mkdir(parents=True)
|
||||
if icons is not None:
|
||||
(integration_dir / "icons.json").write_text(json.dumps(icons))
|
||||
return integration_dir
|
||||
|
||||
|
||||
# --- Python code tests ---
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code",
|
||||
[
|
||||
pytest.param(
|
||||
'icon="mdi:thermometer"',
|
||||
id="valid_icon",
|
||||
),
|
||||
pytest.param(
|
||||
'icon="mdi:lightning-bolt"',
|
||||
id="valid_icon_with_hyphen",
|
||||
),
|
||||
pytest.param(
|
||||
'ICON = "mdi:home"',
|
||||
id="valid_icon_constant",
|
||||
),
|
||||
pytest.param(
|
||||
'device_class = "temperature"',
|
||||
id="non_mdi_string",
|
||||
),
|
||||
pytest.param(
|
||||
'icon = "mdi:%s" % icon_name',
|
||||
id="percent_format_template",
|
||||
),
|
||||
pytest.param(
|
||||
'icon = "mdi:{}".format(icon_name)',
|
||||
id="str_format_template",
|
||||
),
|
||||
pytest.param(
|
||||
'icon = f"mdi:{icon_name}"',
|
||||
id="fstring_template",
|
||||
),
|
||||
pytest.param(
|
||||
'icon = "mdi:fan-speed-" + suffix',
|
||||
id="partial_with_concatenation",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_python_no_warning(
|
||||
linter: UnittestLinter,
|
||||
mdi_checker: MdiIconsChecker,
|
||||
code: str,
|
||||
) -> None:
|
||||
"""Test that valid MDI icons in Python code pass."""
|
||||
root_node = astroid.parse(code, "homeassistant.components.test_integration.sensor")
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(mdi_checker)
|
||||
|
||||
with assert_no_messages(linter):
|
||||
walker.walk(root_node)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("icon", "code"),
|
||||
[
|
||||
pytest.param(
|
||||
"mdi:nonexistent-icon-name",
|
||||
'icon="mdi:nonexistent-icon-name"',
|
||||
id="nonexistent_icon",
|
||||
),
|
||||
pytest.param(
|
||||
"mdi:typo-thremometer",
|
||||
'ICON = "mdi:typo-thremometer"',
|
||||
id="typo_in_icon",
|
||||
),
|
||||
pytest.param(
|
||||
"mdi:bad_icon",
|
||||
'icon = "mdi:bad_icon"',
|
||||
id="underscore_in_name",
|
||||
),
|
||||
pytest.param(
|
||||
"mdi:Bad-Icon",
|
||||
'icon = "mdi:Bad-Icon"',
|
||||
id="uppercase_in_name",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_python_invalid_icon_flagged(
|
||||
linter: UnittestLinter,
|
||||
mdi_checker: MdiIconsChecker,
|
||||
icon: str,
|
||||
code: str,
|
||||
) -> None:
|
||||
"""Test that invalid MDI icons in Python code are flagged."""
|
||||
root_node = astroid.parse(code, "homeassistant.components.test_integration.sensor")
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(mdi_checker)
|
||||
walker.walk(root_node)
|
||||
|
||||
messages = linter.release_messages()
|
||||
assert len(messages) == 1
|
||||
assert messages[0].msg_id == "home-assistant-mdi-icon-not-found"
|
||||
assert icon in messages[0].args[0]
|
||||
|
||||
|
||||
def test_python_not_integration_ignored(
|
||||
linter: UnittestLinter,
|
||||
mdi_checker: MdiIconsChecker,
|
||||
) -> None:
|
||||
"""Test that non-integration modules are ignored."""
|
||||
root_node = astroid.parse(
|
||||
'ICON = "mdi:nonexistent-icon"',
|
||||
"tests.components.test_integration",
|
||||
)
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(mdi_checker)
|
||||
|
||||
with assert_no_messages(linter):
|
||||
walker.walk(root_node)
|
||||
|
||||
|
||||
# --- icons.json tests ---
|
||||
|
||||
|
||||
def test_icons_json_valid(
|
||||
linter: UnittestLinter,
|
||||
mdi_checker: MdiIconsChecker,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that valid icons.json passes."""
|
||||
integration_dir = _make_integration(
|
||||
tmp_path,
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"temperature": {"default": "mdi:thermometer"},
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"my_service": {"service": "mdi:cog"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
root_node = astroid.parse(
|
||||
"DOMAIN = 'test_int'",
|
||||
"homeassistant.components.test_int.__init__",
|
||||
)
|
||||
root_node.file = str(integration_dir / "__init__.py")
|
||||
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(mdi_checker)
|
||||
|
||||
with assert_no_messages(linter):
|
||||
walker.walk(root_node)
|
||||
|
||||
|
||||
def test_icons_json_invalid_flagged(
|
||||
linter: UnittestLinter,
|
||||
mdi_checker: MdiIconsChecker,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that invalid icons in icons.json are flagged."""
|
||||
integration_dir = _make_integration(
|
||||
tmp_path,
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"temperature": {"default": "mdi:nonexistent-sensor-icon"},
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
root_node = astroid.parse(
|
||||
"DOMAIN = 'test_int'",
|
||||
"homeassistant.components.test_int.__init__",
|
||||
)
|
||||
root_node.file = str(integration_dir / "__init__.py")
|
||||
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(mdi_checker)
|
||||
walker.walk(root_node)
|
||||
|
||||
messages = linter.release_messages()
|
||||
assert len(messages) == 1
|
||||
assert messages[0].msg_id == "home-assistant-mdi-icon-json-not-found"
|
||||
assert "nonexistent-sensor-icon" in messages[0].args[0]
|
||||
|
||||
|
||||
def test_icons_json_no_file_no_warning(
|
||||
linter: UnittestLinter,
|
||||
mdi_checker: MdiIconsChecker,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that missing icons.json doesn't cause warnings."""
|
||||
integration_dir = _make_integration(tmp_path)
|
||||
|
||||
root_node = astroid.parse(
|
||||
"DOMAIN = 'test_int'",
|
||||
"homeassistant.components.test_int.__init__",
|
||||
)
|
||||
root_node.file = str(integration_dir / "__init__.py")
|
||||
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(mdi_checker)
|
||||
|
||||
with assert_no_messages(linter):
|
||||
walker.walk(root_node)
|
||||
|
||||
|
||||
def test_icons_json_nested_invalid_flagged(
|
||||
linter: UnittestLinter,
|
||||
mdi_checker: MdiIconsChecker,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test that deeply nested invalid icons are caught."""
|
||||
integration_dir = _make_integration(
|
||||
tmp_path,
|
||||
{
|
||||
"entity": {
|
||||
"light": {
|
||||
"my_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
"state": {
|
||||
"sparkle": "mdi:does-not-exist",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
root_node = astroid.parse(
|
||||
"DOMAIN = 'test_int'",
|
||||
"homeassistant.components.test_int.__init__",
|
||||
)
|
||||
root_node.file = str(integration_dir / "__init__.py")
|
||||
|
||||
walker = ASTWalker(linter)
|
||||
walker.add_checker(mdi_checker)
|
||||
walker.walk(root_node)
|
||||
|
||||
messages = linter.release_messages()
|
||||
assert len(messages) == 1
|
||||
assert "does-not-exist" in messages[0].args[0]
|
||||
@@ -0,0 +1,678 @@
|
||||
"""Tests for the split_tests cache logic."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from script import split_tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path: Path) -> Path:
|
||||
"""Build a tree: root conftest, two integrations, a ``common.py`` helper."""
|
||||
# Bound the ancestor-fixture walk so it doesn't escape tmp_path.
|
||||
(tmp_path / "pyproject.toml").write_text("")
|
||||
(tmp_path / "conftest.py").write_text("# tests/conftest.py\n")
|
||||
(tmp_path / "common.py").write_text("# helper module\n")
|
||||
|
||||
alpha_dir = tmp_path / "components" / "alpha"
|
||||
alpha_dir.mkdir(parents=True)
|
||||
(alpha_dir / "conftest.py").write_text("# alpha conftest\n")
|
||||
(alpha_dir / "test_one.py").write_text("def test_a():\n pass\n")
|
||||
(alpha_dir / "test_two.py").write_text("def test_b():\n pass\n")
|
||||
|
||||
beta_dir = tmp_path / "components" / "beta"
|
||||
beta_dir.mkdir()
|
||||
(beta_dir / "test_x.py").write_text("def test_x():\n pass\n")
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_iter_eligible_children_filters_helpers(tree: Path) -> None:
|
||||
"""Helper files like conftest.py and common.py are not collection targets."""
|
||||
children = split_tests._iter_eligible_children(tree)
|
||||
names = {p.name for p in children}
|
||||
assert "common.py" not in names
|
||||
assert "conftest.py" not in names
|
||||
# components/ is a dir, gets included.
|
||||
assert "components" in names
|
||||
|
||||
|
||||
def test_enumerate_batch_paths_fans_out_components(tree: Path) -> None:
|
||||
"""tests/components fans out one level deeper into per-integration paths."""
|
||||
paths = split_tests._enumerate_batch_paths(tree)
|
||||
rel = {p.relative_to(tree).as_posix() for p in paths}
|
||||
assert rel == {"components/beta", "components/alpha"}
|
||||
|
||||
|
||||
def test_enumerate_batch_paths_for_single_file(tmp_path: Path) -> None:
|
||||
"""A test file passed directly is returned as-is."""
|
||||
file = tmp_path / "test_solo.py"
|
||||
file.write_text("def test_x(): pass\n")
|
||||
assert split_tests._enumerate_batch_paths(file) == [file]
|
||||
|
||||
|
||||
def _fixture_hash_for(tree: Path, file: Path) -> str:
|
||||
"""Compute the fixture scope hash for ``file`` rooted at ``tree``."""
|
||||
_, fixtures = split_tests._walk_test_tree(tree)
|
||||
fixtures_by_dir = split_tests._build_fixtures_by_dir(tree, fixtures)
|
||||
return split_tests._file_fixture_hash(file, tree, fixtures_by_dir)
|
||||
|
||||
|
||||
def _prime_cache(
|
||||
cache_path: Path,
|
||||
tree: Path,
|
||||
hits: dict[Path, int] | None = None,
|
||||
extra_entries: dict[str, split_tests._CacheEntry] | None = None,
|
||||
) -> None:
|
||||
"""Save a cache for ``tree`` keyed on real file and fixture hashes.
|
||||
|
||||
``hits`` maps file → cached count (hashed for real, so the next
|
||||
run resolves as a hit). ``extra_entries`` injects raw entries
|
||||
whose path may not exist on disk (eg ghost files).
|
||||
"""
|
||||
entries: dict[str, split_tests._CacheEntry] = {
|
||||
str(file.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(file),
|
||||
fixture_hash=_fixture_hash_for(tree, file),
|
||||
count=count,
|
||||
)
|
||||
for file, count in (hits or {}).items()
|
||||
}
|
||||
if extra_entries:
|
||||
entries.update(extra_entries)
|
||||
split_tests._Cache(entries=entries).save(cache_path)
|
||||
|
||||
|
||||
def _echo_one_test_each(
|
||||
skip: set[Path] | None = None,
|
||||
) -> Callable[[list[Path]], list[tuple[str, str, int]]]:
|
||||
"""Fake ``_run_collect_batches``: 1 test per path; ``skip`` paths drop out."""
|
||||
skip = skip or set()
|
||||
|
||||
def fake(paths: list[Path]) -> list[tuple[str, str, int]]:
|
||||
emitted = [p for p in paths if p not in skip]
|
||||
return [("\n".join(f"{p}: 1" for p in emitted) + "\n", "", 0)]
|
||||
|
||||
return fake
|
||||
|
||||
|
||||
def test_file_fixture_hash_changes_when_ancestor_conftest_changes(tree: Path) -> None:
|
||||
"""A conftest edit in the file's ancestor chain busts that file's hash."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
# Same-dir conftest is an ancestor of alpha_one.
|
||||
(tree / "components" / "alpha" / "conftest.py").write_text("# changed\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_changes_when_same_dir_helper_changes(tree: Path) -> None:
|
||||
"""A non-conftest helper in the same dir busts the file's hash."""
|
||||
alpha_dir = tree / "components" / "alpha"
|
||||
(alpha_dir / "common.py").write_text("# helper v1\n")
|
||||
alpha_one = alpha_dir / "test_one.py"
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
(alpha_dir / "common.py").write_text("# helper v2\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_isolated_from_sibling_dir(tree: Path) -> None:
|
||||
"""A helper change in a sibling subtree leaves this file's hash alone."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
# beta is a sibling of alpha (not an ancestor), so its helper edit
|
||||
# must not affect alpha_one's fixture hash.
|
||||
(tree / "components" / "beta" / "common.py").write_text("# beta v2\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_file_fixture_hash_changes_when_ancestor_helper_changes(tree: Path) -> None:
|
||||
"""A helper edit anywhere on the ancestor path busts the file's hash.
|
||||
|
||||
Test files often import VALUES for ``@pytest.mark.parametrize`` from
|
||||
shared helpers like ``tests/components/common.py``; any ancestor
|
||||
``.py`` change has to invalidate descendants so cached counts don't
|
||||
drift after edits to those sources.
|
||||
"""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
# Seed a shared helper one level up from alpha.
|
||||
components_common = tree / "components" / "common.py"
|
||||
components_common.write_text("# helper v1\n")
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
components_common.write_text("# helper v2\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_stable_for_test_changes(tree: Path) -> None:
|
||||
"""Test-file edits do not invalidate the file's fixture hash."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
before = _fixture_hash_for(tree, alpha_one)
|
||||
alpha_one.write_text("def test_a():\n pass\n\ndef test_c():\n pass\n")
|
||||
after = _fixture_hash_for(tree, alpha_one)
|
||||
assert before == after
|
||||
|
||||
|
||||
def test_find_ancestor_fixtures_stops_at_project_root(tmp_path: Path) -> None:
|
||||
"""A project-root marker bounds the ancestor walk."""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / "pyproject.toml").write_text("")
|
||||
(project / "common.py").write_text("# included\n")
|
||||
nested = project / "tests" / "x"
|
||||
nested.mkdir(parents=True)
|
||||
# Above the project root: must NOT be picked up.
|
||||
(tmp_path / "outside.py").write_text("# excluded\n")
|
||||
|
||||
found = {p.name for p in split_tests._find_ancestor_fixtures(nested)}
|
||||
assert "common.py" in found
|
||||
assert "outside.py" not in found
|
||||
|
||||
|
||||
def test_find_ancestor_fixtures_walks_through_gaps(tmp_path: Path) -> None:
|
||||
"""Ancestor conftests + helpers are collected across intermediate gaps."""
|
||||
(tmp_path / "pyproject.toml").write_text("") # bound the walk
|
||||
nested = tmp_path / "a" / "b" / "c"
|
||||
nested.mkdir(parents=True)
|
||||
# ``a/b`` has no fixtures, but ``a`` has both a conftest and a helper.
|
||||
(tmp_path / "a" / "conftest.py").write_text("# a\n")
|
||||
(tmp_path / "a" / "common.py").write_text("# a helper\n")
|
||||
(tmp_path / "a" / "b" / "c" / "conftest.py").write_text("# c\n")
|
||||
|
||||
found = {
|
||||
p.relative_to(tmp_path).as_posix()
|
||||
for p in split_tests._find_ancestor_fixtures(nested)
|
||||
}
|
||||
# The walk starts at ``nested.parent`` (a/b); a/b/c/conftest.py is
|
||||
# not an ancestor. Both ``a/conftest.py`` and ``a/common.py`` must
|
||||
# be found despite a/b having no fixtures of its own.
|
||||
assert "a/conftest.py" in found
|
||||
assert "a/common.py" in found
|
||||
assert "a/b/c/conftest.py" not in found
|
||||
|
||||
|
||||
def test_file_fixture_hash_picks_up_ancestor_helper_above_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An ancestor non-conftest helper above root still busts descendant hashes.
|
||||
|
||||
A subtree run on ``components/`` must still invalidate when a shared
|
||||
helper one level up (eg ``tests/components/common.py``) changes.
|
||||
"""
|
||||
(tmp_path / "pyproject.toml").write_text("") # bound the walk
|
||||
(tmp_path / "common.py").write_text("# v1\n")
|
||||
subtree = tmp_path / "components"
|
||||
subtree.mkdir()
|
||||
test_file = subtree / "test_x.py"
|
||||
test_file.write_text("def test_x(): pass\n")
|
||||
|
||||
before = _fixture_hash_for(subtree, test_file)
|
||||
(tmp_path / "common.py").write_text("# v2\n")
|
||||
after = _fixture_hash_for(subtree, test_file)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_picks_up_ancestor_conftest_across_gap(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""An ancestor conftest across a gap still busts the descendant's hash."""
|
||||
(tmp_path / "pyproject.toml").write_text("") # bound the walk
|
||||
nested = tmp_path / "a" / "b"
|
||||
nested.mkdir(parents=True)
|
||||
(tmp_path / "a" / "conftest.py").write_text("# v1\n")
|
||||
test_file = nested / "test_x.py"
|
||||
test_file.write_text("def test_x(): pass\n")
|
||||
|
||||
before = _fixture_hash_for(nested, test_file)
|
||||
(tmp_path / "a" / "conftest.py").write_text("# v2\n")
|
||||
after = _fixture_hash_for(nested, test_file)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_file_fixture_hash_includes_ancestor_above_root(tmp_path: Path) -> None:
|
||||
"""An ancestor conftest above root must still scope a subtree file."""
|
||||
(tmp_path / "pyproject.toml").write_text("") # bound the walk
|
||||
(tmp_path / "conftest.py").write_text("# parent\n")
|
||||
subtree = tmp_path / "components"
|
||||
subtree.mkdir()
|
||||
test_file = subtree / "test_x.py"
|
||||
test_file.write_text("def test_x(): pass\n")
|
||||
|
||||
before = _fixture_hash_for(subtree, test_file)
|
||||
(tmp_path / "conftest.py").write_text("# parent changed\n")
|
||||
after = _fixture_hash_for(subtree, test_file)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_walk_test_tree_separates_tests_from_fixtures(tree: Path) -> None:
|
||||
"""The walker returns test_*.py files and every other .py as fixtures."""
|
||||
test_files, fixtures = split_tests._walk_test_tree(tree)
|
||||
test_names = {p.name for p in test_files}
|
||||
fixture_paths = {p.relative_to(tree).as_posix() for p in fixtures}
|
||||
assert test_names == {"test_one.py", "test_two.py", "test_x.py"}
|
||||
assert fixture_paths == {
|
||||
"conftest.py",
|
||||
"common.py",
|
||||
"components/alpha/conftest.py",
|
||||
}
|
||||
|
||||
|
||||
def test_walk_test_tree_skips_hidden_and_dunder_dirs(tmp_path: Path) -> None:
|
||||
"""Hidden/dunder directories are pruned from the walk."""
|
||||
(tmp_path / "__pycache__").mkdir()
|
||||
(tmp_path / "__pycache__" / "test_ghost.py").write_text("def test_g(): pass\n")
|
||||
(tmp_path / ".hidden").mkdir()
|
||||
(tmp_path / ".hidden" / "test_invisible.py").write_text("def test_h(): pass\n")
|
||||
(tmp_path / "test_real.py").write_text("def test_r(): pass\n")
|
||||
|
||||
test_files, _ = split_tests._walk_test_tree(tmp_path)
|
||||
assert {p.name for p in test_files} == {"test_real.py"}
|
||||
|
||||
|
||||
def test_collect_tests_skips_cache_for_single_file_root(tmp_path: Path) -> None:
|
||||
"""Single-file root bypasses caching.
|
||||
|
||||
Otherwise the invalidation hash would be constant and stale counts
|
||||
could survive conftest edits.
|
||||
"""
|
||||
cache_path = tmp_path / "cache.json"
|
||||
file = tmp_path / "test_solo.py"
|
||||
file.write_text("def test_x(): pass\n")
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_collect_tests_uncached") as uncached,
|
||||
patch.object(split_tests, "_collect_tests_cached") as cached,
|
||||
):
|
||||
split_tests.collect_tests(file, cache_path)
|
||||
|
||||
uncached.assert_called_once_with(file)
|
||||
cached.assert_not_called()
|
||||
assert not cache_path.exists()
|
||||
|
||||
|
||||
def test_cache_roundtrip(tmp_path: Path) -> None:
|
||||
"""A cache survives save → load."""
|
||||
cache_path = tmp_path / "cache.json"
|
||||
cache = split_tests._Cache(
|
||||
entries={
|
||||
"tests/alpha/test_a.py": split_tests._CacheEntry(
|
||||
hash="h1", fixture_hash="f1", count=5
|
||||
)
|
||||
},
|
||||
)
|
||||
cache.save(cache_path)
|
||||
loaded = split_tests._Cache.load(cache_path)
|
||||
assert loaded.entries == cache.entries
|
||||
|
||||
|
||||
def test_cache_load_missing_returns_empty(tmp_path: Path) -> None:
|
||||
"""A missing cache file degrades gracefully to an empty cache."""
|
||||
cache = split_tests._Cache.load(tmp_path / "missing.json")
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_invalid_json_returns_empty(tmp_path: Path) -> None:
|
||||
"""Corrupt JSON is treated as a cache miss instead of crashing."""
|
||||
path = tmp_path / "broken.json"
|
||||
path.write_text("{not json")
|
||||
cache = split_tests._Cache.load(path)
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_wrong_version_returns_empty(tmp_path: Path) -> None:
|
||||
"""An older cache schema is discarded rather than misread."""
|
||||
path = tmp_path / "old.json"
|
||||
path.write_text(json.dumps({"version": 0, "files": {}}))
|
||||
cache = split_tests._Cache.load(path)
|
||||
assert cache.entries == {}
|
||||
|
||||
|
||||
def test_cache_load_drops_malformed_entries(tmp_path: Path) -> None:
|
||||
"""Malformed per-file entries are skipped, valid ones are kept."""
|
||||
path = tmp_path / "cache.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": split_tests._CACHE_VERSION,
|
||||
"files": {
|
||||
"good.py": {"hash": "h1", "fixture_hash": "f1", "count": 3},
|
||||
"bad_count.py": {
|
||||
"hash": "h2",
|
||||
"fixture_hash": "f2",
|
||||
"count": "three",
|
||||
},
|
||||
"missing_hash.py": {"fixture_hash": "f3", "count": 4},
|
||||
"missing_fixture_hash.py": {"hash": "h4", "count": 4},
|
||||
"not_dict.py": 5,
|
||||
# bool is an int subclass; reject so True isn't read as 1.
|
||||
"bool_count.py": {
|
||||
"hash": "h5",
|
||||
"fixture_hash": "f5",
|
||||
"count": True,
|
||||
},
|
||||
"negative_count.py": {
|
||||
"hash": "h6",
|
||||
"fixture_hash": "f6",
|
||||
"count": -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
cache = split_tests._Cache.load(path)
|
||||
assert set(cache.entries) == {"good.py"}
|
||||
|
||||
|
||||
def test_cache_save_creates_parent_dir(tmp_path: Path) -> None:
|
||||
"""Save mkdirs missing parent dirs so ``--cache foo/bar.json`` works."""
|
||||
cache_path = tmp_path / "nested" / "subdir" / "cache.json"
|
||||
split_tests._Cache(entries={}).save(cache_path)
|
||||
assert cache_path.is_file()
|
||||
|
||||
|
||||
def _resolve(
|
||||
test_files: list[Path], cache: split_tests._Cache, tree: Path
|
||||
) -> tuple[dict[Path, split_tests._CacheEntry], list[Path]]:
|
||||
"""Run resolve_entries with a freshly indexed fixtures_by_dir."""
|
||||
_, fixtures = split_tests._walk_test_tree(tree)
|
||||
return split_tests._resolve_entries(
|
||||
test_files,
|
||||
cache,
|
||||
tree,
|
||||
split_tests._build_fixtures_by_dir(tree, fixtures),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_entries_hits_and_misses(tree: Path) -> None:
|
||||
"""Files with matching content + fixture hashes are hits."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
alpha_one_hash = split_tests._hash_file(alpha_one)
|
||||
alpha_one_fixture = _fixture_hash_for(tree, alpha_one)
|
||||
cache = split_tests._Cache(
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=alpha_one_hash, fixture_hash=alpha_one_fixture, count=1
|
||||
),
|
||||
str(alpha_two.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash="stale", fixture_hash=alpha_one_fixture, count=99
|
||||
),
|
||||
},
|
||||
)
|
||||
entries, misses = _resolve([alpha_one, alpha_two, beta_x], cache, tree)
|
||||
# Hit: cached entry passed through verbatim.
|
||||
assert entries[alpha_one] == split_tests._CacheEntry(
|
||||
hash=alpha_one_hash, fixture_hash=alpha_one_fixture, count=1
|
||||
)
|
||||
# Misses: fresh hashes plus a count=0 placeholder.
|
||||
assert set(misses) == {alpha_two, beta_x}
|
||||
assert entries[alpha_two].count == 0
|
||||
assert entries[alpha_two].hash == split_tests._hash_file(alpha_two)
|
||||
assert entries[beta_x].count == 0
|
||||
assert entries[beta_x].hash == split_tests._hash_file(beta_x)
|
||||
|
||||
|
||||
def test_resolve_entries_misses_on_fixture_drift(tree: Path) -> None:
|
||||
"""A file with unchanged content but changed scope counts as a miss."""
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
cache = split_tests._Cache(
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one),
|
||||
fixture_hash="stale-fixture-hash",
|
||||
count=1,
|
||||
),
|
||||
},
|
||||
)
|
||||
_, misses = _resolve([alpha_one], cache, tree)
|
||||
assert misses == [alpha_one]
|
||||
|
||||
|
||||
def test_resolve_entries_isolates_unrelated_dirs(tree: Path) -> None:
|
||||
"""Editing a helper in one dir leaves files in other dirs as hits."""
|
||||
alpha_dir = tree / "components" / "alpha"
|
||||
beta_dir = tree / "components" / "beta"
|
||||
# Helpers per dir, so a change in alpha doesn't bust beta.
|
||||
(alpha_dir / "common.py").write_text("# alpha helper v1\n")
|
||||
(beta_dir / "common.py").write_text("# beta helper v1\n")
|
||||
alpha_one = alpha_dir / "test_one.py"
|
||||
beta_x = beta_dir / "test_x.py"
|
||||
|
||||
# Snapshot cache entries with the v1 fixture state.
|
||||
cache = split_tests._Cache(
|
||||
entries={
|
||||
str(alpha_one.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(alpha_one),
|
||||
fixture_hash=_fixture_hash_for(tree, alpha_one),
|
||||
count=1,
|
||||
),
|
||||
str(beta_x.relative_to(tree)): split_tests._CacheEntry(
|
||||
hash=split_tests._hash_file(beta_x),
|
||||
fixture_hash=_fixture_hash_for(tree, beta_x),
|
||||
count=2,
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Now bust beta's helper; alpha's scope is unchanged, beta's isn't.
|
||||
(beta_dir / "common.py").write_text("# beta helper v2\n")
|
||||
_, misses = _resolve([alpha_one, beta_x], cache, tree)
|
||||
assert misses == [beta_x]
|
||||
|
||||
|
||||
def test_collect_tests_hashes_each_file_once(tree: Path) -> None:
|
||||
"""Hits reuse the stored hash, misses reuse the resolve-time hash.
|
||||
|
||||
Guards against regressing the double-read on cache-miss rebuilds:
|
||||
each test file should pass through _hash_file at most once per run.
|
||||
"""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
# Prime with one hit so we exercise the file-level (not directory-level) miss path.
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1})
|
||||
|
||||
real_hash = split_tests._hash_file
|
||||
counts: dict[Path, int] = {}
|
||||
|
||||
def counting_hash(path: Path) -> str:
|
||||
counts[path] = counts.get(path, 0) + 1
|
||||
return real_hash(path)
|
||||
|
||||
# Pin the threshold so the tiny tree stays on the file-level path.
|
||||
with (
|
||||
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
|
||||
patch.object(split_tests, "_hash_file", side_effect=counting_hash),
|
||||
patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
|
||||
),
|
||||
):
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
assert all(n == 1 for n in counts.values()), counts
|
||||
|
||||
|
||||
def test_collect_tests_warm_cache_skips_pytest(tree: Path) -> None:
|
||||
"""A warm cache with no diffs should skip the pytest subprocess entirely."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1, alpha_two: 2, beta_x: 3})
|
||||
|
||||
with patch.object(split_tests, "_run_collect_batches") as run_batches:
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
run_batches.assert_not_called()
|
||||
assert folder.total_tests == 6
|
||||
|
||||
|
||||
def test_collect_tests_cold_cache_collects_only_missing(tree: Path) -> None:
|
||||
"""A partial cache should only re-collect the files that changed."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1})
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
|
||||
patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
|
||||
) as run_batches,
|
||||
):
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
assert run_batches.call_count == 1
|
||||
requested = set(run_batches.call_args.args[0])
|
||||
assert requested == {alpha_two, beta_x}
|
||||
assert folder.total_tests == 3
|
||||
|
||||
# Cache should now contain entries for every test file.
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert set(saved["files"]) == {
|
||||
str(alpha_one.relative_to(tree)),
|
||||
str(alpha_two.relative_to(tree)),
|
||||
str(beta_x.relative_to(tree)),
|
||||
}
|
||||
|
||||
|
||||
def test_collect_tests_falls_back_to_dirs_when_misses_dominate(tree: Path) -> None:
|
||||
"""Heavy misses should switch back to dir-level invocation."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1})
|
||||
# 2 misses / 3 total = 67% miss, above the 30% default threshold; this
|
||||
# also covers the new-directory PR case (mostly-new test files).
|
||||
|
||||
with patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
|
||||
) as run_batches:
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
# We expect the dir-level batch paths, not the individual miss files.
|
||||
requested = set(run_batches.call_args.args[0])
|
||||
assert requested == set(split_tests._enumerate_batch_paths(tree))
|
||||
|
||||
|
||||
def test_collect_tests_caches_files_with_no_collected_tests(tree: Path) -> None:
|
||||
"""Files pytest returns nothing for are cached as 0 so we stop re-collecting them.
|
||||
|
||||
Helper modules named test_*.py with no actual test functions look like
|
||||
test files to the walker but pytest reports no tests for them. We
|
||||
want the cache to remember that and skip them on subsequent runs.
|
||||
"""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
alpha_two = tree / "components" / "alpha" / "test_two.py"
|
||||
beta_x = tree / "components" / "beta" / "test_x.py"
|
||||
|
||||
# Prime the cache with one hit so collect_tests takes the file-level
|
||||
# diff path; the cold-cache path hands pytest top-level directories
|
||||
# rather than individual file paths.
|
||||
_prime_cache(cache_path, tree, hits={alpha_one: 1})
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
|
||||
patch.object(
|
||||
split_tests,
|
||||
"_run_collect_batches",
|
||||
side_effect=_echo_one_test_each(skip={alpha_two}),
|
||||
),
|
||||
):
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert saved["files"][str(alpha_two.relative_to(tree))]["count"] == 0
|
||||
assert saved["files"][str(alpha_one.relative_to(tree))]["count"] == 1
|
||||
assert saved["files"][str(beta_x.relative_to(tree))]["count"] == 1
|
||||
|
||||
# Re-running with the same content should now be a full cache hit
|
||||
# even though alpha_two has no tests.
|
||||
with patch.object(split_tests, "_run_collect_batches") as run_batches:
|
||||
folder = split_tests.collect_tests(tree, cache_path)
|
||||
run_batches.assert_not_called()
|
||||
# alpha_two contributes 0, only alpha_one + beta_x count.
|
||||
assert folder.total_tests == 2
|
||||
|
||||
|
||||
def test_collect_tests_drops_deleted_files_from_cache(tree: Path) -> None:
|
||||
"""Files that disappear from disk are dropped from the saved cache."""
|
||||
cache_path = tree / "cache.json"
|
||||
alpha_one = tree / "components" / "alpha" / "test_one.py"
|
||||
ghost_rel = "components/alpha/test_ghost.py"
|
||||
|
||||
_prime_cache(
|
||||
cache_path,
|
||||
tree,
|
||||
hits={alpha_one: 1},
|
||||
extra_entries={
|
||||
ghost_rel: split_tests._CacheEntry(
|
||||
hash="dead", fixture_hash="dead", count=42
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(split_tests, "_DIR_LEVEL_MISS_RATIO", 1.0),
|
||||
patch.object(
|
||||
split_tests, "_run_collect_batches", side_effect=_echo_one_test_each()
|
||||
),
|
||||
):
|
||||
split_tests.collect_tests(tree, cache_path)
|
||||
|
||||
saved = json.loads(cache_path.read_text())
|
||||
assert ghost_rel not in saved["files"]
|
||||
|
||||
|
||||
def _build_folder(tree: Path, counts: dict[Path, int]) -> split_tests.TestFolder:
|
||||
"""Build a TestFolder for ``tree`` populated with ``counts``."""
|
||||
folder = split_tests.TestFolder(tree)
|
||||
for path, n in counts.items():
|
||||
folder.add_test_file(split_tests.TestFile(n, path))
|
||||
return folder
|
||||
|
||||
|
||||
def test_split_tests_keeps_siblings_together_when_snapshots_present(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Same-dir files stay together when the folder has syrupy snapshots."""
|
||||
one = tmp_path / "alpha" / "test_one.py"
|
||||
two = tmp_path / "alpha" / "test_two.py"
|
||||
one.parent.mkdir(parents=True)
|
||||
one.touch()
|
||||
two.touch()
|
||||
# Add a snapshot so the syrupy constraint kicks in.
|
||||
snapshots = tmp_path / "alpha" / "snapshots"
|
||||
snapshots.mkdir()
|
||||
(snapshots / "test_one.ambr").write_text("")
|
||||
|
||||
folder = _build_folder(tmp_path, {one: 60, two: 60})
|
||||
holder = split_tests.BucketHolder(tests_per_bucket=50, bucket_count=3)
|
||||
holder.split_tests(folder)
|
||||
# Both files must end up in one bucket; the other two stay empty.
|
||||
sizes = sorted(b.total_tests for b in holder._buckets)
|
||||
assert sizes == [0, 0, 120]
|
||||
|
||||
|
||||
def test_split_tests_splits_siblings_when_no_snapshots(tmp_path: Path) -> None:
|
||||
"""Same-dir files split freely across buckets when no snapshots exist."""
|
||||
one = tmp_path / "alpha" / "test_one.py"
|
||||
two = tmp_path / "alpha" / "test_two.py"
|
||||
one.parent.mkdir(parents=True)
|
||||
one.touch()
|
||||
two.touch()
|
||||
# No snapshots dir → free to split.
|
||||
|
||||
folder = _build_folder(tmp_path, {one: 60, two: 60})
|
||||
holder = split_tests.BucketHolder(tests_per_bucket=70, bucket_count=2)
|
||||
holder.split_tests(folder)
|
||||
sizes = sorted(b.total_tests for b in holder._buckets)
|
||||
assert sizes == [60, 60]
|
||||
Reference in New Issue
Block a user