mirror of
https://github.com/home-assistant/core.git
synced 2026-05-24 09:45:13 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21664b933a |
@@ -15,7 +15,6 @@ Dockerfile.dev linguist-language=Dockerfile
|
||||
# Generated files
|
||||
CODEOWNERS linguist-generated=true
|
||||
homeassistant/generated/*.py linguist-generated=true
|
||||
pylint/plugins/pylint_home_assistant/generated/*.py linguist-generated=true
|
||||
machine/* linguist-generated=true
|
||||
mypy.ini linguist-generated=true
|
||||
requirements.txt linguist-generated=true
|
||||
|
||||
@@ -5,12 +5,8 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import labs, websocket_api
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
@@ -53,7 +49,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
_DATA_SNAPSHOTS_URL: HassKey[str | None] = HassKey(f"{DOMAIN}_snapshots_url")
|
||||
|
||||
LABS_SNAPSHOT_FEATURE = "snapshots"
|
||||
|
||||
@@ -62,39 +57,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the analytics integration."""
|
||||
analytics_config = config.get(DOMAIN, {})
|
||||
|
||||
snapshots_url: str | None = None
|
||||
if CONF_SNAPSHOTS_URL in analytics_config:
|
||||
await labs.async_update_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, enabled=True
|
||||
)
|
||||
snapshots_url = analytics_config[CONF_SNAPSHOTS_URL]
|
||||
else:
|
||||
snapshots_url = None
|
||||
|
||||
hass.data[_DATA_SNAPSHOTS_URL] = snapshots_url
|
||||
|
||||
discovery_flow.async_create_flow(
|
||||
hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
|
||||
)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Analytics from a config entry."""
|
||||
snapshots_url = hass.data.get(_DATA_SNAPSHOTS_URL)
|
||||
analytics = Analytics(hass, snapshots_url)
|
||||
|
||||
try:
|
||||
await analytics.load()
|
||||
except HassioNotReadyError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="supervisor_not_ready",
|
||||
) from err
|
||||
# Load stored data
|
||||
await analytics.load()
|
||||
|
||||
started = False
|
||||
|
||||
@@ -106,30 +80,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if started:
|
||||
await analytics.async_schedule()
|
||||
|
||||
async def start_schedule(hass: HomeAssistant) -> None:
|
||||
"""Start the send schedule once Home Assistant has started."""
|
||||
async def start_schedule(_event: Event) -> None:
|
||||
"""Start the send schedule after the started event."""
|
||||
nonlocal started
|
||||
started = True
|
||||
await analytics.async_schedule()
|
||||
|
||||
entry.async_on_unload(
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
labs.async_subscribe_preview_feature(
|
||||
hass, DOMAIN, LABS_SNAPSHOT_FEATURE, _async_handle_labs_update
|
||||
)
|
||||
entry.async_on_unload(async_at_started(hass, start_schedule))
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, start_schedule)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket_analytics)
|
||||
websocket_api.async_register_command(hass, websocket_analytics_preferences)
|
||||
|
||||
hass.http.register_view(AnalyticsDevicesView)
|
||||
|
||||
hass.data[DATA_COMPONENT] = analytics
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload an Analytics config entry."""
|
||||
analytics = hass.data.pop(DATA_COMPONENT)
|
||||
analytics.cancel_scheduled()
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "analytics"})
|
||||
@@ -139,9 +109,7 @@ def websocket_analytics(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{ATTR_PREFERENCES: analytics.preferences, ATTR_ONBOARDED: analytics.onboarded},
|
||||
@@ -162,10 +130,8 @@ async def websocket_analytics_preferences(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update analytics preferences."""
|
||||
if (analytics := hass.data.get(DATA_COMPONENT)) is None:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "Not loaded")
|
||||
return
|
||||
preferences = msg[ATTR_PREFERENCES]
|
||||
analytics = hass.data[DATA_COMPONENT]
|
||||
|
||||
await analytics.save_preferences(preferences)
|
||||
await analytics.async_schedule()
|
||||
|
||||
@@ -299,8 +299,12 @@ class Analytics:
|
||||
self._data = AnalyticsData.from_dict(stored)
|
||||
|
||||
if self.supervisor and not self.onboarded:
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable.
|
||||
# The caller is responsible for handling this and triggering a retry.
|
||||
# This may raise HassioNotReadyError if Supervisor was unreachable
|
||||
# during setup of the Supervisor integration. That will fail setup
|
||||
# of this integration. However there is no better option at this time
|
||||
# since we need to get the diagnostic setting from Supervisor to correctly
|
||||
# setup this integration and we can't raise ConfigEntryNotReady to
|
||||
# trigger a retry from async_setup.
|
||||
supervisor_info = hassio.get_supervisor_info(self._hass)
|
||||
|
||||
# User have not configured analytics, get this setting from the supervisor
|
||||
@@ -345,7 +349,8 @@ class Analytics:
|
||||
await self._save()
|
||||
|
||||
if self.supervisor:
|
||||
# The others may raise HassioNotReadyError if only some
|
||||
# get_supervisor_info was called during setup so we can't get here
|
||||
# if it raised. The others may raise HassioNotReadyError if only some
|
||||
# data was successfully fetched from Supervisor
|
||||
supervisor_info = hassio.get_supervisor_info(hass)
|
||||
with contextlib.suppress(hassio.HassioNotReadyError):
|
||||
@@ -625,16 +630,6 @@ class Analytics:
|
||||
err,
|
||||
)
|
||||
|
||||
@callback
|
||||
def cancel_scheduled(self) -> None:
|
||||
"""Cancel all scheduled analytics tasks."""
|
||||
if self._basic_scheduled is not None:
|
||||
self._basic_scheduled()
|
||||
self._basic_scheduled = None
|
||||
if self._snapshot_scheduled is not None:
|
||||
self._snapshot_scheduled()
|
||||
self._snapshot_scheduled = None
|
||||
|
||||
async def async_schedule(self) -> None:
|
||||
"""Schedule analytics."""
|
||||
if not self.onboarded:
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Config flow for Analytics integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class AnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Analytics."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_system(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
return self.async_create_entry(title="Analytics", data={})
|
||||
@@ -3,7 +3,6 @@
|
||||
"name": "Analytics",
|
||||
"after_dependencies": ["energy", "hassio", "recorder"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["api", "websocket_api", "http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/analytics",
|
||||
"integration_type": "system",
|
||||
@@ -15,6 +14,5 @@
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
"quality_scale": "internal",
|
||||
"single_config_entry": true
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"supervisor_not_ready": {
|
||||
"message": "Supervisor was not ready during setup, will retry"
|
||||
}
|
||||
},
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
|
||||
@@ -19,9 +19,7 @@ EXPECTED_ENTRY_VERSION = (
|
||||
@callback
|
||||
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
|
||||
"""Return board info."""
|
||||
entries = hass.config_entries.async_entries(
|
||||
DOMAIN, include_ignore=False, include_disabled=False
|
||||
)
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
return [
|
||||
HardwareInfo(
|
||||
board=None,
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["homewizard_energy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-homewizard-energy==10.1.0"],
|
||||
"requirements": ["python-homewizard-energy==10.0.1"],
|
||||
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
|
||||
@@ -65,6 +66,13 @@ def to_percentage(value: float | None) -> float | None:
|
||||
return value * 100 if value is not None else None
|
||||
|
||||
|
||||
def uptime_to_datetime(value: int) -> datetime:
|
||||
"""Convert seconds to datetime timestamp."""
|
||||
return utcnow().replace(microsecond=0) - timedelta(seconds=value)
|
||||
|
||||
|
||||
uptime_to_stable_datetime = ignore_variance(uptime_to_datetime, timedelta(minutes=5))
|
||||
|
||||
SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="smr_version",
|
||||
@@ -635,7 +643,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="uptime",
|
||||
translation_key="uptime",
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
has_fn=(
|
||||
@@ -643,7 +651,7 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
),
|
||||
value_fn=(
|
||||
lambda data: (
|
||||
utcnow() - timedelta(seconds=data.system.uptime_s)
|
||||
uptime_to_stable_datetime(data.system.uptime_s)
|
||||
if data.system is not None and data.system.uptime_s is not None
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -61,14 +61,13 @@
|
||||
},
|
||||
"select": {
|
||||
"battery_group_mode": {
|
||||
"name": "Battery group charging strategy",
|
||||
"name": "Battery group mode",
|
||||
"state": {
|
||||
"predictive": "Smart charging",
|
||||
"standby": "Standby",
|
||||
"to_full": "One-time full charge",
|
||||
"zero": "Net zero",
|
||||
"zero_charge_only": "Net zero (charge only)",
|
||||
"zero_discharge_only": "Net zero (discharge only)"
|
||||
"to_full": "Manual charge mode",
|
||||
"zero": "Zero mode",
|
||||
"zero_charge_only": "Zero mode (charge only)",
|
||||
"zero_discharge_only": "Zero mode (discharge only)"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -87,8 +87,6 @@ def async_get_triggers(
|
||||
|
||||
# Get Hue device id from device identifier
|
||||
hue_dev_id = get_hue_device_id(device_entry)
|
||||
if hue_dev_id is None or hue_dev_id not in api.devices:
|
||||
return []
|
||||
# extract triggers from all button resources of this Hue device
|
||||
triggers: list[dict[str, Any]] = []
|
||||
model_id = api.devices[hue_dev_id].product_data.product_name
|
||||
|
||||
@@ -118,8 +118,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
device = devices.add_x10_device(housecode, unitcode, x10_type, steps)
|
||||
|
||||
create_insteon_device(hass, devices.modem, entry.entry_id)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, INSTEON_PLATFORMS)
|
||||
|
||||
for address in devices:
|
||||
@@ -133,6 +131,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
register_new_device_callback(hass)
|
||||
async_setup_services(hass)
|
||||
|
||||
create_insteon_device(hass, devices.modem, entry.entry_id)
|
||||
|
||||
entry.async_create_background_task(
|
||||
hass, async_get_device_config(hass, entry), "insteon-get-device-config"
|
||||
)
|
||||
|
||||
@@ -241,7 +241,7 @@ def preprocess_turn_on_alternatives(
|
||||
|
||||
if (color_name := params.pop(ATTR_COLOR_NAME, None)) is not None:
|
||||
try:
|
||||
params[ATTR_RGB_COLOR] = tuple(color_util.color_name_to_rgb(color_name))
|
||||
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
|
||||
except ValueError:
|
||||
_LOGGER.warning("Got unknown color %s, falling back to white", color_name)
|
||||
params[ATTR_RGB_COLOR] = (255, 255, 255)
|
||||
|
||||
@@ -60,7 +60,7 @@ def get_matter_device_info(
|
||||
return None
|
||||
|
||||
return MatterDeviceInfo(
|
||||
unique_id=node.device_info.uniqueID or "",
|
||||
unique_id=node.device_info.uniqueID,
|
||||
vendor_id=hex(node.device_info.vendorID),
|
||||
product_id=hex(node.device_info.productID),
|
||||
)
|
||||
|
||||
@@ -6,14 +6,13 @@ from typing import Any
|
||||
from chip.clusters import Objects
|
||||
from matter_server.common.helpers.util import dataclass_to_dict, parse_attribute_path
|
||||
|
||||
from homeassistant.components.diagnostics import REDACTED, async_redact_data
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .helpers import MatterConfigEntry, get_matter, get_node_from_device_entry
|
||||
|
||||
ATTRIBUTES_TO_REDACT = {Objects.BasicInformation.Attributes.Location}
|
||||
SERVER_INFO_TO_REDACT = {"wifi_ssid"}
|
||||
|
||||
|
||||
def redact_matter_attributes(node_data: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -45,7 +44,6 @@ async def async_get_config_entry_diagnostics(
|
||||
matter = get_matter(hass)
|
||||
server_diagnostics = await matter.matter_client.get_diagnostics()
|
||||
data = dataclass_to_dict(server_diagnostics)
|
||||
data["info"] = async_redact_data(data["info"], SERVER_INFO_TO_REDACT)
|
||||
nodes = [redact_matter_attributes(node_data) for node_data in data["nodes"]]
|
||||
data["nodes"] = nodes
|
||||
|
||||
@@ -61,9 +59,7 @@ async def async_get_device_diagnostics(
|
||||
node = get_node_from_device_entry(hass, device)
|
||||
|
||||
return {
|
||||
"server_info": async_redact_data(
|
||||
dataclass_to_dict(server_diagnostics.info), SERVER_INFO_TO_REDACT
|
||||
),
|
||||
"server_info": dataclass_to_dict(server_diagnostics.info),
|
||||
"node": redact_matter_attributes(
|
||||
remove_serialization_type(dataclass_to_dict(node.node_data) if node else {})
|
||||
),
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["matter-python-client==0.7.1"],
|
||||
"requirements": ["matter-python-client==0.6.0"],
|
||||
"zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."]
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ ABBREVIATIONS = {
|
||||
"mode_stat_t": "mode_state_topic",
|
||||
"mode_stat_tpl": "mode_state_template",
|
||||
"modes": "modes",
|
||||
"msg_exp_int": "message_expiry_interval",
|
||||
"name": "name",
|
||||
"o": "origin",
|
||||
"off_dly": "off_delay",
|
||||
|
||||
@@ -120,8 +120,6 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
DurationSelector,
|
||||
DurationSelectorConfig,
|
||||
FileSelector,
|
||||
FileSelectorConfig,
|
||||
NumberSelector,
|
||||
@@ -229,7 +227,6 @@ from .const import (
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_MAX,
|
||||
CONF_MAX_KELVIN,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_MIN,
|
||||
CONF_MIN_KELVIN,
|
||||
CONF_MODE_COMMAND_TEMPLATE,
|
||||
@@ -3724,11 +3721,6 @@ MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
default=DEFAULT_QOS,
|
||||
section="mqtt_settings",
|
||||
),
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL: PlatformField(
|
||||
selector=DurationSelector(DurationSelectorConfig(enable_day=True)),
|
||||
required=False,
|
||||
section="mqtt_settings",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ CONF_IMAGE_TOPIC = "image_topic"
|
||||
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
|
||||
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
|
||||
CONF_KEEPALIVE = "keepalive"
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL = "message_expiry_interval"
|
||||
CONF_ORIGIN = "origin"
|
||||
CONF_QOS = ATTR_QOS
|
||||
CONF_RETAIN = ATTR_RETAIN
|
||||
|
||||
@@ -17,7 +17,7 @@ from .models import DATA_MQTT, PublishPayloadType
|
||||
STORED_MESSAGES = 10
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass
|
||||
class TimestampedPublishMessage:
|
||||
"""MQTT Message."""
|
||||
|
||||
@@ -26,8 +26,6 @@ class TimestampedPublishMessage:
|
||||
qos: int
|
||||
retain: bool
|
||||
timestamp: float
|
||||
encoding: str | None
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
def log_message(
|
||||
@@ -37,8 +35,6 @@ def log_message(
|
||||
payload: PublishPayloadType,
|
||||
qos: int,
|
||||
retain: bool,
|
||||
encoding: str | None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Log an outgoing MQTT message."""
|
||||
entity_info = hass.data[DATA_MQTT].debug_info_entities.setdefault(
|
||||
@@ -49,13 +45,7 @@ def log_message(
|
||||
"messages": deque(maxlen=STORED_MESSAGES),
|
||||
}
|
||||
msg = TimestampedPublishMessage(
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
timestamp=time.monotonic(),
|
||||
encoding=encoding,
|
||||
kwargs=kwargs,
|
||||
topic, payload, qos, retain, timestamp=time.monotonic()
|
||||
)
|
||||
entity_info["transmitted"][topic]["messages"].append(msg)
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ from .const import (
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
CONF_JSON_ATTRS_TOPIC,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_QOS,
|
||||
@@ -95,6 +94,7 @@ from .const import (
|
||||
CONF_SW_VERSION,
|
||||
CONF_TOPIC,
|
||||
CONF_VIA_DEVICE,
|
||||
DEFAULT_ENCODING,
|
||||
DOMAIN,
|
||||
MQTT_CONNECTION_STATE,
|
||||
)
|
||||
@@ -153,8 +153,6 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"unit_of_measurement",
|
||||
}
|
||||
|
||||
PUBLISH_KWARGS = (CONF_MESSAGE_EXPIRY_INTERVAL,)
|
||||
|
||||
|
||||
@callback
|
||||
def async_handle_schema_error(
|
||||
@@ -1541,20 +1539,36 @@ class MqttEntity(
|
||||
await MqttDiscoveryUpdateMixin.async_will_remove_from_hass(self)
|
||||
debug_info.remove_entity_data(self.hass, self.entity_id)
|
||||
|
||||
async def async_publish(
|
||||
self,
|
||||
topic: str,
|
||||
payload: PublishPayloadType,
|
||||
qos: int = 0,
|
||||
retain: bool = False,
|
||||
encoding: str | None = DEFAULT_ENCODING,
|
||||
) -> None:
|
||||
"""Publish message to an MQTT topic."""
|
||||
log_message(self.hass, self.entity_id, topic, payload, qos, retain)
|
||||
await async_publish(
|
||||
self.hass,
|
||||
topic,
|
||||
payload,
|
||||
qos,
|
||||
retain,
|
||||
encoding,
|
||||
)
|
||||
|
||||
async def async_publish_with_config(
|
||||
self, topic: str, payload: PublishPayloadType
|
||||
) -> None:
|
||||
"""Publish payload to a topic using config."""
|
||||
kwargs: dict[str, Any] = {
|
||||
key: value for key, value in self._config.items() if key in PUBLISH_KWARGS
|
||||
}
|
||||
qos: int = self._config[CONF_QOS]
|
||||
retain: bool = self._config[CONF_RETAIN]
|
||||
encoding: str = self._config[CONF_ENCODING]
|
||||
log_message(
|
||||
self.hass, self.entity_id, topic, payload, qos, retain, encoding, **kwargs
|
||||
await self.async_publish(
|
||||
topic,
|
||||
payload,
|
||||
self._config[CONF_QOS],
|
||||
self._config[CONF_RETAIN],
|
||||
self._config[CONF_ENCODING],
|
||||
)
|
||||
await async_publish(self.hass, topic, payload, qos, retain, encoding, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
|
||||
@@ -509,20 +509,10 @@ class MqttComponentConfig:
|
||||
discovery_payload: MQTTDiscoveryPayload
|
||||
|
||||
|
||||
class MessageExpiryInterval(TypedDict, total=False):
|
||||
"""Hold the Message Expiry Interval."""
|
||||
|
||||
days: float
|
||||
hours: float
|
||||
minutes: float
|
||||
seconds: float
|
||||
|
||||
|
||||
class DeviceMqttOptions(TypedDict, total=False):
|
||||
"""Hold the shared MQTT specific options for an MQTT device."""
|
||||
|
||||
qos: int
|
||||
message_expiry_interval: MessageExpiryInterval
|
||||
|
||||
|
||||
class MqttDeviceData(TypedDict, total=False):
|
||||
|
||||
@@ -40,7 +40,6 @@ from .const import (
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
CONF_JSON_ATTRS_TOPIC,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_ORIGIN,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
@@ -67,7 +66,6 @@ SHARED_OPTIONS = [
|
||||
CONF_AVAILABILITY_TOPIC,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_MESSAGE_EXPIRY_INTERVAL,
|
||||
CONF_PAYLOAD_AVAILABLE,
|
||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
CONF_STATE_TOPIC,
|
||||
@@ -163,14 +161,6 @@ MQTT_ORIGIN_INFO_SCHEMA = vol.All(
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def valid_message_expiry_interval(value: Any) -> int:
|
||||
"""Return Message Expiry Interval in seconds."""
|
||||
if isinstance(value, int):
|
||||
return cv.positive_int(value) # type: ignore[no-any-return]
|
||||
return int(cv.positive_time_period_dict(value).total_seconds())
|
||||
|
||||
|
||||
MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
|
||||
@@ -182,7 +172,6 @@ MQTT_ENTITY_COMMON_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_DEFAULT_ENTITY_ID): cv.string,
|
||||
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
|
||||
vol.Optional(CONF_UNIQUE_ID): cv.string,
|
||||
}
|
||||
)
|
||||
@@ -214,7 +203,6 @@ DEVICE_DISCOVERY_SCHEMA = _MQTT_AVAILABILITY_SCHEMA.extend(
|
||||
vol.Required(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA,
|
||||
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_MESSAGE_EXPIRY_INTERVAL): valid_message_expiry_interval,
|
||||
vol.Optional(CONF_QOS): valid_qos_schema,
|
||||
vol.Optional(CONF_ENCODING): cv.string,
|
||||
}
|
||||
|
||||
@@ -197,11 +197,9 @@
|
||||
},
|
||||
"mqtt_settings": {
|
||||
"data": {
|
||||
"message_expiry_interval": "Message Expiry Interval",
|
||||
"qos": "QoS"
|
||||
},
|
||||
"data_description": {
|
||||
"message_expiry_interval": "Retention time interval for published message.",
|
||||
"qos": "The Quality of Service value the device's entities should use."
|
||||
},
|
||||
"name": "MQTT settings"
|
||||
|
||||
@@ -6,7 +6,6 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import ATTR_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -15,6 +14,7 @@ from .const import (
|
||||
ATTR_DESCRIPTION,
|
||||
ATTR_EXPIRES,
|
||||
ATTR_HEADLINE,
|
||||
ATTR_ID,
|
||||
ATTR_RECOMMENDED_ACTIONS,
|
||||
ATTR_SENDER,
|
||||
ATTR_SENT,
|
||||
|
||||
@@ -29,6 +29,8 @@ ATTR_SEVERITY: str = "severity"
|
||||
ATTR_RECOMMENDED_ACTIONS: str = "recommended_actions"
|
||||
ATTR_AFFECTED_AREAS: str = "affected_areas"
|
||||
ATTR_WEB: str = "web"
|
||||
# pylint: disable-next=home-assistant-duplicate-const
|
||||
ATTR_ID: str = "id"
|
||||
ATTR_SENT: str = "sent"
|
||||
ATTR_START: str = "start"
|
||||
ATTR_EXPIRES: str = "expires"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
|
||||
from rf_protocols.commands.novy import NovyCookerHoodCommand
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import (
|
||||
@@ -128,10 +128,8 @@ class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Toggle the hood light on then off so it ends in its starting state."""
|
||||
assert self._transmitter_entity_id is not None
|
||||
command = NovyCookerHoodCommand(channel=self._code, key=COMMAND_LIGHT)
|
||||
try:
|
||||
command = await get_codes_for_code(self._code).async_load_command(
|
||||
COMMAND_LIGHT
|
||||
)
|
||||
await async_send_command(self.hass, self._transmitter_entity_id, command)
|
||||
await asyncio.sleep(_TOGGLE_GAP)
|
||||
await async_send_command(self.hass, self._transmitter_entity_id, command)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Fan platform for the Novy Cooker Hood (calibrated speed control)."""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
|
||||
from rf_protocols.commands.novy import NovyCookerHoodCommand
|
||||
|
||||
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
@@ -25,6 +26,9 @@ PARALLEL_UPDATES = 1
|
||||
|
||||
_SPEED_RANGE = (1, SPEED_COUNT)
|
||||
|
||||
# Novy hood expects at least 150ms between RF commands
|
||||
_COMMAND_DELAY = 0.2
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -49,7 +53,7 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the fan."""
|
||||
super().__init__(entry)
|
||||
self._codes = get_codes_for_code(entry.data[CONF_CODE])
|
||||
self._code: int = entry.data[CONF_CODE]
|
||||
self._level = 0
|
||||
self._attr_unique_id = entry.entry_id
|
||||
|
||||
@@ -103,18 +107,16 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
|
||||
async def async_increase_speed(self, percentage_step: int | None = None) -> None:
|
||||
"""Bump speed up by N hardware levels (no recalibration)."""
|
||||
steps = self._steps_from_percentage(percentage_step)
|
||||
plus = await self._codes.async_load_command(COMMAND_PLUS)
|
||||
for _ in range(steps):
|
||||
await self._async_send(plus)
|
||||
plus = NovyCookerHoodCommand(channel=self._code, key=COMMAND_PLUS)
|
||||
await self._async_send_repeated(plus, steps)
|
||||
self._level = min(SPEED_COUNT, self._level + steps)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_decrease_speed(self, percentage_step: int | None = None) -> None:
|
||||
"""Bump speed down by N hardware levels (no recalibration)."""
|
||||
steps = self._steps_from_percentage(percentage_step)
|
||||
minus = await self._codes.async_load_command(COMMAND_MINUS)
|
||||
for _ in range(steps):
|
||||
await self._async_send(minus)
|
||||
minus = NovyCookerHoodCommand(channel=self._code, key=COMMAND_MINUS)
|
||||
await self._async_send_repeated(minus, steps)
|
||||
self._level = max(0, self._level - steps)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -127,17 +129,25 @@ class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
|
||||
|
||||
async def _async_set_level(self, level: int) -> None:
|
||||
"""Reset to off with `SPEED_COUNT` minus presses, then climb to level."""
|
||||
minus = await self._codes.async_load_command(COMMAND_MINUS)
|
||||
for _ in range(SPEED_COUNT):
|
||||
await self._async_send(minus)
|
||||
minus = NovyCookerHoodCommand(channel=self._code, key=COMMAND_MINUS)
|
||||
await self._async_send_repeated(minus, SPEED_COUNT)
|
||||
if level > 0:
|
||||
plus = await self._codes.async_load_command(COMMAND_PLUS)
|
||||
for _ in range(level):
|
||||
await self._async_send(plus)
|
||||
await asyncio.sleep(_COMMAND_DELAY)
|
||||
plus = NovyCookerHoodCommand(channel=self._code, key=COMMAND_PLUS)
|
||||
await self._async_send_repeated(plus, level)
|
||||
self._level = level
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send(self, command: Any) -> None:
|
||||
async def _async_send_repeated(
|
||||
self, command: NovyCookerHoodCommand, count: int
|
||||
) -> None:
|
||||
"""Send the same RF command N times, pausing between presses."""
|
||||
for i in range(count):
|
||||
if i > 0:
|
||||
await asyncio.sleep(_COMMAND_DELAY)
|
||||
await self._async_send(command)
|
||||
|
||||
async def _async_send(self, command: NovyCookerHoodCommand) -> None:
|
||||
"""Send a single RF command via the configured transmitter."""
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rf_protocols.codes.novy.cooker_hood import get_codes_for_code
|
||||
from rf_protocols.commands.novy import NovyCookerHoodCommand
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
@@ -37,7 +37,7 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(entry)
|
||||
self._codes = get_codes_for_code(entry.data[CONF_CODE])
|
||||
self._code = entry.data[CONF_CODE]
|
||||
self._attr_unique_id = entry.entry_id
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
@@ -58,9 +58,9 @@ class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send_command(self, name: str) -> None:
|
||||
"""Load the named command and send it via the configured transmitter."""
|
||||
command = await self._codes.async_load_command(name)
|
||||
async def _async_send_command(self, key: str) -> None:
|
||||
"""Build the named command and send it via the configured transmitter."""
|
||||
command = NovyCookerHoodCommand(channel=self._code, key=key)
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
)
|
||||
|
||||
@@ -37,15 +37,11 @@ class OpenhomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("async_step_ssdp: Incomplete discovery, ignoring")
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
|
||||
udn = discovery_info.upnp[ATTR_UPNP_UDN]
|
||||
if isinstance(udn, list):
|
||||
if not udn:
|
||||
return self.async_abort(reason="incomplete_discovery")
|
||||
udn = udn[0]
|
||||
_LOGGER.debug(
|
||||
"async_step_ssdp: setting unique id %s", discovery_info.upnp[ATTR_UPNP_UDN]
|
||||
)
|
||||
|
||||
_LOGGER.debug("async_step_ssdp: setting unique id %s", udn)
|
||||
|
||||
await self.async_set_unique_id(udn)
|
||||
await self.async_set_unique_id(discovery_info.upnp[ATTR_UPNP_UDN])
|
||||
self._abort_if_unique_id_configured({CONF_HOST: discovery_info.ssdp_location})
|
||||
|
||||
_LOGGER.debug(
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.20.0"]
|
||||
"requirements": ["reolink-aio==0.19.1"]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""The System Bridge integration."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from systembridgeconnector.exceptions import (
|
||||
AuthenticationException,
|
||||
@@ -9,34 +11,71 @@ from systembridgeconnector.exceptions import (
|
||||
ConnectionErrorException,
|
||||
DataMissingException,
|
||||
)
|
||||
from systembridgeconnector.models.keyboard_key import KeyboardKey
|
||||
from systembridgeconnector.models.keyboard_text import KeyboardText
|
||||
from systembridgeconnector.models.modules.processes import Process
|
||||
from systembridgeconnector.models.open_path import OpenPath
|
||||
from systembridgeconnector.models.open_url import OpenUrl
|
||||
from systembridgeconnector.version import Version
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_COMMAND,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_PATH,
|
||||
CONF_PORT,
|
||||
CONF_TOKEN,
|
||||
CONF_URL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
)
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .config_flow import SystemBridgeConfigFlow
|
||||
from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES
|
||||
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, entry_id: str
|
||||
) -> SystemBridgeDataUpdateCoordinator:
|
||||
"""Return the coordinator for a config entry id."""
|
||||
entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
entry_id
|
||||
)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": entry_id},
|
||||
)
|
||||
return entry.runtime_data
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.MEDIA_PLAYER,
|
||||
@@ -45,12 +84,26 @@ PLATFORMS = [
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
CONF_BRIDGE = "bridge"
|
||||
CONF_KEY = "key"
|
||||
CONF_TEXT = "text"
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the System Bridge services."""
|
||||
SERVICE_GET_PROCESS_BY_ID = "get_process_by_id"
|
||||
SERVICE_GET_PROCESSES_BY_NAME = "get_processes_by_name"
|
||||
SERVICE_OPEN_PATH = "open_path"
|
||||
SERVICE_POWER_COMMAND = "power_command"
|
||||
SERVICE_OPEN_URL = "open_url"
|
||||
SERVICE_SEND_KEYPRESS = "send_keypress"
|
||||
SERVICE_SEND_TEXT = "send_text"
|
||||
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
POWER_COMMAND_MAP = {
|
||||
"hibernate": "power_hibernate",
|
||||
"lock": "power_lock",
|
||||
"logout": "power_logout",
|
||||
"restart": "power_restart",
|
||||
"shutdown": "power_shutdown",
|
||||
"sleep": "power_sleep",
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -178,6 +231,219 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL):
|
||||
return True
|
||||
|
||||
def valid_device(device: str) -> str:
|
||||
"""Check device is valid."""
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device)
|
||||
if device_entry is not None:
|
||||
try:
|
||||
return next(
|
||||
entry.entry_id
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
)
|
||||
except StopIteration as exception:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device},
|
||||
) from exception
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device},
|
||||
)
|
||||
|
||||
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the get process by id service call."""
|
||||
_LOGGER.debug("Get process by id: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
processes: list[Process] = coordinator.data.processes
|
||||
|
||||
# Find process.id from list, raise ServiceValidationError if not found
|
||||
try:
|
||||
return asdict(
|
||||
next(
|
||||
process
|
||||
for process in processes
|
||||
if process.id == service_call.data[CONF_ID]
|
||||
)
|
||||
)
|
||||
except StopIteration as exception:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="process_not_found",
|
||||
translation_placeholders={"id": service_call.data[CONF_ID]},
|
||||
) from exception
|
||||
|
||||
async def handle_get_processes_by_name(
|
||||
service_call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handle the get process by name service call."""
|
||||
_LOGGER.debug("Get process by name: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
|
||||
# Find processes from list
|
||||
items: list[dict[str, Any]] = [
|
||||
asdict(process)
|
||||
for process in coordinator.data.processes
|
||||
if process.name is not None
|
||||
and service_call.data[CONF_NAME].lower() in process.name.lower()
|
||||
]
|
||||
|
||||
return {
|
||||
"count": len(items),
|
||||
"processes": list(items),
|
||||
}
|
||||
|
||||
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open path service call."""
|
||||
_LOGGER.debug("Open path: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_path(
|
||||
OpenPath(path=service_call.data[CONF_PATH])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the power command service call."""
|
||||
_LOGGER.debug("Power command: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await getattr(
|
||||
coordinator.websocket_client,
|
||||
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
|
||||
)()
|
||||
return asdict(response)
|
||||
|
||||
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open url service call."""
|
||||
_LOGGER.debug("Open URL: %s", service_call.data)
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_url(
|
||||
OpenUrl(url=service_call.data[CONF_URL])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_keypress(
|
||||
KeyboardKey(key=service_call.data[CONF_KEY])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_text(
|
||||
KeyboardText(text=service_call.data[CONF_TEXT])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_PROCESS_BY_ID,
|
||||
handle_get_process_by_id,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_ID): cv.positive_int,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_PROCESSES_BY_NAME,
|
||||
handle_get_processes_by_name,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_OPEN_PATH,
|
||||
handle_open_path,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_PATH): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_POWER_COMMAND,
|
||||
handle_power_command,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_OPEN_URL,
|
||||
handle_open_url,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_URL): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_KEYPRESS,
|
||||
handle_send_keypress,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_KEY): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
description_placeholders={
|
||||
"syntax_keys_documentation_url": "http://robotjs.io/docs/syntax#keys"
|
||||
},
|
||||
)
|
||||
|
||||
# pylint: disable-next=home-assistant-service-registered-in-setup-entry
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_TEXT,
|
||||
handle_send_text,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): valid_device,
|
||||
vol.Required(CONF_TEXT): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
# Reload entry when its updated.
|
||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||
|
||||
@@ -188,7 +454,9 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: SystemBridgeConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
|
||||
)
|
||||
if unload_ok:
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
"""Service registration for System Bridge integration."""
|
||||
|
||||
from dataclasses import asdict
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from systembridgeconnector.models.keyboard_key import KeyboardKey
|
||||
from systembridgeconnector.models.keyboard_text import KeyboardText
|
||||
from systembridgeconnector.models.modules.processes import Process
|
||||
from systembridgeconnector.models.open_path import OpenPath
|
||||
from systembridgeconnector.models.open_url import OpenUrl
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_COMMAND, CONF_ID, CONF_NAME, CONF_PATH, CONF_URL
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
ServiceCall,
|
||||
ServiceResponse,
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
service,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_BRIDGE = "bridge"
|
||||
CONF_KEY = "key"
|
||||
CONF_TEXT = "text"
|
||||
|
||||
POWER_COMMAND_MAP = {
|
||||
"hibernate": "power_hibernate",
|
||||
"lock": "power_lock",
|
||||
"logout": "power_logout",
|
||||
"restart": "power_restart",
|
||||
"shutdown": "power_shutdown",
|
||||
"sleep": "power_sleep",
|
||||
}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for System Bridge integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_process_by_id",
|
||||
handle_get_process_by_id,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_ID): cv.positive_int,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"get_processes_by_name",
|
||||
handle_get_processes_by_name,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"open_path",
|
||||
handle_open_path,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_PATH): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"power_command",
|
||||
handle_power_command,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_COMMAND): vol.In(POWER_COMMAND_MAP),
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"open_url",
|
||||
handle_open_url,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_URL): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"send_keypress",
|
||||
handle_send_keypress,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_KEY): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
description_placeholders={
|
||||
"syntax_keys_documentation_url": "https://robotjs.dev/docs/syntax#keys"
|
||||
},
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
"send_text",
|
||||
handle_send_text,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_BRIDGE): cv.string,
|
||||
vol.Required(CONF_TEXT): cv.string,
|
||||
},
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
def _get_coordinator(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> SystemBridgeDataUpdateCoordinator:
|
||||
"""Return the coordinator for a device id."""
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device_id},
|
||||
)
|
||||
try:
|
||||
entry_id = next(
|
||||
entry.entry_id
|
||||
for entry in hass.config_entries.async_entries(DOMAIN)
|
||||
if entry.entry_id in device_entry.config_entries
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_not_found",
|
||||
translation_placeholders={"device": device_id},
|
||||
) from e
|
||||
entry: SystemBridgeConfigEntry = service.async_get_config_entry(
|
||||
hass, DOMAIN, entry_id
|
||||
)
|
||||
return entry.runtime_data
|
||||
|
||||
|
||||
async def handle_get_process_by_id(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the get process by id service call."""
|
||||
_LOGGER.debug("Get process by id: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
processes: list[Process] = coordinator.data.processes
|
||||
|
||||
# Find process.id from list, raise ServiceValidationError if not found
|
||||
try:
|
||||
return asdict(
|
||||
next(
|
||||
process
|
||||
for process in processes
|
||||
if process.id == service_call.data[CONF_ID]
|
||||
)
|
||||
)
|
||||
except StopIteration as e:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="process_not_found",
|
||||
translation_placeholders={"id": service_call.data[CONF_ID]},
|
||||
) from e
|
||||
|
||||
|
||||
async def handle_get_processes_by_name(
|
||||
service_call: ServiceCall,
|
||||
) -> ServiceResponse:
|
||||
"""Handle the get process by name service call."""
|
||||
_LOGGER.debug("Get process by name: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
|
||||
# Find processes from list
|
||||
items: list[dict[str, Any]] = [
|
||||
asdict(process)
|
||||
for process in coordinator.data.processes
|
||||
if process.name is not None
|
||||
and service_call.data[CONF_NAME].lower() in process.name.lower()
|
||||
]
|
||||
|
||||
return {
|
||||
"count": len(items),
|
||||
"processes": list(items),
|
||||
}
|
||||
|
||||
|
||||
async def handle_open_path(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open path service call."""
|
||||
_LOGGER.debug("Open path: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_path(
|
||||
OpenPath(path=service_call.data[CONF_PATH])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_power_command(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the power command service call."""
|
||||
_LOGGER.debug("Power command: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await getattr(
|
||||
coordinator.websocket_client,
|
||||
POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]],
|
||||
)()
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_open_url(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the open url service call."""
|
||||
_LOGGER.debug("Open URL: %s", service_call.data)
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.open_url(
|
||||
OpenUrl(url=service_call.data[CONF_URL])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_keypress service call."""
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_keypress(
|
||||
KeyboardKey(key=service_call.data[CONF_KEY])
|
||||
)
|
||||
return asdict(response)
|
||||
|
||||
|
||||
async def handle_send_text(service_call: ServiceCall) -> ServiceResponse:
|
||||
"""Handle the send_text service call."""
|
||||
coordinator = _get_coordinator(service_call.hass, service_call.data[CONF_BRIDGE])
|
||||
response = await coordinator.websocket_client.keyboard_text(
|
||||
KeyboardText(text=service_call.data[CONF_TEXT])
|
||||
)
|
||||
return asdict(response)
|
||||
@@ -89,4 +89,3 @@ power_command:
|
||||
- "restart"
|
||||
- "shutdown"
|
||||
- "sleep"
|
||||
translation_key: "power_command"
|
||||
|
||||
@@ -178,18 +178,6 @@
|
||||
"title": "System Bridge upgrade required"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"power_command": {
|
||||
"options": {
|
||||
"hibernate": "Hibernate",
|
||||
"lock": "Lock",
|
||||
"logout": "Logout",
|
||||
"restart": "[%key:common::action::restart%]",
|
||||
"shutdown": "Shutdown",
|
||||
"sleep": "Sleep"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_process_by_id": {
|
||||
"description": "Gets a process by the ID.",
|
||||
|
||||
@@ -21,4 +21,4 @@ CONF_INSTANCE_ID = "instance_id"
|
||||
# Polling interval (seconds)
|
||||
DEFAULT_SCAN_INTERVAL = 1800
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.LOCK, Platform.SWITCH]
|
||||
PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SWITCH]
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Lock platform for Xthings Cloud."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.lock import LockEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import XthingsCloudConfigEntry
|
||||
from .entity import XthingsCloudEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: XthingsCloudConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up lock platform."""
|
||||
coordinator = entry.runtime_data
|
||||
entities = [
|
||||
XthingsCloudLock(coordinator, device_id, device_data)
|
||||
for device_id, device_data in coordinator.data.items()
|
||||
if device_data["type"] == "lock"
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class XthingsCloudLock(XthingsCloudEntity, LockEntity):
|
||||
"""Xthings Cloud lock entity."""
|
||||
|
||||
@property
|
||||
def is_locked(self) -> bool | None:
|
||||
"""Return true if lock is locked."""
|
||||
return self.device_data["status"].get("locked")
|
||||
|
||||
@property
|
||||
def is_jammed(self) -> bool | None:
|
||||
"""Return true if lock is jammed."""
|
||||
return self.device_data["status"].get("jammed")
|
||||
|
||||
async def async_lock(self, **kwargs: Any) -> None:
|
||||
"""Lock the device."""
|
||||
await self.coordinator.client.async_lock_lock(self._device_id)
|
||||
|
||||
async def async_unlock(self, **kwargs: Any) -> None:
|
||||
"""Unlock the device."""
|
||||
await self.coordinator.client.async_lock_unlock(self._device_id)
|
||||
Generated
-1
@@ -59,7 +59,6 @@ FLOWS = {
|
||||
"amberelectric",
|
||||
"ambient_network",
|
||||
"ambient_station",
|
||||
"analytics",
|
||||
"analytics_insights",
|
||||
"android_ip_webcam",
|
||||
"androidtv",
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Checker for invalid MDI icon references.
|
||||
|
||||
Validates that ``mdi:`` icon references in integration code and
|
||||
``icons.json`` files refer to icons that actually exist in the
|
||||
Material Design Icons set.
|
||||
|
||||
- ``E7409``: MDI icon reference not found in Python code
|
||||
- ``E7410``: MDI icon reference not found in icons.json
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from astroid import nodes
|
||||
from pylint.checkers import BaseChecker
|
||||
from pylint.lint import PyLinter
|
||||
|
||||
from pylint_home_assistant.generated.mdi_icons import MDI_ICONS
|
||||
from pylint_home_assistant.helpers.icons import collect_mdi_icons, load_icons
|
||||
from pylint_home_assistant.helpers.module_info import parse_module
|
||||
|
||||
# Matches strings that look like intentional icon name attempts
|
||||
# (letters, digits, hyphens, underscores). Rejects format templates
|
||||
# (%s, {}, {name}), empty names, and other dynamic patterns.
|
||||
_LOOKS_LIKE_ICON_NAME = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*[a-zA-Z0-9]$")
|
||||
|
||||
|
||||
class MdiIconsChecker(BaseChecker):
|
||||
"""Checker for invalid MDI icon references."""
|
||||
|
||||
name = "home_assistant_mdi_icons"
|
||||
priority = -1
|
||||
msgs = {
|
||||
"E7409": (
|
||||
"MDI icon '%s' does not exist in the Material Design Icons set",
|
||||
"home-assistant-mdi-icon-not-found",
|
||||
"Used when an integration references an MDI icon in Python "
|
||||
"code that does not exist. Check the icon name at "
|
||||
"https://pictogrammers.com/library/mdi/",
|
||||
),
|
||||
"E7410": (
|
||||
"MDI icon '%s' in icons.json does not exist in the "
|
||||
"Material Design Icons set",
|
||||
"home-assistant-mdi-icon-json-not-found",
|
||||
"Used when an integration's icons.json references an MDI "
|
||||
"icon that does not exist. Check the icon name at "
|
||||
"https://pictogrammers.com/library/mdi/",
|
||||
),
|
||||
}
|
||||
options = ()
|
||||
|
||||
_in_integration: bool
|
||||
_checked_icons_json: set[str]
|
||||
|
||||
def open(self) -> None:
|
||||
"""Initialize per-run state."""
|
||||
self._checked_icons_json = set()
|
||||
|
||||
def visit_module(self, node: nodes.Module) -> None:
|
||||
"""Check icons.json and track integration context."""
|
||||
parsed = parse_module(node.name)
|
||||
self._in_integration = parsed is not None
|
||||
if parsed is None:
|
||||
return
|
||||
|
||||
# Only check icons.json once per integration
|
||||
if parsed.domain in self._checked_icons_json:
|
||||
return
|
||||
self._checked_icons_json.add(parsed.domain)
|
||||
|
||||
icons_data = load_icons(node)
|
||||
if icons_data is None:
|
||||
return
|
||||
|
||||
mdi_refs = collect_mdi_icons(icons_data)
|
||||
for icon_ref in sorted(mdi_refs):
|
||||
icon_name = icon_ref[4:] # Strip "mdi:" prefix
|
||||
if icon_name not in MDI_ICONS:
|
||||
self.add_message(
|
||||
"home-assistant-mdi-icon-json-not-found",
|
||||
node=node,
|
||||
args=(icon_ref,),
|
||||
)
|
||||
|
||||
def visit_const(self, node: nodes.Const) -> None:
|
||||
"""Check string constants for invalid MDI icon references."""
|
||||
if not self._in_integration:
|
||||
return
|
||||
|
||||
if not isinstance(node.value, str):
|
||||
return
|
||||
|
||||
if not node.value.startswith("mdi:"):
|
||||
return
|
||||
|
||||
icon_name = node.value[4:] # Strip "mdi:" prefix
|
||||
|
||||
# Only check names that look like intentional icon name attempts.
|
||||
# This skips f-string fragments, format templates (%s, {}),
|
||||
# partial names, and other dynamic patterns.
|
||||
if not _LOOKS_LIKE_ICON_NAME.match(icon_name):
|
||||
return
|
||||
|
||||
if icon_name not in MDI_ICONS:
|
||||
self.add_message(
|
||||
"home-assistant-mdi-icon-not-found",
|
||||
node=node,
|
||||
args=(node.value,),
|
||||
)
|
||||
|
||||
|
||||
def register(linter: PyLinter) -> None:
|
||||
"""Register the checker."""
|
||||
linter.register_checker(MdiIconsChecker(linter))
|
||||
@@ -1 +0,0 @@
|
||||
"""Generated files for the pylint Home Assistant plugin."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,60 +0,0 @@
|
||||
"""Helpers for reading integration icon files."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from astroid import nodes
|
||||
import orjson
|
||||
|
||||
from .integration import get_integration_dir
|
||||
|
||||
_icons_cache: dict[str, dict | None] = {}
|
||||
|
||||
|
||||
def clear_icons_cache() -> None:
|
||||
"""Clear the icons cache (used by tests)."""
|
||||
_icons_cache.clear()
|
||||
|
||||
|
||||
def load_icons(module: nodes.Module) -> dict | None:
|
||||
"""Load and cache the icons.json for the current integration.
|
||||
|
||||
Returns the parsed JSON as a dict, or ``None`` if not found.
|
||||
"""
|
||||
integration_dir = get_integration_dir(module)
|
||||
if integration_dir is None:
|
||||
return None
|
||||
|
||||
cache_key = str(integration_dir)
|
||||
if cache_key in _icons_cache:
|
||||
return _icons_cache[cache_key]
|
||||
|
||||
icons_path = integration_dir / "icons.json"
|
||||
result: dict | None = None
|
||||
if icons_path.exists():
|
||||
with contextlib.suppress(orjson.JSONDecodeError, OSError):
|
||||
parsed = orjson.loads(icons_path.read_bytes())
|
||||
if isinstance(parsed, dict):
|
||||
result = parsed
|
||||
|
||||
_icons_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
|
||||
def collect_mdi_icons(
|
||||
data: dict | list | str, icons: set[str] | None = None
|
||||
) -> set[str]:
|
||||
"""Recursively collect all mdi: icon references from a data structure."""
|
||||
if icons is None:
|
||||
icons = set()
|
||||
|
||||
if isinstance(data, str):
|
||||
if data.startswith("mdi:"):
|
||||
icons.add(data)
|
||||
elif isinstance(data, dict):
|
||||
for value in data.values():
|
||||
collect_mdi_icons(value, icons)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
collect_mdi_icons(item, icons)
|
||||
|
||||
return icons
|
||||
Generated
+3
-3
@@ -1516,7 +1516,7 @@ lxml==6.0.1
|
||||
matrix-nio==0.25.2
|
||||
|
||||
# homeassistant.components.matter
|
||||
matter-python-client==0.7.1
|
||||
matter-python-client==0.6.0
|
||||
|
||||
# homeassistant.components.maxcube
|
||||
maxcube-api==0.4.3
|
||||
@@ -2641,7 +2641,7 @@ python-google-weather-api==0.0.6
|
||||
python-homeassistant-analytics==0.9.0
|
||||
|
||||
# homeassistant.components.homewizard
|
||||
python-homewizard-energy==10.1.0
|
||||
python-homewizard-energy==10.0.1
|
||||
|
||||
# homeassistant.components.hp_ilo
|
||||
python-hpilo==4.4.3
|
||||
@@ -2871,7 +2871,7 @@ renault-api==0.5.10
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.20.0
|
||||
reolink-aio==0.19.1
|
||||
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==3.2.0
|
||||
|
||||
@@ -23,7 +23,6 @@ from . import (
|
||||
json,
|
||||
labs,
|
||||
manifest,
|
||||
mdi_icons,
|
||||
metadata,
|
||||
mqtt,
|
||||
mypy_config,
|
||||
@@ -66,7 +65,6 @@ INTEGRATION_PLUGINS = [
|
||||
HASS_PLUGINS = [
|
||||
core_files,
|
||||
docker,
|
||||
mdi_icons,
|
||||
mypy_config,
|
||||
metadata,
|
||||
]
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Generate MDI icons file for the pylint plugin."""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from importlib.resources import files
|
||||
import json
|
||||
|
||||
from .model import Config, Integration
|
||||
from .serializer import format_python_namespace
|
||||
|
||||
_TARGET = "pylint/plugins/pylint_home_assistant/generated/mdi_icons.py"
|
||||
|
||||
|
||||
def _get_frontend_version() -> str | None:
|
||||
"""Get the installed home-assistant-frontend version."""
|
||||
try:
|
||||
return version("home-assistant-frontend")
|
||||
except PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _load_mdi_icons() -> set[str]:
|
||||
"""Load the MDI icon names from the frontend package."""
|
||||
try:
|
||||
mdi_dir = files("hass_frontend") / "static" / "mdi"
|
||||
icon_list_path = mdi_dir / "iconList.json"
|
||||
data = json.loads(icon_list_path.read_text(encoding="utf-8"))
|
||||
return {icon["name"] for icon in data}
|
||||
except ImportError, FileNotFoundError, json.JSONDecodeError, KeyError:
|
||||
return set()
|
||||
|
||||
|
||||
def validate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Validate the generated MDI icons file is up to date."""
|
||||
frontend_version = _get_frontend_version()
|
||||
if frontend_version is None:
|
||||
return
|
||||
|
||||
icons = _load_mdi_icons()
|
||||
if not icons:
|
||||
config.add_error(
|
||||
"mdi_icons",
|
||||
"Could not load MDI icons from home-assistant-frontend",
|
||||
)
|
||||
return
|
||||
|
||||
content = format_python_namespace(
|
||||
{
|
||||
"FRONTEND_VERSION": frontend_version,
|
||||
"MDI_ICONS": icons,
|
||||
},
|
||||
annotations={
|
||||
"FRONTEND_VERSION": "Final[str]",
|
||||
"MDI_ICONS": "Final[set[str]]",
|
||||
},
|
||||
)
|
||||
|
||||
config.cache["mdi_icons_content"] = content
|
||||
|
||||
if config.specific_integrations:
|
||||
return
|
||||
|
||||
target_path = config.root / _TARGET
|
||||
if not target_path.exists() or target_path.read_text() != content:
|
||||
config.add_error(
|
||||
"mdi_icons",
|
||||
f"File {_TARGET} is not up to date. Run python3 -m script.hassfest",
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
|
||||
def generate(integrations: dict[str, Integration], config: Config) -> None:
|
||||
"""Generate MDI icons file."""
|
||||
if "mdi_icons_content" not in config.cache:
|
||||
return
|
||||
target_path = config.root / _TARGET
|
||||
target_path.write_text(config.cache["mdi_icons_content"])
|
||||
@@ -6,18 +6,15 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.analytics import CONF_SNAPSHOTS_URL, LABS_SNAPSHOT_FEATURE
|
||||
from homeassistant.components.analytics import LABS_SNAPSHOT_FEATURE
|
||||
from homeassistant.components.analytics.const import (
|
||||
BASIC_ENDPOINT_URL,
|
||||
BASIC_ENDPOINT_URL_DEV,
|
||||
DOMAIN,
|
||||
SNAPSHOT_DEFAULT_URL,
|
||||
SNAPSHOT_URL_PATH,
|
||||
STORAGE_KEY,
|
||||
)
|
||||
from homeassistant.components.hassio import HassioNotReadyError
|
||||
from homeassistant.components.labs import async_update_preview_feature
|
||||
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
|
||||
@@ -40,175 +37,6 @@ async def test_setup(hass: HomeAssistant) -> None:
|
||||
assert DOMAIN in hass.data
|
||||
|
||||
|
||||
async def test_setup_with_snapshots_url(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test setup with snapshots_url in YAML config sends snapshots to that URL."""
|
||||
custom_url = "https://custom-snapshot-endpoint.example.com"
|
||||
snapshot_endpoint = custom_url + SNAPSHOT_URL_PATH
|
||||
aioclient_mock.post(snapshot_endpoint, status=200, json={})
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.analytics.analytics._async_snapshot_payload",
|
||||
return_value={"mock": {}},
|
||||
):
|
||||
assert await async_setup_component(hass, "labs", {})
|
||||
assert await async_setup_component(
|
||||
hass, DOMAIN, {DOMAIN: {CONF_SNAPSHOTS_URL: custom_url}}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json_auto_id(
|
||||
{"type": "analytics/preferences", "preferences": {"snapshots": True}}
|
||||
)
|
||||
assert (await ws_client.receive_json())["success"]
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=25))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert any(str(call[1]) == snapshot_endpoint for call in aioclient_mock.mock_calls)
|
||||
|
||||
|
||||
async def test_setup_entry_supervisor_not_ready(hass: HomeAssistant) -> None:
|
||||
"""Test that HassioNotReadyError raises ConfigEntryNotReady."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.analytics.analytics.is_hassio",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_supervisor_info",
|
||||
side_effect=HassioNotReadyError,
|
||||
),
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_schedule_starts_and_sends_analytics(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test that the analytics schedule fires and sends analytics after time travel."""
|
||||
aioclient_mock.post(BASIC_ENDPOINT_URL, status=200)
|
||||
aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200)
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION):
|
||||
await ws_client.send_json_auto_id(
|
||||
{"type": "analytics/preferences", "preferences": {"base": True}}
|
||||
)
|
||||
assert (await ws_client.receive_json())["success"]
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 0
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
hass_storage: dict[str, Any],
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test that unloading the config entry stops the analytics schedule."""
|
||||
aioclient_mock.post(BASIC_ENDPOINT_URL, status=200)
|
||||
aioclient_mock.post(BASIC_ENDPOINT_URL_DEV, status=200)
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
with patch("homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION):
|
||||
await ws_client.send_json_auto_id(
|
||||
{"type": "analytics/preferences", "preferences": {"base": True}}
|
||||
)
|
||||
await ws_client.receive_json()
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=901))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 1
|
||||
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(days=2))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(aioclient_mock.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_websocket_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test websocket returns error when analytics entry failed to load."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.analytics.analytics.is_hassio",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_supervisor_info",
|
||||
side_effect=HassioNotReadyError,
|
||||
),
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json_auto_id({"type": "analytics"})
|
||||
response = await ws_client.receive_json()
|
||||
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "not_found"
|
||||
|
||||
|
||||
async def test_websocket_preferences_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test preferences websocket returns error when analytics entry failed to load."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.analytics.analytics.is_hassio",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.hassio.get_supervisor_info",
|
||||
side_effect=HassioNotReadyError,
|
||||
),
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
ws_client = await hass_ws_client(hass)
|
||||
await ws_client.send_json_auto_id(
|
||||
{"type": "analytics/preferences", "preferences": {"base": True}}
|
||||
)
|
||||
response = await ws_client.receive_json()
|
||||
|
||||
assert not response["success"]
|
||||
assert response["error"]["code"] == "not_found"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_snapshot_payload")
|
||||
async def test_labs_feature_toggle(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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
|
||||
@@ -66,66 +65,3 @@ 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",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"product_type": "HWE-P1",
|
||||
"product_name": "P1 meter",
|
||||
"serial": "5c2fafabcdef",
|
||||
"firmware_version": "4.19",
|
||||
"api_version": "2.3.0"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"cloud_enabled": true
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
# serializer version: 1
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy]
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Device Battery group charging strategy',
|
||||
'friendly_name': 'Device Battery group mode',
|
||||
'options': list([
|
||||
'standby',
|
||||
'to_full',
|
||||
@@ -10,14 +10,14 @@
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.device_battery_group_charging_strategy',
|
||||
'entity_id': 'select.device_battery_group_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'zero',
|
||||
})
|
||||
# ---
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_charging_strategy].1
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].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_charging_strategy',
|
||||
'entity_id': 'select.device_battery_group_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -45,12 +45,12 @@
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Battery group charging strategy',
|
||||
'object_id_base': 'Battery group mode',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Battery group charging strategy',
|
||||
'original_name': 'Battery group mode',
|
||||
'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_charging_strategy].2
|
||||
# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].2
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
|
||||
@@ -798,7 +798,7 @@
|
||||
'object_id_base': 'Uptime',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.UPTIME: 'uptime'>,
|
||||
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
|
||||
'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': 'uptime',
|
||||
'device_class': 'timestamp',
|
||||
'friendly_name': 'Device Uptime',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -33,31 +33,31 @@ pytestmark = [
|
||||
(
|
||||
"HWE-WTR",
|
||||
[
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"select.device_battery_group_mode",
|
||||
],
|
||||
),
|
||||
(
|
||||
"SDM230",
|
||||
[
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"select.device_battery_group_mode",
|
||||
],
|
||||
),
|
||||
(
|
||||
"SDM630",
|
||||
[
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"select.device_battery_group_mode",
|
||||
],
|
||||
),
|
||||
(
|
||||
"HWE-KWH1",
|
||||
[
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"select.device_battery_group_mode",
|
||||
],
|
||||
),
|
||||
(
|
||||
"HWE-KWH3",
|
||||
[
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"select.device_battery_group_mode",
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -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_charging_strategy"),
|
||||
("HWE-P1", "select.device_battery_group_mode"),
|
||||
],
|
||||
)
|
||||
async def test_select_entity_snapshots(
|
||||
@@ -101,28 +101,17 @@ async def test_select_entity_snapshots(
|
||||
[
|
||||
(
|
||||
"HWE-P1",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"select.device_battery_group_mode",
|
||||
"standby",
|
||||
Batteries.Mode.STANDBY,
|
||||
),
|
||||
(
|
||||
"HWE-P1",
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"select.device_battery_group_mode",
|
||||
"to_full",
|
||||
Batteries.Mode.TO_FULL,
|
||||
),
|
||||
(
|
||||
"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,
|
||||
),
|
||||
("HWE-P1", "select.device_battery_group_mode", "zero", Batteries.Mode.ZERO),
|
||||
],
|
||||
)
|
||||
async def test_select_set_option(
|
||||
@@ -145,19 +134,12 @@ 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_charging_strategy", "zero"),
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy", "standby"),
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy", "to_full"),
|
||||
("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"),
|
||||
],
|
||||
)
|
||||
async def test_select_request_error(
|
||||
@@ -186,7 +168,7 @@ async def test_select_request_error(
|
||||
@pytest.mark.parametrize(
|
||||
("device_fixture", "entity_id", "option"),
|
||||
[
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy", "to_full"),
|
||||
("HWE-P1", "select.device_battery_group_mode", "to_full"),
|
||||
],
|
||||
)
|
||||
async def test_select_unauthorized_error(
|
||||
@@ -221,7 +203,7 @@ async def test_select_unauthorized_error(
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "method"),
|
||||
[
|
||||
("select.device_battery_group_charging_strategy", "combined"),
|
||||
("select.device_battery_group_mode", "combined"),
|
||||
],
|
||||
)
|
||||
async def test_select_unreachable(
|
||||
@@ -244,7 +226,7 @@ async def test_select_unreachable(
|
||||
@pytest.mark.parametrize(
|
||||
("device_fixture", "entity_id"),
|
||||
[
|
||||
("HWE-P1", "select.device_battery_group_charging_strategy"),
|
||||
("HWE-P1", "select.device_battery_group_mode"),
|
||||
],
|
||||
)
|
||||
async def test_select_multiple_state_changes(
|
||||
@@ -293,7 +275,7 @@ async def test_select_multiple_state_changes(
|
||||
(
|
||||
"HWE-P1-no-batteries",
|
||||
[
|
||||
"select.device_battery_group_charging_strategy",
|
||||
"select.device_battery_group_mode",
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -116,30 +116,3 @@ 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,6 +1,5 @@
|
||||
"""The tests for the Light component."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
@@ -1709,50 +1708,42 @@ async def test_light_service_call_color_conversion(hass: HomeAssistant) -> None:
|
||||
assert data == {"brightness": 128, "color_temp_kelvin": 3451}
|
||||
|
||||
|
||||
@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(
|
||||
async def test_light_service_call_color_conversion_named_tuple(
|
||||
hass: HomeAssistant,
|
||||
color_input: dict[str, Any],
|
||||
) -> None:
|
||||
"""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.
|
||||
"""
|
||||
"""Test a named tuple (RGBColor) is handled correctly."""
|
||||
entities = [
|
||||
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}
|
||||
),
|
||||
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),
|
||||
]
|
||||
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()
|
||||
|
||||
@@ -1760,25 +1751,30 @@ async def test_light_turn_on_rgb_color_is_plain_tuple(
|
||||
"light",
|
||||
"turn_on",
|
||||
{
|
||||
"entity_id": [entity.entity_id for entity in entities],
|
||||
"entity_id": [
|
||||
entity0.entity_id,
|
||||
entity1.entity_id,
|
||||
entity2.entity_id,
|
||||
entity3.entity_id,
|
||||
entity4.entity_id,
|
||||
entity5.entity_id,
|
||||
],
|
||||
"brightness_pct": 25,
|
||||
**color_input,
|
||||
"rgb_color": color_util.RGBColor(128, 0, 0),
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
_, data = entities[0].last_call("turn_on")
|
||||
_, data = entity0.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "hs_color": (0.0, 100.0)}
|
||||
_, data = entities[1].last_call("turn_on")
|
||||
_, data = entity1.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
|
||||
assert type(data["rgb_color"]) is tuple
|
||||
_, data = entities[2].last_call("turn_on")
|
||||
_, data = entity2.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "xy_color": (0.701, 0.299)}
|
||||
_, data = entities[3].last_call("turn_on")
|
||||
_, data = entity3.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgb_color": (128, 0, 0)}
|
||||
assert type(data["rgb_color"]) is tuple
|
||||
_, data = entities[4].last_call("turn_on")
|
||||
_, data = entity4.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgbw_color": (128, 0, 0, 0)}
|
||||
_, data = entities[5].last_call("turn_on")
|
||||
_, data = entity5.last_call("turn_on")
|
||||
assert data == {"brightness": 64, "rgbww_color": (128, 0, 0, 0, 0)}
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
"wifi_credentials_set": true,
|
||||
"thread_credentials_set": false,
|
||||
"min_supported_schema_version": 1,
|
||||
"bluetooth_enabled": false,
|
||||
"wifi_ssid": "test_ssid",
|
||||
"ble_proxy_enabled": false
|
||||
"bluetooth_enabled": false
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
|
||||
@@ -8,9 +8,7 @@
|
||||
"wifi_credentials_set": true,
|
||||
"thread_credentials_set": false,
|
||||
"min_supported_schema_version": 1,
|
||||
"bluetooth_enabled": false,
|
||||
"wifi_ssid": "**REDACTED**",
|
||||
"ble_proxy_enabled": false
|
||||
"bluetooth_enabled": false
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
|
||||
@@ -9,12 +9,8 @@ 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 (
|
||||
SERVER_INFO_TO_REDACT,
|
||||
redact_matter_attributes,
|
||||
)
|
||||
from homeassistant.components.matter.diagnostics import redact_matter_attributes
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
@@ -89,7 +85,7 @@ async def test_device_diagnostics(
|
||||
"""Test the device diagnostics."""
|
||||
system_info_dict = config_entry_diagnostics["info"]
|
||||
device_diagnostics_redacted = {
|
||||
"server_info": async_redact_data(system_info_dict, SERVER_INFO_TO_REDACT),
|
||||
"server_info": system_info_dict,
|
||||
"node": redact_matter_attributes(device_diagnostics),
|
||||
}
|
||||
server_diagnostics_response = {
|
||||
|
||||
@@ -749,18 +749,7 @@ MOCK_SUBENTRY_DEVICE_DATA = {
|
||||
}
|
||||
|
||||
MOCK_NOTIFY_SUBENTRY_DATA_MULTI = {
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA
|
||||
| {
|
||||
"mqtt_settings": {
|
||||
"qos": 2.0,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 1,
|
||||
"seconds": 30,
|
||||
},
|
||||
}
|
||||
},
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 2}},
|
||||
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1 | MOCK_SUBENTRY_NOTIFY_COMPONENT2,
|
||||
} | MOCK_SUBENTRY_AVAILABILITY_DATA
|
||||
|
||||
@@ -893,18 +882,7 @@ 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,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 1,
|
||||
"seconds": 30,
|
||||
},
|
||||
}
|
||||
},
|
||||
"device": MOCK_SUBENTRY_DEVICE_DATA | {"mqtt_settings": {"qos": 0}},
|
||||
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1
|
||||
| MOCK_SUBENTRY_NOTIFY_COMPONENT2
|
||||
| MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT
|
||||
|
||||
@@ -88,66 +88,6 @@ 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,13 +1502,12 @@ async def test_publish_error(
|
||||
|
||||
async def test_subscribe_error(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
mqtt_client_mock: MqttMockPahoClient,
|
||||
setup_with_birth_msg_client_mock: MqttMockPahoClient,
|
||||
record_calls: MessageCallbackType,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test publish error."""
|
||||
await mqtt_mock_entry()
|
||||
mqtt_client_mock = setup_with_birth_msg_client_mock
|
||||
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,14 +5196,7 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
.schema["mqtt_settings"]
|
||||
.schema.schema.items()
|
||||
}
|
||||
assert mqtt_settings_key_descriptions == {
|
||||
"qos": {
|
||||
"suggested_value": 2,
|
||||
},
|
||||
"message_expiry_interval": {
|
||||
"suggested_value": {"days": 0, "hours": 0, "minutes": 1, "seconds": 30}
|
||||
},
|
||||
}
|
||||
assert mqtt_settings_key_descriptions == {"qos": {"suggested_value": 2}}
|
||||
assert result["data_schema"].schema["mqtt_settings"].options == {"collapsed": False}
|
||||
|
||||
# Update the device details
|
||||
@@ -5216,15 +5209,7 @@ async def test_subentry_reconfigure_update_device_properties(
|
||||
"model_id": "bn003",
|
||||
"manufacturer": "Beer Masters",
|
||||
"configuration_url": "https://example.com",
|
||||
"mqtt_settings": {
|
||||
"qos": 1,
|
||||
"message_expiry_interval": {
|
||||
"days": 0,
|
||||
"hours": 0,
|
||||
"minutes": 0,
|
||||
"seconds": 30,
|
||||
},
|
||||
},
|
||||
"mqtt_settings": {"qos": 1},
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
@@ -5247,12 +5232,6 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Common fixtures for the Novy Cooker Hood tests."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rf_protocols.loader import CodeCollection
|
||||
|
||||
from homeassistant.components.novy_cooker_hood.const import CONF_TRANSMITTER, DOMAIN
|
||||
from homeassistant.const import CONF_CODE
|
||||
@@ -12,36 +11,16 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.radio_frequency.common import (
|
||||
MockRadioFrequencyCommand,
|
||||
MockRadioFrequencyEntity,
|
||||
)
|
||||
from tests.components.radio_frequency.common import MockRadioFrequencyEntity
|
||||
|
||||
TRANSMITTER_ENTITY_ID = "radio_frequency.test_rf_transmitter"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_get_codes() -> Iterator[MagicMock]:
|
||||
"""Patch the bundled-codes loader so tests don't hit the filesystem."""
|
||||
fake_collection = MagicMock(spec=CodeCollection)
|
||||
fake_collection.async_load_command = AsyncMock(
|
||||
side_effect=lambda name: MockRadioFrequencyCommand()
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.novy_cooker_hood.light.get_codes_for_code",
|
||||
return_value=fake_collection,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.novy_cooker_hood.fan.get_codes_for_code",
|
||||
return_value=fake_collection,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.novy_cooker_hood.config_flow.get_codes_for_code",
|
||||
return_value=fake_collection,
|
||||
),
|
||||
):
|
||||
yield fake_collection
|
||||
def mock_command_delay() -> Iterator[None]:
|
||||
"""Drop the inter-command delay so tests don't spend real time waiting."""
|
||||
with patch("homeassistant.components.novy_cooker_hood.fan._COMMAND_DELAY", 0):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Test the Novy Hood config flow."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -49,7 +49,6 @@ async def _start_user_flow(hass: HomeAssistant, code: str = "1") -> dict:
|
||||
|
||||
async def test_user_flow_test_then_finish(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
@@ -58,8 +57,10 @@ async def test_user_flow_test_then_finish(
|
||||
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["step_id"] == "test_light"
|
||||
mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT)
|
||||
assert len(mock_rf_entity.send_command_calls) == 2
|
||||
sent = mock_rf_entity.send_command_calls[0].command
|
||||
assert sent.key == COMMAND_LIGHT
|
||||
assert sent.channel == 3
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "finish"}
|
||||
@@ -77,7 +78,6 @@ async def test_user_flow_test_then_finish(
|
||||
|
||||
async def test_user_flow_retry_picks_different_code(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
@@ -99,9 +99,13 @@ async def test_user_flow_retry_picks_different_code(
|
||||
},
|
||||
)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
# One load per test x two tests; two sends per test x two tests.
|
||||
assert mock_get_codes.async_load_command.await_count == 2
|
||||
assert len(mock_rf_entity.send_command_calls) == 4
|
||||
assert [c.command.channel for c in mock_rf_entity.send_command_calls] == [
|
||||
1,
|
||||
1,
|
||||
7,
|
||||
7,
|
||||
]
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "finish"}
|
||||
@@ -127,7 +131,6 @@ async def test_user_flow_test_transmit_failure(
|
||||
|
||||
async def test_recover_after_transmit_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
) -> None:
|
||||
"""The user can Retry from test_failed and complete the flow."""
|
||||
@@ -183,7 +186,6 @@ async def test_unique_id_already_configured(
|
||||
|
||||
async def test_same_transmitter_different_code_is_allowed(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
entity_registry: er.EntityRegistry,
|
||||
@@ -205,7 +207,6 @@ async def test_same_transmitter_different_code_is_allowed(
|
||||
|
||||
async def test_reconfigure_updates_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
init_novy_cooker_hood: MockConfigEntry,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
entity_registry: er.EntityRegistry,
|
||||
@@ -224,7 +225,9 @@ async def test_reconfigure_updates_entry(
|
||||
)
|
||||
assert result["type"] is FlowResultType.MENU
|
||||
assert result["step_id"] == "test_light"
|
||||
mock_get_codes.async_load_command.assert_awaited_with(COMMAND_LIGHT)
|
||||
sent = mock_rf_entity.send_command_calls[-1].command
|
||||
assert sent.key == COMMAND_LIGHT
|
||||
assert sent.channel == 4
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "finish"}
|
||||
@@ -239,7 +242,6 @@ async def test_reconfigure_updates_entry(
|
||||
|
||||
async def test_reconfigure_frees_old_unique_id(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
init_novy_cooker_hood: MockConfigEntry,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
) -> None:
|
||||
@@ -295,7 +297,6 @@ async def test_reconfigure_aborts_on_collision(
|
||||
|
||||
async def test_reconfigure_retry_returns_to_picker(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
init_novy_cooker_hood: MockConfigEntry,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
) -> None:
|
||||
@@ -326,7 +327,6 @@ async def test_no_transmitters(hass: HomeAssistant) -> None:
|
||||
|
||||
async def test_recover_after_no_transmitters(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
) -> None:
|
||||
"""User can re-init the flow after the radio_frequency integration loads."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for the Novy Hood light platform."""
|
||||
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
from homeassistant.components.light import (
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
@@ -28,7 +26,6 @@ ENTITY_ID = "light.novy_cooker_hood_light"
|
||||
|
||||
async def test_turn_on_and_off_send_light_once_each(
|
||||
hass: HomeAssistant,
|
||||
mock_get_codes: MagicMock,
|
||||
mock_rf_entity: MockRadioFrequencyEntity,
|
||||
init_novy_cooker_hood: MockConfigEntry,
|
||||
) -> None:
|
||||
@@ -66,11 +63,11 @@ async def test_turn_on_and_off_send_light_once_each(
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
assert mock_get_codes.async_load_command.await_args_list == [
|
||||
call(COMMAND_LIGHT),
|
||||
call(COMMAND_LIGHT),
|
||||
]
|
||||
assert len(mock_rf_entity.send_command_calls) == 2
|
||||
assert [c.command.key for c in mock_rf_entity.send_command_calls] == [
|
||||
COMMAND_LIGHT,
|
||||
COMMAND_LIGHT,
|
||||
]
|
||||
|
||||
|
||||
async def test_restore_state(
|
||||
|
||||
@@ -116,54 +116,3 @@ 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,36 +126,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# 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,8 +2,6 @@
|
||||
|
||||
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
|
||||
@@ -15,23 +13,6 @@ 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(
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
"""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,7 +50,6 @@ def device_fixtures() -> list[str]:
|
||||
"XT-LT200",
|
||||
"XT-PL50",
|
||||
"XT-PL100",
|
||||
"XT-LK50",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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,34 +154,3 @@
|
||||
'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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# 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,10 +27,9 @@ from .const import (
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_user_flow_success(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
hass: HomeAssistant, mock_api_client: AsyncMock
|
||||
) -> None:
|
||||
"""Test successful user login flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -62,9 +61,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,
|
||||
@@ -91,11 +90,9 @@ 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_setup_entry: AsyncMock,
|
||||
mock_api_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
hass: HomeAssistant, 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)
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
"""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,4 +1,52 @@
|
||||
"""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,33 +1,35 @@
|
||||
"""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
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MOCK_HOST_ZEVERSOLAR, MOCK_SERIAL_NUMBER
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
|
||||
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,
|
||||
data={CONF_HOST: MOCK_HOST_ZEVERSOLAR},
|
||||
unique_id=MOCK_SERIAL_NUMBER,
|
||||
unique_id="my_id_2",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def zeversolar_data() -> ZeverSolarData:
|
||||
"""Create a ZeverSolarData structure for tests."""
|
||||
|
||||
return ZeverSolarData(
|
||||
wifi_enabled=False,
|
||||
serial_or_registry_id="1223",
|
||||
@@ -37,39 +39,9 @@ def zeversolar_data() -> ZeverSolarData:
|
||||
reported_datetime="19900101 23:00",
|
||||
communication_status=StatusEnum.OK,
|
||||
num_inverters=1,
|
||||
serial_number=MOCK_SERIAL_NUMBER,
|
||||
serial_number="123456778",
|
||||
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,
|
||||
'energy_today': 123.4,
|
||||
'hardware_version': 'M10',
|
||||
'meter_status': 'OK',
|
||||
'num_inverters': 1,
|
||||
'pac': 1234,
|
||||
'registry_key': 'A-2',
|
||||
'reported_datetime': '19900101 23:00',
|
||||
'registry_key': 'WSMQKHTQ3JVYQWA9',
|
||||
'reported_datetime': '19900101 23:01:45',
|
||||
'serial_number': '123456778',
|
||||
'serial_or_registry_id': '1223',
|
||||
'software_version': '123-23',
|
||||
'serial_or_registry_id': 'EAB9615C0001',
|
||||
'software_version': '19703-826R+17511-707R',
|
||||
'status': 'OK',
|
||||
'wifi_enabled': False,
|
||||
})
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '123',
|
||||
'state': '123.4',
|
||||
})
|
||||
# ---
|
||||
# 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, mock_zeversolar_client: MagicMock) -> None:
|
||||
async def test_form(hass: HomeAssistant) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
@@ -26,9 +26,7 @@ async def test_form(hass: HomeAssistant, mock_zeversolar_client: MagicMock) -> N
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] is None
|
||||
|
||||
await _set_up_zeversolar(
|
||||
hass=hass, flow_id=result["flow_id"], mock_client=mock_zeversolar_client
|
||||
)
|
||||
await _set_up_zeversolar(hass=hass, flow_id=result["flow_id"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -54,7 +52,6 @@ async def test_form(hass: HomeAssistant, mock_zeversolar_client: MagicMock) -> N
|
||||
)
|
||||
async def test_form_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_zeversolar_client: MagicMock,
|
||||
side_effect: Exception,
|
||||
errors: dict,
|
||||
) -> None:
|
||||
@@ -63,30 +60,32 @@ async def test_form_errors(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
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
|
||||
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",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["errors"] == errors
|
||||
|
||||
await _set_up_zeversolar(
|
||||
hass=hass, flow_id=result["flow_id"], mock_client=mock_zeversolar_client
|
||||
)
|
||||
await _set_up_zeversolar(hass=hass, flow_id=result["flow_id"])
|
||||
|
||||
|
||||
async def test_abort_already_configured(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_zeversolar_client: MagicMock,
|
||||
) -> None:
|
||||
async def test_abort_already_configured(hass: HomeAssistant) -> None:
|
||||
"""Test we abort when the device is already configured."""
|
||||
config_entry.add_to_hass(hass)
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Zeversolar",
|
||||
data={CONF_HOST: "test_ip"},
|
||||
unique_id="test_serial",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
@@ -95,9 +94,14 @@ async def test_abort_already_configured(
|
||||
assert result.get("errors") is None
|
||||
assert "flow_id" in result
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.zeversolar.async_setup_entry",
|
||||
) as mock_setup_entry:
|
||||
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,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
flow_id=result["flow_id"],
|
||||
user_input={
|
||||
@@ -111,15 +115,17 @@ async def test_abort_already_configured(
|
||||
assert len(mock_setup_entry.mock_calls) == 0
|
||||
|
||||
|
||||
async def _set_up_zeversolar(
|
||||
hass: HomeAssistant, flow_id: str, mock_client: MagicMock
|
||||
) -> None:
|
||||
async def _set_up_zeversolar(hass: HomeAssistant, flow_id: str) -> None:
|
||||
"""Reusable successful setup of Zeversolar sensor."""
|
||||
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:
|
||||
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,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
flow_id=flow_id,
|
||||
user_input={
|
||||
|
||||
@@ -6,9 +6,8 @@ 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
|
||||
from . import MOCK_SERIAL_NUMBER, init_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.diagnostics import (
|
||||
get_diagnostics_for_config_entry,
|
||||
get_diagnostics_for_device,
|
||||
@@ -20,13 +19,12 @@ async def test_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
snapshot: SnapshotAssertion,
|
||||
init_integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config entry diagnostics."""
|
||||
assert (
|
||||
await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
|
||||
== snapshot
|
||||
)
|
||||
|
||||
entry = await init_integration(hass)
|
||||
|
||||
assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot
|
||||
|
||||
|
||||
async def test_device_diagnostics(
|
||||
@@ -34,14 +32,15 @@ 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, init_integration, device)
|
||||
== snapshot
|
||||
await get_diagnostics_for_device(hass, hass_client, entry, device) == snapshot
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Test the init file code."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
from zeversolar import ZeverSolarData
|
||||
from zeversolar.exceptions import ZeverSolarTimeout
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -11,23 +12,28 @@ from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_async_setup_entry_fails(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
mock_zeversolar_client: MagicMock,
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry, zeversolar_data: ZeverSolarData
|
||||
) -> None:
|
||||
"""Test to load/unload the integration."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
mock_zeversolar_client.get_data.side_effect = ZeverSolarTimeout
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
with (
|
||||
patch("zeversolar.ZeverSolarClient.get_data", side_effect=ZeverSolarTimeout),
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
mock_zeversolar_client.get_data.side_effect = None
|
||||
with patch("homeassistant.components.zeversolar.PLATFORMS", []):
|
||||
with (
|
||||
patch("homeassistant.components.zeversolar.PLATFORMS", []),
|
||||
patch("zeversolar.ZeverSolarClient.get_data", return_value=zeversolar_data),
|
||||
):
|
||||
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 tests.common import MockConfigEntry, snapshot_platform
|
||||
from . import init_integration
|
||||
|
||||
from tests.common import snapshot_platform
|
||||
|
||||
|
||||
async def test_sensors(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
init_integration: MockConfigEntry,
|
||||
hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion
|
||||
) -> None:
|
||||
"""Test sensors."""
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.zeversolar.PLATFORMS",
|
||||
[Platform.SENSOR],
|
||||
):
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, init_integration.entry_id
|
||||
)
|
||||
entry = await init_integration(hass)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
|
||||
|
||||
+1
-3
@@ -1074,9 +1074,7 @@ 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, properties=properties
|
||||
)
|
||||
async_fire_mqtt_message(hass, topic, payload or b"", qos, retain)
|
||||
mid = get_mid()
|
||||
hass.loop.call_soon(
|
||||
mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"""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]
|
||||
Reference in New Issue
Block a user