Add Bond hub as a device for bond entities (#37772)
* Introduce Bond Hub concept * Read Hub version information when setting up entry * Link entities to Hub using via_device * Add test to verify created Hub device properties
This commit is contained in:
parent
6826a8829c
commit
c6ab2c5d0a
7 changed files with 136 additions and 50 deletions
|
@ -6,8 +6,10 @@ from bond import Bond
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .utils import BondHub
|
||||||
|
|
||||||
PLATFORMS = ["cover", "fan"]
|
PLATFORMS = ["cover", "fan"]
|
||||||
|
|
||||||
|
@ -23,7 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
|
||||||
host = entry.data[CONF_HOST]
|
host = entry.data[CONF_HOST]
|
||||||
token = entry.data[CONF_ACCESS_TOKEN]
|
token = entry.data[CONF_ACCESS_TOKEN]
|
||||||
|
|
||||||
hass.data[DOMAIN][entry.entry_id] = Bond(bondIp=host, bondToken=token)
|
bond = Bond(bondIp=host, bondToken=token)
|
||||||
|
hub = BondHub(bond)
|
||||||
|
await hass.async_add_executor_job(hub.setup)
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = hub
|
||||||
|
|
||||||
|
device_registry = await dr.async_get_registry(hass)
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=entry.entry_id,
|
||||||
|
identifiers={(DOMAIN, hub.bond_id)},
|
||||||
|
manufacturer="Olibra",
|
||||||
|
name=hub.bond_id,
|
||||||
|
model=hub.target,
|
||||||
|
sw_version=hub.fw_ver,
|
||||||
|
)
|
||||||
|
|
||||||
for component in PLATFORMS:
|
for component in PLATFORMS:
|
||||||
hass.async_create_task(
|
hass.async_create_task(
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""Support for Bond covers."""
|
"""Support for Bond covers."""
|
||||||
from typing import Any, Callable, List, Optional
|
from typing import Any, Callable, List, Optional
|
||||||
|
|
||||||
from bond import Bond, DeviceTypes
|
from bond import DeviceTypes
|
||||||
|
|
||||||
from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity
|
from homeassistant.components.cover import DEVICE_CLASS_SHADE, CoverEntity
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
@ -10,7 +10,7 @@ from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .entity import BondEntity
|
from .entity import BondEntity
|
||||||
from .utils import BondDevice, get_bond_devices
|
from .utils import BondDevice, BondHub
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
|
@ -19,12 +19,12 @@ async def async_setup_entry(
|
||||||
async_add_entities: Callable[[List[Entity], bool], None],
|
async_add_entities: Callable[[List[Entity], bool], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Bond cover devices."""
|
"""Set up Bond cover devices."""
|
||||||
bond: Bond = hass.data[DOMAIN][entry.entry_id]
|
hub: BondHub = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
devices = await hass.async_add_executor_job(get_bond_devices, hass, bond)
|
devices = await hass.async_add_executor_job(hub.get_bond_devices)
|
||||||
|
|
||||||
covers = [
|
covers = [
|
||||||
BondCover(bond, device)
|
BondCover(hub, device)
|
||||||
for device in devices
|
for device in devices
|
||||||
if device.type == DeviceTypes.MOTORIZED_SHADES
|
if device.type == DeviceTypes.MOTORIZED_SHADES
|
||||||
]
|
]
|
||||||
|
@ -35,9 +35,9 @@ async def async_setup_entry(
|
||||||
class BondCover(BondEntity, CoverEntity):
|
class BondCover(BondEntity, CoverEntity):
|
||||||
"""Representation of a Bond cover."""
|
"""Representation of a Bond cover."""
|
||||||
|
|
||||||
def __init__(self, bond: Bond, device: BondDevice):
|
def __init__(self, hub: BondHub, device: BondDevice):
|
||||||
"""Create HA entity representing Bond cover."""
|
"""Create HA entity representing Bond cover."""
|
||||||
super().__init__(bond, device)
|
super().__init__(hub, device)
|
||||||
|
|
||||||
self._closed: Optional[bool] = None
|
self._closed: Optional[bool] = None
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ class BondCover(BondEntity, CoverEntity):
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Fetch assumed state of the cover from the hub using API."""
|
"""Fetch assumed state of the cover from the hub using API."""
|
||||||
state: dict = self._bond.getDeviceState(self._device.device_id)
|
state: dict = self._hub.bond.getDeviceState(self._device.device_id)
|
||||||
cover_open = state.get("open")
|
cover_open = state.get("open")
|
||||||
self._closed = True if cover_open == 0 else False if cover_open == 1 else None
|
self._closed = True if cover_open == 0 else False if cover_open == 1 else None
|
||||||
|
|
||||||
|
@ -59,12 +59,12 @@ class BondCover(BondEntity, CoverEntity):
|
||||||
|
|
||||||
def open_cover(self, **kwargs: Any) -> None:
|
def open_cover(self, **kwargs: Any) -> None:
|
||||||
"""Open the cover."""
|
"""Open the cover."""
|
||||||
self._bond.open(self._device.device_id)
|
self._hub.bond.open(self._device.device_id)
|
||||||
|
|
||||||
def close_cover(self, **kwargs: Any) -> None:
|
def close_cover(self, **kwargs: Any) -> None:
|
||||||
"""Close cover."""
|
"""Close cover."""
|
||||||
self._bond.close(self._device.device_id)
|
self._hub.bond.close(self._device.device_id)
|
||||||
|
|
||||||
def stop_cover(self, **kwargs):
|
def stop_cover(self, **kwargs):
|
||||||
"""Hold cover."""
|
"""Hold cover."""
|
||||||
self._bond.hold(self._device.device_id)
|
self._hub.bond.hold(self._device.device_id)
|
||||||
|
|
|
@ -1,20 +1,18 @@
|
||||||
"""An abstract class common to all Bond entities."""
|
"""An abstract class common to all Bond entities."""
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from bond import Bond
|
|
||||||
|
|
||||||
from homeassistant.components.bond.utils import BondDevice
|
|
||||||
from homeassistant.const import ATTR_NAME
|
from homeassistant.const import ATTR_NAME
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .utils import BondDevice, BondHub
|
||||||
|
|
||||||
|
|
||||||
class BondEntity:
|
class BondEntity:
|
||||||
"""Generic Bond entity encapsulating common features of any Bond controlled device."""
|
"""Generic Bond entity encapsulating common features of any Bond controlled device."""
|
||||||
|
|
||||||
def __init__(self, bond: Bond, device: BondDevice):
|
def __init__(self, hub: BondHub, device: BondDevice):
|
||||||
"""Initialize entity with API and device info."""
|
"""Initialize entity with API and device info."""
|
||||||
self._bond = bond
|
self._hub = hub
|
||||||
self._device = device
|
self._device = device
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -30,7 +28,11 @@ class BondEntity:
|
||||||
@property
|
@property
|
||||||
def device_info(self) -> Optional[Dict[str, Any]]:
|
def device_info(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get a an HA device representing this Bond controlled device."""
|
"""Get a an HA device representing this Bond controlled device."""
|
||||||
return {ATTR_NAME: self.name, "identifiers": {(DOMAIN, self._device.device_id)}}
|
return {
|
||||||
|
ATTR_NAME: self.name,
|
||||||
|
"identifiers": {(DOMAIN, self._device.device_id)},
|
||||||
|
"via_device": (DOMAIN, self._hub.bond_id),
|
||||||
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def assumed_state(self) -> bool:
|
def assumed_state(self) -> bool:
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"""Support for Bond fans."""
|
"""Support for Bond fans."""
|
||||||
from typing import Any, Callable, List, Optional
|
from typing import Any, Callable, List, Optional
|
||||||
|
|
||||||
from bond import Bond, DeviceTypes
|
from bond import DeviceTypes
|
||||||
|
|
||||||
from homeassistant.components.fan import (
|
from homeassistant.components.fan import (
|
||||||
SPEED_HIGH,
|
SPEED_HIGH,
|
||||||
|
@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .entity import BondEntity
|
from .entity import BondEntity
|
||||||
from .utils import BondDevice, get_bond_devices
|
from .utils import BondDevice, BondHub
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
|
@ -26,12 +26,12 @@ async def async_setup_entry(
|
||||||
async_add_entities: Callable[[List[Entity], bool], None],
|
async_add_entities: Callable[[List[Entity], bool], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Bond fan devices."""
|
"""Set up Bond fan devices."""
|
||||||
bond: Bond = hass.data[DOMAIN][entry.entry_id]
|
hub: BondHub = hass.data[DOMAIN][entry.entry_id]
|
||||||
|
|
||||||
devices = await hass.async_add_executor_job(get_bond_devices, hass, bond)
|
devices = await hass.async_add_executor_job(hub.get_bond_devices)
|
||||||
|
|
||||||
fans = [
|
fans = [
|
||||||
BondFan(bond, device)
|
BondFan(hub, device)
|
||||||
for device in devices
|
for device in devices
|
||||||
if device.type == DeviceTypes.CEILING_FAN
|
if device.type == DeviceTypes.CEILING_FAN
|
||||||
]
|
]
|
||||||
|
@ -42,9 +42,9 @@ async def async_setup_entry(
|
||||||
class BondFan(BondEntity, FanEntity):
|
class BondFan(BondEntity, FanEntity):
|
||||||
"""Representation of a Bond fan."""
|
"""Representation of a Bond fan."""
|
||||||
|
|
||||||
def __init__(self, bond: Bond, device: BondDevice):
|
def __init__(self, hub: BondHub, device: BondDevice):
|
||||||
"""Create HA entity representing Bond fan."""
|
"""Create HA entity representing Bond fan."""
|
||||||
super().__init__(bond, device)
|
super().__init__(hub, device)
|
||||||
|
|
||||||
self._power: Optional[bool] = None
|
self._power: Optional[bool] = None
|
||||||
self._speed: Optional[int] = None
|
self._speed: Optional[int] = None
|
||||||
|
@ -74,21 +74,21 @@ class BondFan(BondEntity, FanEntity):
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Fetch assumed state of the fan from the hub using API."""
|
"""Fetch assumed state of the fan from the hub using API."""
|
||||||
state: dict = self._bond.getDeviceState(self._device.device_id)
|
state: dict = self._hub.bond.getDeviceState(self._device.device_id)
|
||||||
self._power = state.get("power")
|
self._power = state.get("power")
|
||||||
self._speed = state.get("speed")
|
self._speed = state.get("speed")
|
||||||
|
|
||||||
def set_speed(self, speed: str) -> None:
|
def set_speed(self, speed: str) -> None:
|
||||||
"""Set the desired speed for the fan."""
|
"""Set the desired speed for the fan."""
|
||||||
speed_index = self.speed_list.index(speed)
|
speed_index = self.speed_list.index(speed)
|
||||||
self._bond.setSpeed(self._device.device_id, speed=speed_index)
|
self._hub.bond.setSpeed(self._device.device_id, speed=speed_index)
|
||||||
|
|
||||||
def turn_on(self, speed: Optional[str] = None, **kwargs) -> None:
|
def turn_on(self, speed: Optional[str] = None, **kwargs) -> None:
|
||||||
"""Turn on the fan."""
|
"""Turn on the fan."""
|
||||||
if speed is not None:
|
if speed is not None:
|
||||||
self.set_speed(speed)
|
self.set_speed(speed)
|
||||||
self._bond.turnOn(self._device.device_id)
|
self._hub.bond.turnOn(self._device.device_id)
|
||||||
|
|
||||||
def turn_off(self, **kwargs: Any) -> None:
|
def turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn the fan off."""
|
"""Turn the fan off."""
|
||||||
self._bond.turnOff(self._device.device_id)
|
self._hub.bond.turnOff(self._device.device_id)
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
"""Reusable utilities for the Bond component."""
|
"""Reusable utilities for the Bond component."""
|
||||||
|
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from bond import Bond
|
from bond import Bond
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
|
|
||||||
|
|
||||||
class BondDevice:
|
class BondDevice:
|
||||||
"""Helper device class to hold ID and attributes together."""
|
"""Helper device class to hold ID and attributes together."""
|
||||||
|
@ -31,10 +29,38 @@ class BondDevice:
|
||||||
return command in actions
|
return command in actions
|
||||||
|
|
||||||
|
|
||||||
def get_bond_devices(hass: HomeAssistant, bond: Bond) -> List[BondDevice]:
|
class BondHub:
|
||||||
"""Fetch all available devices using Bond API."""
|
"""Hub device representing Bond Bridge."""
|
||||||
device_ids = bond.getDeviceIds()
|
|
||||||
devices = [
|
def __init__(self, bond: Bond):
|
||||||
BondDevice(device_id, bond.getDevice(device_id)) for device_id in device_ids
|
"""Initialize Bond Hub."""
|
||||||
]
|
self.bond: Bond = bond
|
||||||
return devices
|
self._version: Optional[dict] = None
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
"""Read hub version information."""
|
||||||
|
self._version = self.bond.getVersion()
|
||||||
|
|
||||||
|
def get_bond_devices(self) -> List[BondDevice]:
|
||||||
|
"""Fetch all available devices using Bond API."""
|
||||||
|
device_ids = self.bond.getDeviceIds()
|
||||||
|
devices = [
|
||||||
|
BondDevice(device_id, self.bond.getDevice(device_id))
|
||||||
|
for device_id in device_ids
|
||||||
|
]
|
||||||
|
return devices
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bond_id(self) -> str:
|
||||||
|
"""Return unique Bond ID for this hub."""
|
||||||
|
return self._version["bondid"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target(self) -> str:
|
||||||
|
"""Return this hub model."""
|
||||||
|
return self._version.get("target")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fw_ver(self) -> str:
|
||||||
|
"""Return this hub firmware version."""
|
||||||
|
return self._version.get("fw_ver")
|
||||||
|
|
|
@ -9,6 +9,23 @@ from homeassistant.setup import async_setup_component
|
||||||
from tests.async_mock import patch
|
from tests.async_mock import patch
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
MOCK_HUB_VERSION: dict = {"bondid": "test-bond-id"}
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_bond_entity(
|
||||||
|
hass: core.HomeAssistant, config_entry: MockConfigEntry, hub_version=None
|
||||||
|
):
|
||||||
|
"""Set up Bond entity."""
|
||||||
|
if hub_version is None:
|
||||||
|
hub_version = MOCK_HUB_VERSION
|
||||||
|
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.bond.Bond.getVersion", return_value=hub_version
|
||||||
|
):
|
||||||
|
return await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
async def setup_platform(
|
async def setup_platform(
|
||||||
hass: core.HomeAssistant, platform: str, discovered_device: Dict[str, Any]
|
hass: core.HomeAssistant, platform: str, discovered_device: Dict[str, Any]
|
||||||
|
@ -21,6 +38,8 @@ async def setup_platform(
|
||||||
mock_entry.add_to_hass(hass)
|
mock_entry.add_to_hass(hass)
|
||||||
|
|
||||||
with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch(
|
with patch("homeassistant.components.bond.PLATFORMS", [platform]), patch(
|
||||||
|
"homeassistant.components.bond.Bond.getVersion", return_value=MOCK_HUB_VERSION
|
||||||
|
), patch(
|
||||||
"homeassistant.components.bond.Bond.getDeviceIds",
|
"homeassistant.components.bond.Bond.getDeviceIds",
|
||||||
return_value=["bond-device-id"],
|
return_value=["bond-device-id"],
|
||||||
), patch(
|
), patch(
|
||||||
|
|
|
@ -3,8 +3,11 @@ from homeassistant.components.bond.const import DOMAIN
|
||||||
from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
|
from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
|
||||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from .common import setup_bond_entity
|
||||||
|
|
||||||
from tests.async_mock import patch
|
from tests.async_mock import patch
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
@ -16,22 +19,45 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant):
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
async def test_async_setup_entry_sets_up_supported_domains(hass: HomeAssistant):
|
async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAssistant):
|
||||||
"""Test that configuring entry sets up cover domain."""
|
"""Test that configuring entry sets up cover domain."""
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"},
|
domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"},
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.bond.cover.async_setup_entry"
|
"homeassistant.components.bond.cover.async_setup_entry"
|
||||||
) as mock_cover_async_setup_entry:
|
) as mock_cover_async_setup_entry, patch(
|
||||||
result = await hass.config_entries.async_setup(config_entry.entry_id)
|
"homeassistant.components.bond.fan.async_setup_entry"
|
||||||
|
) as mock_fan_async_setup_entry:
|
||||||
|
result = await setup_bond_entity(
|
||||||
|
hass,
|
||||||
|
config_entry,
|
||||||
|
hub_version={
|
||||||
|
"bondid": "test-bond-id",
|
||||||
|
"target": "test-model",
|
||||||
|
"fw_ver": "test-version",
|
||||||
|
},
|
||||||
|
)
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert config_entry.entry_id in hass.data[DOMAIN]
|
||||||
|
assert config_entry.state == ENTRY_STATE_LOADED
|
||||||
|
|
||||||
|
# verify hub device is registered correctly
|
||||||
|
device_registry = await dr.async_get_registry(hass)
|
||||||
|
hub = device_registry.async_get_device(
|
||||||
|
identifiers={(DOMAIN, "test-bond-id")}, connections=set()
|
||||||
|
)
|
||||||
|
assert hub.name == "test-bond-id"
|
||||||
|
assert hub.manufacturer == "Olibra"
|
||||||
|
assert hub.model == "test-model"
|
||||||
|
assert hub.sw_version == "test-version"
|
||||||
|
|
||||||
|
# verify supported domains are setup
|
||||||
assert len(mock_cover_async_setup_entry.mock_calls) == 1
|
assert len(mock_cover_async_setup_entry.mock_calls) == 1
|
||||||
|
assert len(mock_fan_async_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_unload_config_entry(hass: HomeAssistant):
|
async def test_unload_config_entry(hass: HomeAssistant):
|
||||||
|
@ -39,15 +65,13 @@ async def test_unload_config_entry(hass: HomeAssistant):
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"},
|
domain=DOMAIN, data={CONF_HOST: "1.1.1.1", CONF_ACCESS_TOKEN: "test-token"},
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
|
||||||
with patch(
|
with patch("homeassistant.components.bond.cover.async_setup_entry"), patch(
|
||||||
"homeassistant.components.bond.cover.async_setup_entry", return_value=True
|
"homeassistant.components.bond.fan.async_setup_entry"
|
||||||
):
|
):
|
||||||
result = await hass.config_entries.async_setup(config_entry.entry_id)
|
result = await setup_bond_entity(hass, config_entry)
|
||||||
assert result is True
|
assert result is True
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert config_entry.entry_id in hass.data[DOMAIN]
|
|
||||||
assert config_entry.state == ENTRY_STATE_LOADED
|
|
||||||
|
|
||||||
await hass.config_entries.async_unload(config_entry.entry_id)
|
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue