Integrations v2.1: Virtual integrations (#80613)

This commit is contained in:
Franck Nijhof 2022-10-21 05:09:06 +02:00 committed by GitHub
parent 6c23de94e1
commit bb287dd0ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 2968 additions and 2720 deletions

View File

@ -0,0 +1,6 @@
{
"domain": "3_day_blinds",
"name": "3 Day Blinds",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "amp_motorization",
"name": "AMP Motorization",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "august_ble",
"name": "August Bluetooth",
"integration_type": "virtual",
"supported_by": "yalexs_ble"
}

View File

@ -0,0 +1,6 @@
{
"domain": "bliss_automation",
"name": "Bliss Automation",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "bloc_blinds",
"name": "Bloc Blinds",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "brel_home",
"name": "Brel Home",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "bswitch",
"name": "BSwitch",
"integration_type": "virtual",
"supported_by": "switchbee"
}

View File

@ -0,0 +1,6 @@
{
"domain": "bticino",
"name": "BTicino",
"integration_type": "virtual",
"supported_by": "netatmo"
}

View File

@ -0,0 +1,6 @@
{
"domain": "bubendorff",
"name": "Bubendorff",
"integration_type": "virtual",
"supported_by": "netatmo"
}

View File

@ -0,0 +1,6 @@
{
"domain": "cozytouch",
"name": "Atlantic Cozytouch",
"integration_type": "virtual",
"supported_by": "overkiz"
}

View File

@ -0,0 +1,6 @@
{
"domain": "dacia",
"name": "Dacia",
"integration_type": "virtual",
"supported_by": "renault"
}

View File

@ -56,8 +56,5 @@
}
],
"iot_class": "local_polling",
"loggers": ["denonavr"],
"supported_brands": {
"marantz": "Marantz"
}
"loggers": ["denonavr"]
}

View File

@ -0,0 +1,6 @@
{
"domain": "diaz",
"name": "Diaz",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "digital_loggers",
"name": "Digital Loggers",
"integration_type": "virtual",
"supported_by": "wemo"
}

View File

@ -0,0 +1,6 @@
{
"domain": "dooya",
"name": "Dooya",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "flexom",
"name": "Bouygues Flexom",
"integration_type": "virtual",
"supported_by": "overkiz"
}

View File

@ -0,0 +1,6 @@
{
"domain": "gaviota",
"name": "Gaviota",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -7,8 +7,5 @@
"dependencies": ["network"],
"codeowners": ["@cmroche"],
"iot_class": "local_polling",
"loggers": ["greeclimate"],
"supported_brands": {
"heiwa": "Heiwa"
}
"loggers": ["greeclimate"]
}

View File

@ -0,0 +1,6 @@
{
"domain": "havana_shade",
"name": "Havana Shade",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "heiwa",
"name": "Heiwa",
"integration_type": "virtual",
"supported_by": "gree"
}

View File

@ -0,0 +1,6 @@
{
"domain": "hi_kumo",
"name": "Hitachi Hi Kumo",
"integration_type": "virtual",
"supported_by": "overkiz"
}

View File

@ -17,8 +17,5 @@
],
"zeroconf": ["_powerview._tcp.local."],
"iot_class": "local_polling",
"loggers": ["aiopvapi"],
"supported_brands": {
"luxaflex": "Luxaflex"
}
"loggers": ["aiopvapi"]
}

View File

@ -0,0 +1,6 @@
{
"domain": "hurrican_shutters_wholesale",
"name": "Hurrican Shutters Wholesale",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -13,8 +13,5 @@
"requirements": ["inkbird-ble==0.5.5"],
"dependencies": ["bluetooth"],
"codeowners": ["@bdraco"],
"iot_class": "local_push",
"supported_brands": {
"nutrichef": "Nutrichef"
}
"iot_class": "local_push"
}

View File

@ -0,0 +1,6 @@
{
"domain": "inspired_shades",
"name": "Inspired Shades",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "ismartwindow",
"name": "iSmartWindow",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "legrand",
"name": "Legrand",
"integration_type": "virtual",
"supported_by": "netatmo"
}

View File

@ -0,0 +1,6 @@
{
"domain": "luxaflex",
"name": "Luxaflex",
"integration_type": "virtual",
"supported_by": "hunterdouglas_powerview"
}

View File

@ -0,0 +1,6 @@
{
"domain": "marantz",
"name": "Marantz",
"integration_type": "virtual",
"supported_by": "denonavr"
}

View File

@ -0,0 +1,6 @@
{
"domain": "martec",
"name": "Martec",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -19,25 +19,5 @@
],
"codeowners": ["@starkillerOG"],
"iot_class": "local_push",
"loggers": ["motionblinds"],
"supported_brands": {
"amp_motorization": "AMP Motorization",
"bliss_automation": "Bliss Automation",
"bloc_blinds": "Bloc Blinds",
"brel_home": "Brel Home",
"3_day_blinds": "3 Day Blinds",
"diaz": "Diaz",
"dooya": "Dooya",
"gaviota": "Gaviota",
"havana_shade": "Havana Shade",
"hurrican_shutters_wholesale": "Hurrican Shutters Wholesale",
"inspired_shades": "Inspired Shades",
"ismartwindow": "iSmartWindow",
"martec": "Martec",
"raven_rock_mfg": "Raven Rock MFG",
"screenaway": "ScreenAway",
"smart_blinds": "Smart Blinds",
"smart_home": "Smart Home",
"uprise_smart_shades": "Uprise Smart Shades"
}
"loggers": ["motionblinds"]
}

View File

@ -11,11 +11,5 @@
"models": ["Healty Home Coach", "Netatmo Relay", "Presence", "Welcome"]
},
"iot_class": "cloud_polling",
"loggers": ["pyatmo"],
"supported_brands": {
"legrand": "Legrand",
"bubendorff": "Bubendorff",
"smarther": "Smarther",
"bticino": "BTicino"
}
"loggers": ["pyatmo"]
}

View File

@ -0,0 +1,6 @@
{
"domain": "nexity",
"name": "Nexity Eugénie",
"integration_type": "virtual",
"supported_by": "overkiz"
}

View File

@ -0,0 +1,6 @@
{
"domain": "nutrichef",
"name": "Nutrichef",
"integration_type": "virtual",
"supported_by": "inkbird"
}

View File

@ -18,13 +18,5 @@
],
"codeowners": ["@imicknl", "@vlebourl", "@tetienne"],
"iot_class": "cloud_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"supported_brands": {
"cozytouch": "Atlantic Cozytouch",
"flexom": "Bouygues Flexom",
"hi_kumo": "Hitachi Hi Kumo",
"nexity": "Nexity Eugénie",
"rexel": "Rexel Energeasy Connect",
"somfy": "Somfy"
}
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"]
}

View File

@ -0,0 +1,6 @@
{
"domain": "pcs_lighting",
"name": "PCS Lighting",
"integration_type": "virtual",
"supported_by": "upb"
}

View File

@ -0,0 +1,6 @@
{
"domain": "raven_rock_mfg",
"name": "Raven Rock MFG",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -6,6 +6,5 @@
"requirements": ["renault-api==0.1.11"],
"codeowners": ["@epenet"],
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"supported_brands": { "dacia": "Dacia" }
"loggers": ["renault_api"]
}

View File

@ -0,0 +1,6 @@
{
"domain": "rexel",
"name": "Rexel Energeasy Connect",
"integration_type": "virtual",
"supported_by": "overkiz"
}

View File

@ -0,0 +1,6 @@
{
"domain": "roborock",
"name": "Roborock",
"integration_type": "virtual",
"supported_by": "xiaomi_miio"
}

View File

@ -0,0 +1,6 @@
{
"domain": "screenaway",
"name": "ScreenAway",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "sensorblue",
"name": "SensorBlue",
"integration_type": "virtual",
"supported_by": "thermobeacon"
}

View File

@ -0,0 +1,6 @@
{
"domain": "simply_automated",
"name": "Simply Automated",
"integration_type": "virtual",
"supported_by": "upb"
}

View File

@ -0,0 +1,6 @@
{
"domain": "smart_blinds",
"name": "Smart Blinds",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "smart_home",
"name": "Smart Home",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -0,0 +1,6 @@
{
"domain": "smarther",
"name": "Smarther",
"integration_type": "virtual",
"supported_by": "netatmo"
}

View File

@ -0,0 +1,6 @@
{
"domain": "somfy",
"name": "Somfy",
"integration_type": "virtual",
"supported_by": "overkiz"
}

View File

@ -5,8 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/switchbee",
"requirements": ["pyswitchbee==1.5.5"],
"codeowners": ["@jafar-atili"],
"iot_class": "local_polling",
"supported_brands": {
"bswitch": "BSwitch"
}
"iot_class": "local_polling"
}

View File

@ -27,9 +27,5 @@
"requirements": ["thermobeacon-ble==0.3.2"],
"dependencies": ["bluetooth"],
"codeowners": ["@bdraco"],
"iot_class": "local_push",
"supported_brands": {
"thermoplus": "ThermoPlus",
"sensorblue": "SensorBlue"
}
"iot_class": "local_push"
}

View File

@ -0,0 +1,6 @@
{
"domain": "thermoplus",
"name": "ThermoPlus",
"integration_type": "virtual",
"supported_by": "thermobeacon"
}

View File

@ -6,9 +6,5 @@
"codeowners": ["@gwww"],
"config_flow": true,
"iot_class": "local_push",
"loggers": ["upb_lib"],
"supported_brands": {
"pcs_lighting": "PCS Lighting",
"simply_automated": "Simply Automated"
}
"loggers": ["upb_lib"]
}

View File

@ -0,0 +1,6 @@
{
"domain": "uprise_smart_shades",
"name": "Uprise Smart Shades",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -21,7 +21,6 @@ from homeassistant.exceptions import (
TemplateError,
Unauthorized,
)
from homeassistant.generated import supported_brands
from homeassistant.helpers import config_validation as cv, entity, template
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.event import (
@ -74,7 +73,6 @@ def async_register_commands(
async_reg(hass, handle_unsubscribe_events)
async_reg(hass, handle_validate_config)
async_reg(hass, handle_subscribe_entities)
async_reg(hass, handle_supported_brands)
async_reg(hass, handle_supported_features)
async_reg(hass, handle_integration_descriptions)
@ -705,31 +703,6 @@ async def handle_validate_config(
connection.send_result(msg["id"], result)
@decorators.websocket_command(
{
vol.Required("type"): "supported_brands",
}
)
@decorators.async_response
async def handle_supported_brands(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle supported brands command."""
data = {}
ints_or_excs = await async_get_integrations(
hass, supported_brands.HAS_SUPPORTED_BRANDS
)
for int_or_exc in ints_or_excs.values():
if isinstance(int_or_exc, Exception):
raise int_or_exc
# Happens if a custom component without supported brands overrides a built-in one with supported brands
if "supported_brands" not in int_or_exc.manifest:
continue
data[int_or_exc.domain] = int_or_exc.manifest["supported_brands"]
connection.send_result(msg["id"], data)
@callback
@decorators.websocket_command(
{

View File

@ -14,8 +14,5 @@
},
"codeowners": ["@esev"],
"iot_class": "local_push",
"loggers": ["pywemo"],
"supported_brands": {
"digital_loggers": "Digital Loggers"
}
"loggers": ["pywemo"]
}

View File

@ -7,8 +7,5 @@
"codeowners": ["@rytilahti", "@syssi", "@starkillerOG", "@bieniu"],
"zeroconf": ["_miio._udp.local."],
"iot_class": "local_polling",
"loggers": ["micloud", "miio"],
"supported_brands": {
"roborock": "Roborock"
}
"loggers": ["micloud", "miio"]
}

View File

@ -12,8 +12,5 @@
"service_uuid": "0000fe24-0000-1000-8000-00805f9b34fb"
}
],
"iot_class": "local_push",
"supported_brands": {
"august_ble": "August Bluetooth"
}
"iot_class": "local_push"
}

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +0,0 @@
"""Automatically generated by hassfest.
To update, run python3 -m script.hassfest
"""
HAS_SUPPORTED_BRANDS = [
"denonavr",
"gree",
"hunterdouglas_powerview",
"inkbird",
"motion_blinds",
"netatmo",
"overkiz",
"renault",
"switchbee",
"thermobeacon",
"upb",
"wemo",
"xiaomi_miio",
"yalexs_ble",
]

View File

@ -152,7 +152,6 @@ class Manifest(TypedDict, total=False):
version: str
codeowners: list[str]
loggers: list[str]
supported_brands: dict[str, str]
def manifest_from_legacy_module(domain: str, module: ModuleType) -> Manifest:

View File

@ -20,7 +20,6 @@ from . import (
requirements,
services,
ssdp,
supported_brands,
translations,
usb,
zeroconf,
@ -39,7 +38,6 @@ INTEGRATION_PLUGINS = [
requirements,
services,
ssdp,
supported_brands,
translations,
usb,
zeroconf,

View File

@ -47,7 +47,10 @@ def generate_and_validate(integrations: dict[str, Integration], config: Config):
for domain in sorted(integrations):
integration = integrations[domain]
if not integration.manifest:
if (
not integration.manifest
or integration.manifest.get("integration_type") == "virtual"
):
continue
codeowners = integration.manifest["codeowners"]

View File

@ -171,14 +171,24 @@ def _generate_integrations(
integration = integrations[domain]
if integration.integration_type in ("entity", "system"):
continue
metadata["config_flow"] = integration.config_flow
metadata["iot_class"] = integration.iot_class
metadata["integration_type"] = integration.integration_type
if integration.translated_name:
result["translated_name"].add(domain)
else:
metadata["name"] = integration.name
metadata["integration_type"] = integration.integration_type
if integration.integration_type == "virtual":
if integration.supported_by:
metadata["supported_by"] = integration.supported_by
if integration.iot_standard:
metadata["iot_standard"] = integration.iot_standard
else:
metadata["config_flow"] = integration.config_flow
if integration.iot_class:
metadata["iot_class"] = integration.iot_class
if integration.integration_type == "helper":
result["helper"][domain] = metadata
else:

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from awesomeversion import (
@ -158,7 +159,7 @@ def verify_wildcard(value: str):
return value
MANIFEST_SCHEMA = vol.Schema(
INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
{
vol.Required("domain"): str,
vol.Required("name"): str,
@ -254,14 +255,32 @@ MANIFEST_SCHEMA = vol.Schema(
vol.Optional("loggers"): [str],
vol.Optional("disabled"): str,
vol.Optional("iot_class"): vol.In(SUPPORTED_IOT_CLASSES),
vol.Optional("supported_brands"): vol.Schema({str: str}),
}
)
CUSTOM_INTEGRATION_MANIFEST_SCHEMA = MANIFEST_SCHEMA.extend(
VIRTUAL_INTEGRATION_MANIFEST_SCHEMA = vol.Schema(
{
vol.Required("domain"): str,
vol.Required("name"): str,
vol.Required("integration_type"): "virtual",
vol.Exclusive("iot_standard", "virtual_integration"): vol.Any(
"homekit", "zigbee", "zwave"
),
vol.Exclusive("supported_by", "virtual_integration"): str,
}
)
def manifest_schema(value: dict[str, Any]) -> vol.Schema:
"""Validate integration manifest."""
if value.get("integration_type") == "virtual":
return VIRTUAL_INTEGRATION_MANIFEST_SCHEMA(value)
return INTEGRATION_MANIFEST_SCHEMA(value)
CUSTOM_INTEGRATION_MANIFEST_SCHEMA = INTEGRATION_MANIFEST_SCHEMA.extend(
{
vol.Optional("version"): vol.All(str, verify_version),
vol.Remove("supported_brands"): dict,
}
)
@ -284,7 +303,7 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No
try:
if integration.core:
MANIFEST_SCHEMA(integration.manifest)
manifest_schema(integration.manifest)
else:
CUSTOM_INTEGRATION_MANIFEST_SCHEMA(integration.manifest)
except vol.Invalid as err:
@ -312,15 +331,19 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No
if (
integration.manifest["domain"] not in NO_IOT_CLASS
and "iot_class" not in integration.manifest
and integration.manifest.get("integration_type") != "virtual"
):
integration.add_error("manifest", "Domain is missing an IoT Class")
for domain, _name in integration.manifest.get("supported_brands", {}).items():
if (core_components_dir / domain).exists():
integration.add_warning(
"manifest",
f"Supported brand domain {domain} collides with built-in core integration",
)
if (
integration.manifest.get("integration_type") == "virtual"
and (supported_by := integration.manifest.get("supported_by"))
and not (core_components_dir / supported_by).exists()
):
integration.add_error(
"manifest",
"Virtual integration points to non-existing supported_by integration",
)
if not integration.core:
validate_version(integration)

View File

@ -109,11 +109,12 @@ class Integration:
continue
init = fil / "__init__.py"
if not init.exists():
manifest = fil / "manifest.json"
if not init.exists() and not manifest.exists():
print(
f"Warning: {init} missing, skipping directory. "
"If this is your development environment, "
"you can safely delete this folder."
f"Warning: {init} and manifest.json missing, "
"skipping directory. If this is your development "
"environment, you can safely delete this folder."
)
continue
@ -170,9 +171,9 @@ class Integration:
return self.manifest.get("dependencies", [])
@property
def supported_brands(self) -> dict[str]:
"""Return dict of supported brands."""
return self.manifest.get("supported_brands", {})
def supported_by(self) -> str:
"""Return the integration supported by this virtual integration."""
return self.manifest.get("supported_by", {})
@property
def integration_type(self) -> str:
@ -184,6 +185,11 @@ class Integration:
"""Return the integration IoT Class."""
return self.manifest.get("iot_class")
@property
def iot_standard(self) -> str:
"""Return the IoT standard supported by this virtual integration."""
return self.manifest.get("iot_standard", {})
def add_error(self, *args: Any, **kwargs: Any) -> None:
"""Add an error."""
self.errors.append(Error(*args, **kwargs))

View File

@ -1,54 +0,0 @@
"""Generate supported_brands data."""
from __future__ import annotations
import black
from .model import Config, Integration
from .serializer import to_string
BASE = """
\"\"\"Automatically generated by hassfest.
To update, run python3 -m script.hassfest
\"\"\"
HAS_SUPPORTED_BRANDS = {}
""".strip()
def generate_and_validate(integrations: dict[str, Integration], config: Config) -> str:
"""Validate and generate supported_brands data."""
brands = [
domain
for domain, integration in sorted(integrations.items())
if integration.supported_brands
]
return black.format_str(BASE.format(to_string(brands)), mode=black.Mode())
def validate(integrations: dict[str, Integration], config: Config) -> None:
"""Validate supported_brands data."""
supported_brands_path = config.root / "homeassistant/generated/supported_brands.py"
config.cache["supported_brands"] = content = generate_and_validate(
integrations, config
)
if config.specific_integrations:
return
if supported_brands_path.read_text(encoding="utf-8") != content:
config.add_error(
"supported_brands",
"File supported_brands.py is not up to date. Run python3 -m script.hassfest",
fixable=True,
)
def generate(integrations: dict[str, Integration], config: Config):
"""Generate supported_brands data."""
supported_brands_path = config.root / "homeassistant/generated/supported_brands.py"
supported_brands_path.write_text(
f"{config.cache['supported_brands']}", encoding="utf-8"
)

View File

@ -23,13 +23,7 @@ from homeassistant.helpers.json import json_loads
from homeassistant.loader import async_get_integration
from homeassistant.setup import DATA_SETUP_TIME, async_setup_component
from tests.common import (
MockEntity,
MockEntityPlatform,
MockModule,
async_mock_service,
mock_integration,
)
from tests.common import MockEntity, MockEntityPlatform, async_mock_service
STATE_KEY_SHORT_NAMES = {
"entity_id": "e",
@ -1794,45 +1788,6 @@ async def test_validate_config_invalid(websocket_client, key, config, error):
assert msg["result"] == {key: {"valid": False, "error": error}}
async def test_supported_brands(hass, websocket_client):
"""Test supported brands."""
# Custom components without supported brands that override a built-in component with
# supported brand will still be listed in HAS_SUPPORTED_BRANDS and should be ignored.
mock_integration(
hass,
MockModule("override_without_brands"),
)
mock_integration(
hass,
MockModule("test", partial_manifest={"supported_brands": {"hello": "World"}}),
)
mock_integration(
hass,
MockModule(
"abcd", partial_manifest={"supported_brands": {"something": "Something"}}
),
)
with patch(
"homeassistant.generated.supported_brands.HAS_SUPPORTED_BRANDS",
("abcd", "test", "override_without_brands"),
):
await websocket_client.send_json({"id": 7, "type": "supported_brands"})
msg = await websocket_client.receive_json()
assert msg["id"] == 7
assert msg["type"] == const.TYPE_RESULT
assert msg["success"]
assert msg["result"] == {
"abcd": {
"something": "Something",
},
"test": {
"hello": "World",
},
}
async def test_message_coalescing(hass, websocket_client, hass_admin_user):
"""Test enabling message coalescing."""
await websocket_client.send_json(