Automatic discovery and setting up of devices
This commit is contained in:
parent
035d994705
commit
ba179bc638
13 changed files with 252 additions and 110 deletions
3
.gitmodules
vendored
3
.gitmodules
vendored
|
@ -4,3 +4,6 @@
|
|||
[submodule "homeassistant/external/pywemo"]
|
||||
path = homeassistant/external/pywemo
|
||||
url = https://github.com/balloob/pywemo.git
|
||||
[submodule "homeassistant/external/netdisco"]
|
||||
path = homeassistant/external/netdisco
|
||||
url = https://github.com/balloob/netdisco.git
|
||||
|
|
|
@ -51,6 +51,7 @@ class HomeAssistant(object):
|
|||
self.bus = EventBus(pool)
|
||||
self.services = ServiceRegistry(self.bus, pool)
|
||||
self.states = StateMachine(self.bus)
|
||||
self.components = []
|
||||
|
||||
self.config_dir = os.path.join(os.getcwd(), 'config')
|
||||
|
||||
|
|
|
@ -19,6 +19,33 @@ import homeassistant.loader as loader
|
|||
import homeassistant.components as core_components
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_component(hass, domain, config=None):
|
||||
""" Setup a component for Home Assistant. """
|
||||
if config is None:
|
||||
config = defaultdict(dict)
|
||||
|
||||
component = loader.get_component(domain)
|
||||
|
||||
try:
|
||||
if component.setup(hass, config):
|
||||
hass.components.append(component.DOMAIN)
|
||||
|
||||
_LOGGER.info("component %s initialized", domain)
|
||||
|
||||
return True
|
||||
|
||||
else:
|
||||
_LOGGER.error("component %s failed to initialize", domain)
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error during setup of component %s", domain)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-statements
|
||||
def from_config_dict(config, hass=None):
|
||||
"""
|
||||
|
@ -29,8 +56,6 @@ def from_config_dict(config, hass=None):
|
|||
if hass is None:
|
||||
hass = homeassistant.HomeAssistant()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
loader.prepare(hass)
|
||||
|
||||
# Make a copy because we are mutating it.
|
||||
|
@ -42,12 +67,12 @@ def from_config_dict(config, hass=None):
|
|||
if ' ' not in key and key != homeassistant.DOMAIN)
|
||||
|
||||
if not core_components.setup(hass, config):
|
||||
logger.error(("Home Assistant core failed to initialize. "
|
||||
"Further initialization aborted."))
|
||||
_LOGGER.error("Home Assistant core failed to initialize. "
|
||||
"Further initialization aborted.")
|
||||
|
||||
return hass
|
||||
|
||||
logger.info("Home Assistant core initialized")
|
||||
_LOGGER.info("Home Assistant core initialized")
|
||||
|
||||
# Setup the components
|
||||
|
||||
|
@ -57,22 +82,11 @@ def from_config_dict(config, hass=None):
|
|||
add_worker = True
|
||||
|
||||
for domain in loader.load_order_components(components):
|
||||
component = loader.get_component(domain)
|
||||
if setup_component(hass, domain, config):
|
||||
add_worker = add_worker and domain != "group"
|
||||
|
||||
try:
|
||||
if component.setup(hass, config):
|
||||
logger.info("component %s initialized", domain)
|
||||
|
||||
add_worker = add_worker and domain != "group"
|
||||
|
||||
if add_worker:
|
||||
hass.pool.add_worker()
|
||||
|
||||
else:
|
||||
logger.error("component %s failed to initialize", domain)
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Error during setup of component %s", domain)
|
||||
if add_worker:
|
||||
hass.pool.add_worker()
|
||||
|
||||
return hass
|
||||
|
||||
|
@ -112,7 +126,7 @@ def from_config_file(config_path, hass=None, enable_logging=True):
|
|||
logging.getLogger('').addHandler(err_handler)
|
||||
|
||||
else:
|
||||
logging.getLogger(__name__).error(
|
||||
_LOGGER.error(
|
||||
"Unable to setup error log %s (access denied)", err_log_path)
|
||||
|
||||
# Read config
|
||||
|
|
|
@ -6,13 +6,19 @@ Provides functionality to interact with Chromecasts.
|
|||
"""
|
||||
import logging
|
||||
|
||||
try:
|
||||
import pychromecast
|
||||
except ImportError:
|
||||
# Ignore, we will raise appropriate error later
|
||||
pass
|
||||
|
||||
from homeassistant.loader import get_component
|
||||
import homeassistant.util as util
|
||||
from homeassistant.helpers import extract_entity_ids
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_VOLUME_UP,
|
||||
SERVICE_VOLUME_DOWN, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY,
|
||||
SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREV_TRACK,
|
||||
CONF_HOSTS)
|
||||
SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREV_TRACK)
|
||||
|
||||
|
||||
DOMAIN = 'chromecast'
|
||||
|
@ -105,12 +111,30 @@ def media_prev_track(hass, entity_id=None):
|
|||
hass.services.call(DOMAIN, SERVICE_MEDIA_PREV_TRACK, data)
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals, too-many-branches
|
||||
def setup_chromecast(casts, host):
|
||||
""" Tries to convert host to Chromecast object and set it up. """
|
||||
try:
|
||||
cast = pychromecast.PyChromecast(host)
|
||||
|
||||
entity_id = util.ensure_unique_string(
|
||||
ENTITY_ID_FORMAT.format(
|
||||
util.slugify(cast.device.friendly_name)),
|
||||
casts.keys())
|
||||
|
||||
casts[entity_id] = cast
|
||||
|
||||
except pychromecast.ChromecastConnectionError:
|
||||
pass
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
# pylint: disable=unused-argument,too-many-locals
|
||||
""" Listen for chromecast events. """
|
||||
logger = logging.getLogger(__name__)
|
||||
discovery = get_component('discovery')
|
||||
|
||||
try:
|
||||
# pylint: disable=redefined-outer-name
|
||||
import pychromecast
|
||||
except ImportError:
|
||||
logger.exception(("Failed to import pychromecast. "
|
||||
|
@ -119,33 +143,24 @@ def setup(hass, config):
|
|||
|
||||
return False
|
||||
|
||||
if CONF_HOSTS in config[DOMAIN]:
|
||||
hosts = config[DOMAIN][CONF_HOSTS].split(",")
|
||||
casts = {}
|
||||
|
||||
# If no hosts given, scan for chromecasts
|
||||
else:
|
||||
# If discovery component not loaded, scan ourselves
|
||||
if discovery.DOMAIN not in hass.components:
|
||||
logger.info("Scanning for Chromecasts")
|
||||
hosts = pychromecast.discover_chromecasts()
|
||||
|
||||
casts = {}
|
||||
for host in hosts:
|
||||
setup_chromecast(casts, host)
|
||||
|
||||
for host in hosts:
|
||||
try:
|
||||
cast = pychromecast.PyChromecast(host)
|
||||
# pylint: disable=unused-argument
|
||||
def chromecast_discovered(service, info):
|
||||
""" Called when a Chromecast has been discovered. """
|
||||
logger.info("New Chromecast discovered: %s", info[0])
|
||||
setup_chromecast(casts, info[0])
|
||||
|
||||
entity_id = util.ensure_unique_string(
|
||||
ENTITY_ID_FORMAT.format(
|
||||
util.slugify(cast.device.friendly_name)),
|
||||
casts.keys())
|
||||
|
||||
casts[entity_id] = cast
|
||||
|
||||
except pychromecast.ChromecastConnectionError:
|
||||
pass
|
||||
|
||||
if not casts:
|
||||
logger.error("Could not find Chromecasts")
|
||||
return False
|
||||
discovery.listen(
|
||||
hass, discovery.services.GOOGLE_CAST, chromecast_discovered)
|
||||
|
||||
def update_chromecast_state(entity_id, chromecast):
|
||||
""" Retrieve state of Chromecast and update statemachine. """
|
||||
|
@ -194,10 +209,11 @@ def setup(hass, config):
|
|||
|
||||
def update_chromecast_states(time): # pylint: disable=unused-argument
|
||||
""" Updates all chromecast states. """
|
||||
logger.info("Updating Chromecast status")
|
||||
if casts:
|
||||
logger.info("Updating Chromecast status")
|
||||
|
||||
for entity_id, cast in casts.items():
|
||||
update_chromecast_state(entity_id, cast)
|
||||
for entity_id, cast in casts.items():
|
||||
update_chromecast_state(entity_id, cast)
|
||||
|
||||
def _service_to_entities(service):
|
||||
""" Helper method to get entities from service. """
|
||||
|
|
88
homeassistant/components/discovery.py
Normal file
88
homeassistant/components/discovery.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
"""
|
||||
Starts a service to scan in intervals for new devices.
|
||||
|
||||
Will emit EVENT_SERVICE_DISCOVERED whenever a new service has been discovered.
|
||||
|
||||
Knows which components handle certain types, will make sure they are
|
||||
loaded before the EVENT_SERVICE_DISCOVERED is fired.
|
||||
|
||||
"""
|
||||
import logging
|
||||
import threading
|
||||
|
||||
# pylint: disable=no-name-in-module, import-error
|
||||
from homeassistant.external.netdisco.netdisco import DiscoveryService
|
||||
import homeassistant.external.netdisco.netdisco.const as services
|
||||
|
||||
from homeassistant import bootstrap
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START, ATTR_SERVICE
|
||||
|
||||
DOMAIN = "discovery"
|
||||
DEPENDENCIES = []
|
||||
|
||||
EVENT_SERVICE_DISCOVERED = "service_discovered"
|
||||
|
||||
ATTR_DISCOVERED = "discovered"
|
||||
|
||||
SCAN_INTERVAL = 300 # seconds
|
||||
|
||||
SERVICE_HANDLERS = {
|
||||
services.BELKIN_WEMO: "switch",
|
||||
services.GOOGLE_CAST: "chromecast",
|
||||
services.PHILIPS_HUE: "light",
|
||||
}
|
||||
|
||||
|
||||
def listen(hass, service, callback):
|
||||
"""
|
||||
Setup listener for discovery of specific service.
|
||||
Service can be a string or a list/tuple.
|
||||
"""
|
||||
|
||||
if not isinstance(service, str):
|
||||
service = (service,)
|
||||
|
||||
def discovery_event_listener(event):
|
||||
""" Listens for discovery events. """
|
||||
if event.data[ATTR_SERVICE] in service:
|
||||
callback(event.data[ATTR_SERVICE], event.data[ATTR_DISCOVERED])
|
||||
|
||||
hass.bus.listen(EVENT_SERVICE_DISCOVERED, discovery_event_listener)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
""" Starts a discovery service. """
|
||||
|
||||
# Disable zeroconf logging, it spams
|
||||
logging.getLogger('zeroconf').setLevel(logging.CRITICAL)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
def new_service_listener(service, info):
|
||||
""" Called when a new service is found. """
|
||||
with lock:
|
||||
component = SERVICE_HANDLERS.get(service)
|
||||
|
||||
logger.info("Found new service: %s %s", service, info)
|
||||
|
||||
if component and component not in hass.components:
|
||||
if bootstrap.setup_component(hass, component, config):
|
||||
hass.pool.add_worker()
|
||||
|
||||
hass.bus.fire(EVENT_SERVICE_DISCOVERED, {
|
||||
ATTR_SERVICE: service,
|
||||
ATTR_DISCOVERED: info
|
||||
})
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def start_discovery(event):
|
||||
""" Start discovering. """
|
||||
netdisco = DiscoveryService(SCAN_INTERVAL)
|
||||
netdisco.add_listener(new_service_listener)
|
||||
netdisco.start()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_discovery)
|
||||
|
||||
return True
|
|
@ -132,8 +132,7 @@ class Group(object):
|
|||
def update_tracked_entity_ids(self, entity_ids):
|
||||
""" Update the tracked entity IDs. """
|
||||
self.stop()
|
||||
|
||||
self.tracking = list(entity_ids)
|
||||
self.tracking = tuple(entity_ids)
|
||||
self.group_on, self.group_off = None, None
|
||||
|
||||
self.force_update()
|
||||
|
@ -150,7 +149,8 @@ class Group(object):
|
|||
|
||||
# If parsing the entitys did not result in a state, set UNKNOWN
|
||||
if self.state is None:
|
||||
self.hass.states.set(self.entity_id, STATE_UNKNOWN)
|
||||
self.hass.states.set(
|
||||
self.entity_id, STATE_UNKNOWN, self.state_attr)
|
||||
|
||||
def start(self):
|
||||
""" Starts the tracking. """
|
||||
|
@ -182,25 +182,25 @@ class Group(object):
|
|||
|
||||
# There is already a group state
|
||||
cur_gr_state = self.hass.states.get(self.entity_id).state
|
||||
group_on, group_off = self.group_on, self.group_off
|
||||
|
||||
# if cur_gr_state = OFF and new_state = ON: set ON
|
||||
# if cur_gr_state = ON and new_state = OFF: research
|
||||
# else: ignore
|
||||
|
||||
if cur_gr_state == self.group_off and new_state.state == self.group_on:
|
||||
if cur_gr_state == group_off and new_state.state == group_on:
|
||||
|
||||
self.hass.states.set(
|
||||
self.entity_id, self.group_on, self.state_attr)
|
||||
self.entity_id, group_on, self.state_attr)
|
||||
|
||||
elif (cur_gr_state == self.group_on and
|
||||
new_state.state == self.group_off):
|
||||
elif (cur_gr_state == group_on and
|
||||
new_state.state == group_off):
|
||||
|
||||
# Check if any of the other states is still on
|
||||
if not any([self.hass.states.is_state(ent_id, self.group_on)
|
||||
for ent_id in self.tracking
|
||||
if entity_id != ent_id]):
|
||||
if not any(self.hass.states.is_state(ent_id, group_on)
|
||||
for ent_id in self.tracking if entity_id != ent_id):
|
||||
self.hass.states.set(
|
||||
self.entity_id, self.group_off, self.state_attr)
|
||||
self.entity_id, group_off, self.state_attr)
|
||||
|
||||
|
||||
def setup_group(hass, name, entity_ids, user_defined=True):
|
||||
|
|
|
@ -6,12 +6,13 @@ Component to interface with various switches that can be controlled remotely.
|
|||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.loader import get_component
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import (
|
||||
STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, ATTR_ENTITY_ID)
|
||||
from homeassistant.helpers import (
|
||||
extract_entity_ids, platform_devices_from_config)
|
||||
from homeassistant.components import group
|
||||
from homeassistant.components import group, discovery
|
||||
|
||||
DOMAIN = 'switch'
|
||||
DEPENDENCIES = []
|
||||
|
@ -27,6 +28,11 @@ ATTR_CURRENT_POWER_MWH = "current_power_mwh"
|
|||
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
# Maps discovered services to their platforms
|
||||
DISCOVERY = {
|
||||
discovery.services.BELKIN_WEMO: 'wemo'
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -58,21 +64,41 @@ def setup(hass, config):
|
|||
switches = platform_devices_from_config(
|
||||
config, DOMAIN, hass, ENTITY_ID_FORMAT, logger)
|
||||
|
||||
if not switches:
|
||||
return False
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@util.Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def update_states(now):
|
||||
""" Update states of all switches. """
|
||||
if switches:
|
||||
logger.info("Updating switch states")
|
||||
|
||||
logger.info("Updating switch states")
|
||||
|
||||
for switch in switches.values():
|
||||
switch.update_ha_state(hass)
|
||||
for switch in switches.values():
|
||||
switch.update_ha_state(hass)
|
||||
|
||||
update_states(None)
|
||||
|
||||
# Track all switches in a group
|
||||
switch_group = group.Group(
|
||||
hass, GROUP_NAME_ALL_SWITCHES, switches.keys(), False)
|
||||
|
||||
def switch_discovered(service, info):
|
||||
""" Called when a switch is discovered. """
|
||||
platform = get_component("{}.{}".format(DOMAIN, DISCOVERY[service]))
|
||||
|
||||
switch = platform.device_discovered(hass, config, info)
|
||||
|
||||
if switch is not None:
|
||||
switch.entity_id = util.ensure_unique_string(
|
||||
ENTITY_ID_FORMAT.format(util.slugify(switch.get_name())),
|
||||
switches.keys())
|
||||
|
||||
switches[switch.entity_id] = switch
|
||||
|
||||
switch.update_ha_state(hass)
|
||||
|
||||
switch_group.update_tracked_entity_ids(switches.keys())
|
||||
|
||||
discovery.listen(hass, discovery.services.BELKIN_WEMO, switch_discovered)
|
||||
|
||||
def handle_switch_service(service):
|
||||
""" Handles calls to the switch services. """
|
||||
target_switches = [switches[entity_id] for entity_id
|
||||
|
@ -90,9 +116,6 @@ def setup(hass, config):
|
|||
|
||||
switch.update_ha_state(hass)
|
||||
|
||||
# Track all switches in a group
|
||||
group.Group(hass, GROUP_NAME_ALL_SWITCHES, switches.keys(), False)
|
||||
|
||||
# Update state every 30 seconds
|
||||
hass.track_time_change(update_states, second=[0, 30])
|
||||
|
||||
|
|
|
@ -11,16 +11,9 @@ from homeassistant.components.switch import (
|
|||
def get_devices(hass, config):
|
||||
""" Find and return WeMo switches. """
|
||||
|
||||
try:
|
||||
# Pylint does not play nice if not every folders has an __init__.py
|
||||
# pylint: disable=no-name-in-module, import-error
|
||||
import homeassistant.external.pywemo.pywemo as pywemo
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).exception((
|
||||
"Failed to import pywemo. "
|
||||
"Did you maybe not run `git submodule init` "
|
||||
"and `git submodule update`?"))
|
||||
pywemo, _ = get_pywemo()
|
||||
|
||||
if pywemo is None:
|
||||
return []
|
||||
|
||||
logging.getLogger(__name__).info("Scanning for WeMo devices")
|
||||
|
@ -31,6 +24,36 @@ def get_devices(hass, config):
|
|||
if isinstance(switch, pywemo.Switch)]
|
||||
|
||||
|
||||
def device_discovered(hass, config, info):
|
||||
""" Called when a device is discovered. """
|
||||
_, discovery = get_pywemo()
|
||||
|
||||
if discovery is None:
|
||||
return
|
||||
|
||||
device = discovery.device_from_description(info)
|
||||
|
||||
return None if device is None else WemoSwitch(device)
|
||||
|
||||
|
||||
def get_pywemo():
|
||||
""" Tries to import PyWemo. """
|
||||
try:
|
||||
# pylint: disable=no-name-in-module, import-error
|
||||
import homeassistant.external.pywemo.pywemo as pywemo
|
||||
import homeassistant.external.pywemo.pywemo.discovery as discovery
|
||||
|
||||
return pywemo, discovery
|
||||
|
||||
except ImportError:
|
||||
logging.getLogger(__name__).exception((
|
||||
"Failed to import pywemo. "
|
||||
"Did you maybe not run `git submodule init` "
|
||||
"and `git submodule update`?"))
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
class WemoSwitch(ToggleDevice):
|
||||
""" represents a WeMo switch within home assistant. """
|
||||
def __init__(self, wemo):
|
||||
|
|
1
homeassistant/external/netdisco
vendored
Submodule
1
homeassistant/external/netdisco
vendored
Submodule
|
@ -0,0 +1 @@
|
|||
Subproject commit 20cb8863fce3ce7d771ae077ce29ecafe98f8960
|
2
homeassistant/external/pywemo
vendored
2
homeassistant/external/pywemo
vendored
|
@ -1 +1 @@
|
|||
Subproject commit 6355e04357cf78b38d293fae7bd418cf9f8d1ca0
|
||||
Subproject commit 7f6c383ded75f1273cbca28e858b8a8c96da66d4
|
|
@ -179,7 +179,7 @@ class Device(object):
|
|||
|
||||
def get_name(self):
|
||||
""" Returns the name of the device if any. """
|
||||
return None
|
||||
return "No Name"
|
||||
|
||||
def get_state(self):
|
||||
""" Returns state of the device. """
|
||||
|
|
|
@ -79,12 +79,3 @@ class TestChromecast(unittest.TestCase):
|
|||
self.assertEqual(service_name, call.service)
|
||||
self.assertEqual(self.test_entity,
|
||||
call.data.get(ATTR_ENTITY_ID))
|
||||
|
||||
def test_setup(self):
|
||||
"""
|
||||
Test Chromecast setup.
|
||||
We do not have access to a Chromecast while testing so test errors.
|
||||
In an ideal world we would create a mock pychromecast API..
|
||||
"""
|
||||
self.assertFalse(chromecast.setup(
|
||||
self.hass, {chromecast.DOMAIN: {CONF_HOSTS: '127.0.0.1'}}))
|
||||
|
|
|
@ -7,7 +7,6 @@ Tests switch component.
|
|||
# pylint: disable=too-many-public-methods,protected-access
|
||||
import unittest
|
||||
|
||||
import homeassistant as ha
|
||||
import homeassistant.loader as loader
|
||||
from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM
|
||||
import homeassistant.components.switch as switch
|
||||
|
@ -82,29 +81,12 @@ class TestSwitch(unittest.TestCase):
|
|||
self.assertTrue(switch.is_on(self.hass, self.switch_2.entity_id))
|
||||
self.assertTrue(switch.is_on(self.hass, self.switch_3.entity_id))
|
||||
|
||||
def test_setup(self):
|
||||
# Bogus config
|
||||
self.assertFalse(switch.setup(self.hass, {}))
|
||||
|
||||
self.assertFalse(switch.setup(self.hass, {switch.DOMAIN: {}}))
|
||||
|
||||
# Test with non-existing component
|
||||
self.assertFalse(switch.setup(
|
||||
self.hass, {switch.DOMAIN: {CONF_PLATFORM: 'nonexisting'}}
|
||||
))
|
||||
|
||||
def test_setup_two_platforms(self):
|
||||
""" Test with bad config. """
|
||||
# Test if switch component returns 0 switches
|
||||
test_platform = loader.get_component('switch.test')
|
||||
test_platform.init(True)
|
||||
|
||||
self.assertEqual(
|
||||
[], test_platform.get_switches(None, None))
|
||||
|
||||
self.assertFalse(switch.setup(
|
||||
self.hass, {switch.DOMAIN: {CONF_PLATFORM: 'test'}}
|
||||
))
|
||||
|
||||
# Test if we can load 2 platforms
|
||||
loader.set_component('switch.test2', test_platform)
|
||||
test_platform.init(False)
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue