Add Switch 2 wake capture and replay
This commit is contained in:
parent
8818f1f6e9
commit
6f58d345e0
22 changed files with 1436 additions and 3 deletions
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
#include <uni.h>
|
||||
#include "platform/pico/controller_color_config.h"
|
||||
#include "input/switch2_wake.h"
|
||||
|
||||
namespace {
|
||||
|
||||
|
|
@ -44,6 +45,8 @@ bool expect_configuration_timer_prearmed = false;
|
|||
uint32_t expected_configuration_timer_add_count = 0;
|
||||
int cyw43_init_calls = 0;
|
||||
int uni_init_calls = 0;
|
||||
int switch2_wake_initializations = 0;
|
||||
int switch2_wake_requests = 0;
|
||||
int device_disconnect_calls = 0;
|
||||
uni_hid_device_t* last_disconnected_device = nullptr;
|
||||
uni_hid_device_t* lookup_devices[8]{};
|
||||
|
|
@ -442,6 +445,19 @@ uint32_t btstack_run_loop_get_time_ms() {
|
|||
}
|
||||
|
||||
|
||||
void switch2_wake_initialize() {
|
||||
++switch2_wake_initializations;
|
||||
}
|
||||
|
||||
|
||||
bool switch2_wake_request() {
|
||||
++switch2_wake_requests;
|
||||
return true;
|
||||
}
|
||||
|
||||
void switch2_wake_diagnostics(Switch2WakeDiagnostics*) {
|
||||
}
|
||||
|
||||
#include "core/controller_identity.cpp"
|
||||
#include "input/bluepad32_input_backend.cpp"
|
||||
|
||||
|
|
@ -548,7 +564,8 @@ void start_backend() {
|
|||
kClassicLinkSupervisionTimeout &&
|
||||
!bondable && accepted_stk_methods == 0 &&
|
||||
!ssp_auto_accept && pairing_event_handler != nullptr &&
|
||||
identity_event_handler != nullptr,
|
||||
identity_event_handler != nullptr &&
|
||||
switch2_wake_initializations == 1,
|
||||
"initialization must register Classic and BLE identity policy");
|
||||
}
|
||||
void start_pairing_backend() {
|
||||
|
|
@ -2373,6 +2390,51 @@ void test_flash_core_start_contract() {
|
|||
"backend start must remain idempotent");
|
||||
}
|
||||
|
||||
|
||||
|
||||
void test_system_button_wake_trigger() {
|
||||
start_pairing_backend();
|
||||
uni_hid_device_t controller = device(0);
|
||||
require(platform_on_device_ready(&controller) == UNI_ERROR_SUCCESS,
|
||||
"wake trigger controller did not become ready");
|
||||
|
||||
uni_controller_t input{};
|
||||
input.klass = UNI_CONTROLLER_CLASS_GAMEPAD;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 0,
|
||||
"neutral input requested a wake burst");
|
||||
|
||||
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 0,
|
||||
"plain system button requested a wake burst");
|
||||
|
||||
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 1,
|
||||
"held L+R+System chord did not produce exactly one wake request");
|
||||
|
||||
input.gamepad.buttons = 0;
|
||||
input.gamepad.misc_buttons = 0;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
input.gamepad.buttons = BUTTON_SHOULDER_L | BUTTON_SHOULDER_R;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 1,
|
||||
"L+R without System requested wake");
|
||||
input.gamepad.misc_buttons = MISC_BUTTON_SYSTEM;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 2,
|
||||
"second L+R+System chord edge did not request wake");
|
||||
|
||||
input.gamepad.buttons = 0;
|
||||
input.gamepad.misc_buttons = MISC_BUTTON_CAPTURE;
|
||||
platform_on_controller_data(&controller, &input);
|
||||
require(switch2_wake_requests == 2,
|
||||
"non-system misc button requested wake");
|
||||
}
|
||||
|
||||
|
||||
void test_flash_core_init_fatal() {
|
||||
bluepad32_input_backend_init();
|
||||
flash_core_init_result = false;
|
||||
|
|
@ -2424,6 +2486,8 @@ int main(int argc, char** argv) {
|
|||
test_configuration_timer_rearms_before_storage_work();
|
||||
} else if (scenario == "flash-core-start") {
|
||||
test_flash_core_start_contract();
|
||||
} else if (scenario == "system-wake") {
|
||||
test_system_button_wake_trigger();
|
||||
} else if (scenario == "flash-core-failure") {
|
||||
test_flash_core_init_fatal();
|
||||
} else {
|
||||
|
|
|
|||
57
tests/switch2_wake_native_stubs/btstack.h
Normal file
57
tests/switch2_wake_native_stubs/btstack.h
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
using bd_addr_t = uint8_t[6];
|
||||
using btstack_packet_handler_t = void (*)(uint8_t, uint16_t, uint8_t*, uint16_t);
|
||||
struct btstack_packet_callback_registration_t {
|
||||
void* item;
|
||||
btstack_packet_handler_t callback;
|
||||
};
|
||||
struct btstack_timer_source_t {
|
||||
void (*handler)(btstack_timer_source_t*);
|
||||
uint32_t timeout_ms;
|
||||
uint32_t add_count;
|
||||
};
|
||||
struct hci_cmd_t {
|
||||
uint16_t opcode;
|
||||
const char* format;
|
||||
};
|
||||
|
||||
enum {
|
||||
ERROR_CODE_SUCCESS = 0,
|
||||
HCI_EVENT_PACKET = 4,
|
||||
HCI_EVENT_COMMAND_COMPLETE = 0x0e,
|
||||
BD_ADDR_TYPE_LE_PUBLIC = 0,
|
||||
};
|
||||
|
||||
extern const hci_cmd_t hci_read_bd_addr;
|
||||
extern const hci_cmd_t hci_le_set_advertising_parameters;
|
||||
extern const hci_cmd_t hci_le_set_advertising_data;
|
||||
extern const hci_cmd_t hci_le_set_advertise_enable;
|
||||
|
||||
bool hci_can_send_command_packet_now();
|
||||
uint8_t hci_send_cmd(const hci_cmd_t* command, ...);
|
||||
void hci_add_event_handler(btstack_packet_callback_registration_t* registration);
|
||||
void gap_local_bd_addr(bd_addr_t output);
|
||||
uint32_t btstack_run_loop_get_time_ms();
|
||||
void btstack_run_loop_set_timer_handler(
|
||||
btstack_timer_source_t* timer,
|
||||
void (*handler)(btstack_timer_source_t*));
|
||||
void btstack_run_loop_set_timer(btstack_timer_source_t* timer, uint32_t timeout_ms);
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer);
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t* timer);
|
||||
|
||||
inline uint8_t hci_event_packet_get_type(const uint8_t* event) {
|
||||
return event[0];
|
||||
}
|
||||
inline uint16_t hci_event_command_complete_get_command_opcode(
|
||||
const uint8_t* event) {
|
||||
return static_cast<uint16_t>(event[3]) |
|
||||
static_cast<uint16_t>(event[4] << 8);
|
||||
}
|
||||
inline const uint8_t* hci_event_command_complete_get_return_parameters(
|
||||
const uint8_t* event) {
|
||||
return &event[5];
|
||||
}
|
||||
249
tests/switch2_wake_test.cpp
Normal file
249
tests/switch2_wake_test.cpp
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
#include <cstdarg>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
|
||||
#include <btstack.h>
|
||||
|
||||
namespace {
|
||||
|
||||
struct SubmittedCommand {
|
||||
uint16_t opcode;
|
||||
uint8_t address[6];
|
||||
uint16_t interval_min;
|
||||
uint16_t interval_max;
|
||||
uint8_t advertising_type;
|
||||
uint8_t own_address_type;
|
||||
uint8_t channel_map;
|
||||
uint8_t filter_policy;
|
||||
uint8_t data[31];
|
||||
uint8_t data_length;
|
||||
uint8_t enabled;
|
||||
};
|
||||
|
||||
uint32_t now_ms;
|
||||
bd_addr_t original_address = {0xA8, 0x59, 0x5F, 0x9E, 0x37, 0x65};
|
||||
btstack_packet_handler_t event_handler;
|
||||
btstack_timer_source_t* installed_timer;
|
||||
SubmittedCommand submitted[16]{};
|
||||
size_t submitted_count;
|
||||
bool command_available = true;
|
||||
|
||||
void require(bool condition, const char* message) {
|
||||
if (!condition) {
|
||||
std::cerr << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const hci_cmd_t hci_read_bd_addr = {0x1009, "read-address"};
|
||||
const hci_cmd_t hci_le_set_advertising_parameters = {0x2006, "params"};
|
||||
const hci_cmd_t hci_le_set_advertising_data = {0x2008, "data"};
|
||||
const hci_cmd_t hci_le_set_advertise_enable = {0x200a, "enable"};
|
||||
|
||||
bool hci_can_send_command_packet_now() {
|
||||
return command_available;
|
||||
}
|
||||
|
||||
uint8_t hci_send_cmd(const hci_cmd_t* command, ...) {
|
||||
require(command_available, "submitted without an HCI command credit");
|
||||
require(submitted_count < 16, "command capture overflow");
|
||||
command_available = false;
|
||||
SubmittedCommand& output = submitted[submitted_count++];
|
||||
output.opcode = command->opcode;
|
||||
va_list arguments;
|
||||
va_start(arguments, command);
|
||||
if (command->opcode == 0xfc01) {
|
||||
const uint8_t* address = va_arg(arguments, const uint8_t*);
|
||||
memcpy(output.address, address, sizeof(output.address));
|
||||
} else if (command->opcode == 0x2006) {
|
||||
output.interval_min = static_cast<uint16_t>(va_arg(arguments, int));
|
||||
output.interval_max = static_cast<uint16_t>(va_arg(arguments, int));
|
||||
output.advertising_type = static_cast<uint8_t>(va_arg(arguments, int));
|
||||
output.own_address_type = static_cast<uint8_t>(va_arg(arguments, int));
|
||||
(void)va_arg(arguments, int);
|
||||
(void)va_arg(arguments, const uint8_t*);
|
||||
output.channel_map = static_cast<uint8_t>(va_arg(arguments, int));
|
||||
output.filter_policy = static_cast<uint8_t>(va_arg(arguments, int));
|
||||
} else if (command->opcode == 0x2008) {
|
||||
output.data_length = static_cast<uint8_t>(va_arg(arguments, int));
|
||||
const uint8_t* data = va_arg(arguments, const uint8_t*);
|
||||
memcpy(output.data, data, output.data_length);
|
||||
} else if (command->opcode == 0x200a) {
|
||||
output.enabled = static_cast<uint8_t>(va_arg(arguments, int));
|
||||
}
|
||||
va_end(arguments);
|
||||
return ERROR_CODE_SUCCESS;
|
||||
}
|
||||
|
||||
void hci_add_event_handler(
|
||||
btstack_packet_callback_registration_t* registration) {
|
||||
event_handler = registration->callback;
|
||||
}
|
||||
|
||||
void gap_local_bd_addr(bd_addr_t output) {
|
||||
memcpy(output, original_address, sizeof(original_address));
|
||||
}
|
||||
|
||||
uint32_t btstack_run_loop_get_time_ms() {
|
||||
return now_ms;
|
||||
}
|
||||
|
||||
void btstack_run_loop_set_timer_handler(
|
||||
btstack_timer_source_t* timer,
|
||||
void (*handler)(btstack_timer_source_t*)) {
|
||||
timer->handler = handler;
|
||||
installed_timer = timer;
|
||||
}
|
||||
|
||||
void btstack_run_loop_set_timer(
|
||||
btstack_timer_source_t* timer, uint32_t timeout_ms) {
|
||||
timer->timeout_ms = timeout_ms;
|
||||
}
|
||||
|
||||
void btstack_run_loop_add_timer(btstack_timer_source_t* timer) {
|
||||
++timer->add_count;
|
||||
}
|
||||
int btstack_run_loop_remove_timer(btstack_timer_source_t*) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
#define SWITCH2_WAKE_CONFIGURED 1
|
||||
#define SWITCH2_WAKE_SOURCE_ADDRESS_BYTES \
|
||||
{0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00}
|
||||
#define SWITCH2_WAKE_ADVERTISEMENT_DATA_BYTES \
|
||||
{0x02, 0x01, 0x06, 0x1B, 0xFF, 0x53, 0x05, 0x01, 0x00, 0x03, 0x7E, \
|
||||
0x05, 0x66, 0x20, 0x00, 0x01, 0x81, 0xBD, 0xD6, 0xF7, 0xEB, 0xF1, \
|
||||
0x48, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||
#include "input/switch2_wake.cpp"
|
||||
|
||||
namespace {
|
||||
|
||||
void complete(uint16_t opcode, uint8_t status = 0,
|
||||
const uint8_t* address = nullptr) {
|
||||
uint8_t event[12] = {
|
||||
HCI_EVENT_COMMAND_COMPLETE,
|
||||
static_cast<uint8_t>(address == nullptr ? 4 : 10),
|
||||
1,
|
||||
static_cast<uint8_t>(opcode), static_cast<uint8_t>(opcode >> 8),
|
||||
status,
|
||||
};
|
||||
if (address != nullptr) {
|
||||
for (size_t index = 0; index < 6; ++index) {
|
||||
event[6 + index] = address[5 - index];
|
||||
}
|
||||
}
|
||||
command_available = true;
|
||||
event_handler(
|
||||
HCI_EVENT_PACKET, 0, event,
|
||||
static_cast<uint16_t>(address == nullptr ? 6 : 12));
|
||||
}
|
||||
|
||||
void run_task() {
|
||||
require(installed_timer != nullptr && installed_timer->handler != nullptr,
|
||||
"wake timer was not installed");
|
||||
installed_timer->handler(installed_timer);
|
||||
}
|
||||
|
||||
void require_opcode(size_t index, uint16_t opcode) {
|
||||
require(index < submitted_count && submitted[index].opcode == opcode,
|
||||
"unexpected HCI command sequence");
|
||||
}
|
||||
|
||||
void test_temporary_identity_wake_and_restore() {
|
||||
switch2_wake_initialize();
|
||||
Switch2WakeDiagnostics diagnostics{};
|
||||
switch2_wake_diagnostics(&diagnostics);
|
||||
require(diagnostics.configured && !diagnostics.busy && !g_timer_armed,
|
||||
"configured wake module did not initialize dormant");
|
||||
require(switch2_wake_request() && !switch2_wake_request(),
|
||||
"wake requests were not bounded while busy");
|
||||
|
||||
run_task();
|
||||
require_opcode(0, 0xfc01);
|
||||
const uint8_t wake_address[] = {0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00};
|
||||
require(memcmp(submitted[0].address, wake_address, 6) == 0,
|
||||
"wake address did not reach the radio command");
|
||||
complete(0xfc01);
|
||||
|
||||
run_task();
|
||||
require_opcode(1, 0x2006);
|
||||
require(submitted[1].interval_min == 0x20 &&
|
||||
submitted[1].interval_max == 0x20 &&
|
||||
submitted[1].advertising_type == 3 &&
|
||||
submitted[1].own_address_type == 0 &&
|
||||
submitted[1].channel_map == 7 &&
|
||||
submitted[1].filter_policy == 0,
|
||||
"known-working advertising parameters changed");
|
||||
complete(0x2006);
|
||||
|
||||
run_task();
|
||||
require_opcode(2, 0x2008);
|
||||
require(submitted[2].data_length == 31 &&
|
||||
submitted[2].data[16] == 0x81,
|
||||
"captured wake payload was not submitted intact");
|
||||
complete(0x2008);
|
||||
|
||||
run_task();
|
||||
require_opcode(3, 0x200a);
|
||||
require(submitted[3].enabled == 1,
|
||||
"wake advertising was not enabled");
|
||||
complete(0x200a);
|
||||
require(g_timer_armed && installed_timer->timeout_ms == 2000,
|
||||
"wake burst did not schedule one exact stop deadline");
|
||||
now_ms = 1999;
|
||||
run_task();
|
||||
require(submitted_count == 4,
|
||||
"wake burst stopped before two seconds");
|
||||
now_ms = 2000;
|
||||
run_task();
|
||||
require_opcode(4, 0x200a);
|
||||
require(submitted[4].enabled == 0,
|
||||
"wake advertising was not disabled");
|
||||
complete(0x200a);
|
||||
|
||||
run_task();
|
||||
require_opcode(5, 0xfc01);
|
||||
require(memcmp(submitted[5].address, original_address, 6) == 0,
|
||||
"Pico gameplay identity was not restored");
|
||||
complete(0xfc01);
|
||||
|
||||
switch2_wake_diagnostics(&diagnostics);
|
||||
require(!diagnostics.busy && diagnostics.accepted_requests == 1 &&
|
||||
diagnostics.completed_bursts == 1 &&
|
||||
diagnostics.failures == 0 && !g_timer_armed,
|
||||
"completed wake did not restore a dormant gameplay state");
|
||||
}
|
||||
|
||||
void test_failed_setup_restores_gameplay_identity() {
|
||||
require(switch2_wake_request(),
|
||||
"idle module rejected a second wake request");
|
||||
run_task();
|
||||
require_opcode(6, 0xfc01);
|
||||
complete(0xfc01);
|
||||
run_task();
|
||||
require_opcode(7, 0x2006);
|
||||
complete(0x2006, 0x12);
|
||||
run_task();
|
||||
require_opcode(8, 0xfc01);
|
||||
require(memcmp(submitted[8].address, original_address, 6) == 0,
|
||||
"wake setup failure did not restore the gameplay identity");
|
||||
complete(0xfc01);
|
||||
|
||||
Switch2WakeDiagnostics diagnostics{};
|
||||
switch2_wake_diagnostics(&diagnostics);
|
||||
require(!diagnostics.busy && diagnostics.failures == 1 &&
|
||||
!g_timer_armed,
|
||||
"wake failure did not recover to dormant gameplay");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_temporary_identity_wake_and_restore();
|
||||
test_failed_setup_restores_gameplay_identity();
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ def test_bluepad32_backend_lifecycle_native(tmp_path: Path) -> None:
|
|||
"clear-pairings",
|
||||
"configuration-timer",
|
||||
"flash-core-start",
|
||||
"system-wake",
|
||||
"flash-core-failure",
|
||||
):
|
||||
subprocess.run([str(executable), scenario], check=True, cwd=root)
|
||||
|
|
|
|||
70
tests/test_configure_switch2_wake.py
Normal file
70
tests/test_configure_switch2_wake.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"configure_switch2_wake",
|
||||
ROOT / "tools" / "configure_switch2_wake.py",
|
||||
)
|
||||
configure = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(configure)
|
||||
|
||||
RAW_HEX = "0201061BFF53050100037E056620000181BDD6F7EBF1480F00000000000000"
|
||||
RECORD = {
|
||||
"version": 1,
|
||||
"advertiser": "98:E2:55:07:DF:00",
|
||||
"address_type": 0,
|
||||
"event_type": 0,
|
||||
"rssi": -67,
|
||||
"pid": "2066",
|
||||
"console": "48:F1:EB:F7:D6:BD",
|
||||
"raw_hex": RAW_HEX,
|
||||
"esphome_payload_hex": RAW_HEX[14:],
|
||||
}
|
||||
|
||||
|
||||
def test_valid_capture_generates_build_configuration(tmp_path: Path) -> None:
|
||||
capture_log = tmp_path / "capture.log"
|
||||
capture_log.write_text(
|
||||
"scanner startup\nSWITCH2_WAKE_CAPTURE " + json.dumps(RECORD) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
output = tmp_path / "switch2_wake_config.h"
|
||||
|
||||
assert configure.main(
|
||||
["--input", str(capture_log), "--output", str(output)]
|
||||
) == 0
|
||||
generated = output.read_text(encoding="utf-8")
|
||||
assert "#define SWITCH2_WAKE_CONFIGURED 1" in generated
|
||||
assert "0x98, 0xE2, 0x55, 0x07, 0xDF, 0x00" in generated
|
||||
assert "0x02, 0x01, 0x06, 0x1B, 0xFF" in generated
|
||||
assert "Target Switch 2: 48:F1:EB:F7:D6:BD" in generated
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "message"),
|
||||
[
|
||||
("address_type", 1, "public advertiser"),
|
||||
("event_type", 3, "ADV_IND"),
|
||||
("console", "48:F1:EB:F7:D6:BE", "console address"),
|
||||
("pid", "2067", "PID"),
|
||||
],
|
||||
)
|
||||
def test_inconsistent_capture_is_rejected(field, value, message) -> None:
|
||||
record = dict(RECORD)
|
||||
record[field] = value
|
||||
with pytest.raises(configure.CaptureError, match=message):
|
||||
configure.validate_capture(record)
|
||||
|
||||
|
||||
def test_non_wake_packet_is_rejected() -> None:
|
||||
record = dict(RECORD)
|
||||
raw = bytearray.fromhex(RAW_HEX)
|
||||
raw[16] = 0
|
||||
record["raw_hex"] = raw.hex()
|
||||
with pytest.raises(configure.CaptureError, match="wake flag"):
|
||||
configure.validate_capture(record)
|
||||
28
tests/test_switch2_wake_native.py
Normal file
28
tests/test_switch2_wake_native.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_switch2_wake_native(tmp_path: Path) -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
compiler = shutil.which("c++") or shutil.which("g++")
|
||||
assert compiler is not None, "a host C++ compiler is required"
|
||||
executable = tmp_path / "switch2_wake_test"
|
||||
subprocess.run(
|
||||
[
|
||||
compiler,
|
||||
"-std=c++17",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Werror",
|
||||
"-pedantic",
|
||||
f"-I{root / 'tests' / 'switch2_wake_native_stubs'}",
|
||||
f"-I{root / 'src' / 'firmware'}",
|
||||
str(root / "tests" / "switch2_wake_test.cpp"),
|
||||
"-o",
|
||||
str(executable),
|
||||
],
|
||||
check=True,
|
||||
cwd=root,
|
||||
)
|
||||
subprocess.run([str(executable)], check=True, cwd=root)
|
||||
Loading…
Add table
Add a link
Reference in a new issue