mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2025-12-13 11:02:12 +01:00
Added functionality to support low level triggers for GPIO types #206
This commit is contained in:
parent
0c36c8e746
commit
c0621d82ec
9 changed files with 75 additions and 44 deletions
|
|
@ -7,9 +7,9 @@
|
||||||
|
|
||||||
extern Preferences pref;
|
extern Preferences pref;
|
||||||
|
|
||||||
#define SHADE_HDR_VER 16
|
#define SHADE_HDR_VER 17
|
||||||
#define SHADE_HDR_SIZE 56
|
#define SHADE_HDR_SIZE 56
|
||||||
#define SHADE_REC_SIZE 268
|
#define SHADE_REC_SIZE 272
|
||||||
#define GROUP_REC_SIZE 184
|
#define GROUP_REC_SIZE 184
|
||||||
#define TRANS_REC_SIZE 74
|
#define TRANS_REC_SIZE 74
|
||||||
|
|
||||||
|
|
@ -667,10 +667,10 @@ bool ShadeConfigFile::readShadeRecord(SomfyShade *shade) {
|
||||||
shade->gpioUp = this->readUInt8(shade->gpioUp);
|
shade->gpioUp = this->readUInt8(shade->gpioUp);
|
||||||
shade->gpioDown = this->readUInt8(shade->gpioDown);
|
shade->gpioDown = this->readUInt8(shade->gpioDown);
|
||||||
}
|
}
|
||||||
if(this->header.version > 15) {
|
if(this->header.version > 15)
|
||||||
shade->gpioMy = this->readUInt8(shade->gpioMy);
|
shade->gpioMy = this->readUInt8(shade->gpioMy);
|
||||||
}
|
if(this->header.version > 16)
|
||||||
|
shade->gpioFlags = this->readUInt8(shade->gpioFlags);
|
||||||
if(shade->getShadeId() == 255) shade->clear();
|
if(shade->getShadeId() == 255) shade->clear();
|
||||||
else if(shade->tiltType == tilt_types::tiltonly) {
|
else if(shade->tiltType == tilt_types::tiltonly) {
|
||||||
shade->myPos = shade->currentPos = shade->target = 100.0f;
|
shade->myPos = shade->currentPos = shade->target = 100.0f;
|
||||||
|
|
@ -785,7 +785,8 @@ bool ShadeConfigFile::writeShadeRecord(SomfyShade *shade) {
|
||||||
this->writeUInt8(shade->sortOrder);
|
this->writeUInt8(shade->sortOrder);
|
||||||
this->writeUInt8(shade->gpioUp);
|
this->writeUInt8(shade->gpioUp);
|
||||||
this->writeUInt8(shade->gpioDown);
|
this->writeUInt8(shade->gpioDown);
|
||||||
this->writeUInt8(shade->gpioMy, CFG_REC_END);
|
this->writeUInt8(shade->gpioMy);
|
||||||
|
this->writeUInt8(shade->gpioFlags, CFG_REC_END);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
bool ShadeConfigFile::writeSettingsRecord() {
|
bool ShadeConfigFile::writeSettingsRecord() {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
#ifndef configsettings_h
|
#ifndef configsettings_h
|
||||||
#define configsettings_h
|
#define configsettings_h
|
||||||
|
|
||||||
#define FW_VERSION "v2.2.2c"
|
#define FW_VERSION "v2.2.2d"
|
||||||
enum DeviceStatus {
|
enum DeviceStatus {
|
||||||
DS_OK = 0,
|
DS_OK = 0,
|
||||||
DS_ERROR = 1,
|
DS_ERROR = 1,
|
||||||
|
|
|
||||||
76
Somfy.cpp
76
Somfy.cpp
|
|
@ -811,43 +811,46 @@ bool SomfyShade::isInGroup() {
|
||||||
void SomfyShade::setGPIOs() {
|
void SomfyShade::setGPIOs() {
|
||||||
if(this->proto == radio_proto::GP_Relay) {
|
if(this->proto == radio_proto::GP_Relay) {
|
||||||
// Determine whether the direction needs to be set.
|
// Determine whether the direction needs to be set.
|
||||||
|
uint8_t p_on = this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger == 0x00 ? HIGH : LOW;
|
||||||
|
uint8_t p_off = this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger == 0x00 ? LOW : HIGH;
|
||||||
|
|
||||||
int8_t dir = this->direction;
|
int8_t dir = this->direction;
|
||||||
if(dir == 0 && this->tiltType == tilt_types::integrated)
|
if(dir == 0 && this->tiltType == tilt_types::integrated)
|
||||||
dir = this->tiltDirection;
|
dir = this->tiltDirection;
|
||||||
else if(this->tiltType == tilt_types::tiltonly)
|
else if(this->tiltType == tilt_types::tiltonly)
|
||||||
dir = this->tiltDirection;
|
dir = this->tiltDirection;
|
||||||
if(this->shadeType == shade_types::drycontact) {
|
if(this->shadeType == shade_types::drycontact) {
|
||||||
digitalWrite(this->gpioDown, this->currentPos == 100 ? HIGH : LOW);
|
digitalWrite(this->gpioDown, this->currentPos == 100 ? p_on : p_off);
|
||||||
this->gpioDir = this->currentPos == 100 ? 1 : -1;
|
this->gpioDir = this->currentPos == 100 ? 1 : -1;
|
||||||
}
|
}
|
||||||
else if(this->shadeType == shade_types::drycontact2) {
|
else if(this->shadeType == shade_types::drycontact2) {
|
||||||
if(this->currentPos == 100) {
|
if(this->currentPos == 100) {
|
||||||
digitalWrite(this->gpioDown, LOW);
|
digitalWrite(this->gpioDown, p_off);
|
||||||
digitalWrite(this->gpioUp, HIGH);
|
digitalWrite(this->gpioUp, p_on);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
digitalWrite(this->gpioUp, LOW);
|
digitalWrite(this->gpioUp, p_off);
|
||||||
digitalWrite(this->gpioDown, HIGH);
|
digitalWrite(this->gpioDown, p_on);
|
||||||
}
|
}
|
||||||
this->gpioDir = this->currentPos == 100 ? 1 : -1;
|
this->gpioDir = this->currentPos == 100 ? 1 : -1;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
switch(dir) {
|
switch(dir) {
|
||||||
case -1:
|
case -1:
|
||||||
digitalWrite(this->gpioDown, LOW);
|
digitalWrite(this->gpioDown, p_off);
|
||||||
digitalWrite(this->gpioUp, HIGH);
|
digitalWrite(this->gpioUp, p_on);
|
||||||
if(dir != this->gpioDir) Serial.printf("UP: true, DOWN: false\n");
|
if(dir != this->gpioDir) Serial.printf("UP: true, DOWN: false\n");
|
||||||
this->gpioDir = dir;
|
this->gpioDir = dir;
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
digitalWrite(this->gpioUp, LOW);
|
digitalWrite(this->gpioUp, p_off);
|
||||||
digitalWrite(this->gpioDown, HIGH);
|
digitalWrite(this->gpioDown, p_on);
|
||||||
if(dir != this->gpioDir) Serial.printf("UP: false, DOWN: true\n");
|
if(dir != this->gpioDir) Serial.printf("UP: false, DOWN: true\n");
|
||||||
this->gpioDir = dir;
|
this->gpioDir = dir;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
digitalWrite(this->gpioUp, LOW);
|
digitalWrite(this->gpioUp, p_off);
|
||||||
digitalWrite(this->gpioDown, LOW);
|
digitalWrite(this->gpioDown, p_off);
|
||||||
if(dir != this->gpioDir) Serial.printf("UP: false, DOWN: false\n");
|
if(dir != this->gpioDir) Serial.printf("UP: false, DOWN: false\n");
|
||||||
this->gpioDir = dir;
|
this->gpioDir = dir;
|
||||||
break;
|
break;
|
||||||
|
|
@ -856,9 +859,11 @@ void SomfyShade::setGPIOs() {
|
||||||
}
|
}
|
||||||
else if(this->proto == radio_proto::GP_Remote) {
|
else if(this->proto == radio_proto::GP_Remote) {
|
||||||
if(millis() > this->gpioRelease) {
|
if(millis() > this->gpioRelease) {
|
||||||
digitalWrite(this->gpioUp, LOW);
|
uint8_t p_on = this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger == 0x00 ? HIGH : LOW;
|
||||||
digitalWrite(this->gpioDown, LOW);
|
uint8_t p_off = this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger == 0x00 ? LOW : HIGH;
|
||||||
digitalWrite(this->gpioMy, LOW);
|
digitalWrite(this->gpioUp, p_off);
|
||||||
|
digitalWrite(this->gpioDown, p_off);
|
||||||
|
digitalWrite(this->gpioMy, p_off);
|
||||||
this->gpioRelease = 0;
|
this->gpioRelease = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -866,22 +871,24 @@ void SomfyShade::setGPIOs() {
|
||||||
void SomfyRemote::triggerGPIOs(somfy_frame_t &frame) { }
|
void SomfyRemote::triggerGPIOs(somfy_frame_t &frame) { }
|
||||||
void SomfyShade::triggerGPIOs(somfy_frame_t &frame) {
|
void SomfyShade::triggerGPIOs(somfy_frame_t &frame) {
|
||||||
if(this->proto == radio_proto::GP_Remote) {
|
if(this->proto == radio_proto::GP_Remote) {
|
||||||
|
uint8_t p_on = this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger == 0x00 ? HIGH : LOW;
|
||||||
|
uint8_t p_off = this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger == 0x00 ? LOW : HIGH;
|
||||||
int8_t dir = 0;
|
int8_t dir = 0;
|
||||||
switch(frame.cmd) {
|
switch(frame.cmd) {
|
||||||
case somfy_commands::My:
|
case somfy_commands::My:
|
||||||
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1) {
|
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1) {
|
||||||
digitalWrite(this->gpioUp, LOW);
|
digitalWrite(this->gpioUp, p_off);
|
||||||
digitalWrite(this->gpioDown, LOW);
|
digitalWrite(this->gpioDown, p_off);
|
||||||
digitalWrite(this->gpioMy, HIGH);
|
digitalWrite(this->gpioMy, p_on);
|
||||||
dir = 0;
|
dir = 0;
|
||||||
if(dir != this->gpioDir) Serial.printf("UP: false, DOWN: false, MY: true\n");
|
if(dir != this->gpioDir) Serial.printf("UP: false, DOWN: false, MY: true\n");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case somfy_commands::Up:
|
case somfy_commands::Up:
|
||||||
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
||||||
digitalWrite(this->gpioMy, LOW);
|
digitalWrite(this->gpioMy, p_off);
|
||||||
digitalWrite(this->gpioDown, LOW);
|
digitalWrite(this->gpioDown, p_off);
|
||||||
digitalWrite(this->gpioUp, HIGH);
|
digitalWrite(this->gpioUp, p_on);
|
||||||
dir = -1;
|
dir = -1;
|
||||||
Serial.printf("UP: true, DOWN: false, MY: false\n");
|
Serial.printf("UP: true, DOWN: false, MY: false\n");
|
||||||
}
|
}
|
||||||
|
|
@ -889,34 +896,34 @@ void SomfyShade::triggerGPIOs(somfy_frame_t &frame) {
|
||||||
case somfy_commands::Toggle:
|
case somfy_commands::Toggle:
|
||||||
case somfy_commands::Down:
|
case somfy_commands::Down:
|
||||||
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
||||||
digitalWrite(this->gpioMy, LOW);
|
digitalWrite(this->gpioMy, p_off);
|
||||||
digitalWrite(this->gpioUp, LOW);
|
digitalWrite(this->gpioUp, p_off);
|
||||||
}
|
}
|
||||||
digitalWrite(this->gpioDown, HIGH);
|
digitalWrite(this->gpioDown, p_on);
|
||||||
dir = 1;
|
dir = 1;
|
||||||
Serial.printf("UP: false, DOWN: true, MY: false\n");
|
Serial.printf("UP: false, DOWN: true, MY: false\n");
|
||||||
break;
|
break;
|
||||||
case somfy_commands::MyUp:
|
case somfy_commands::MyUp:
|
||||||
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
||||||
digitalWrite(this->gpioDown, LOW);
|
digitalWrite(this->gpioDown, p_off);
|
||||||
digitalWrite(this->gpioMy, HIGH);
|
digitalWrite(this->gpioMy, p_on);
|
||||||
digitalWrite(this->gpioUp, HIGH);
|
digitalWrite(this->gpioUp, p_on);
|
||||||
Serial.printf("UP: true, DOWN: false, MY: true\n");
|
Serial.printf("UP: true, DOWN: false, MY: true\n");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case somfy_commands::MyDown:
|
case somfy_commands::MyDown:
|
||||||
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
||||||
digitalWrite(this->gpioUp, LOW);
|
digitalWrite(this->gpioUp, p_off);
|
||||||
digitalWrite(this->gpioMy, HIGH);
|
digitalWrite(this->gpioMy, p_on);
|
||||||
digitalWrite(this->gpioDown, HIGH);
|
digitalWrite(this->gpioDown, p_on);
|
||||||
Serial.printf("UP: false, DOWN: true, MY: true\n");
|
Serial.printf("UP: false, DOWN: true, MY: true\n");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case somfy_commands::MyUpDown:
|
case somfy_commands::MyUpDown:
|
||||||
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
if(this->shadeType != shade_types::drycontact && this->shadeType != shade_types::garage1 && this->shadeType != shade_types::drycontact2) {
|
||||||
digitalWrite(this->gpioUp, HIGH);
|
digitalWrite(this->gpioUp, p_on);
|
||||||
digitalWrite(this->gpioMy, HIGH);
|
digitalWrite(this->gpioMy, p_on);
|
||||||
digitalWrite(this->gpioDown, HIGH);
|
digitalWrite(this->gpioDown, p_on);
|
||||||
Serial.printf("UP: true, DOWN: true, MY: true\n");
|
Serial.printf("UP: true, DOWN: true, MY: true\n");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
@ -2777,6 +2784,9 @@ int8_t SomfyShade::fromJSON(JsonObject &obj) {
|
||||||
if(obj.containsKey("proto")) this->proto = static_cast<radio_proto>(obj["proto"].as<uint8_t>());
|
if(obj.containsKey("proto")) this->proto = static_cast<radio_proto>(obj["proto"].as<uint8_t>());
|
||||||
if(obj.containsKey("sunSensor")) this->setSunSensor(obj["sunSensor"]);
|
if(obj.containsKey("sunSensor")) this->setSunSensor(obj["sunSensor"]);
|
||||||
if(obj.containsKey("light")) this->setLight(obj["light"]);
|
if(obj.containsKey("light")) this->setLight(obj["light"]);
|
||||||
|
if(obj.containsKey("gpioFlags")) this->gpioFlags = obj["gpioFlags"];
|
||||||
|
if(obj.containsKey("gpioLLTrigger")) this->gpioFlags = obj["gpioLLTrigger"].as<bool>() ? this->gpioFlags |= (uint8_t)gpio_flags_t::LowLevelTrigger : this->gpioFlags &= ~(uint8_t)gpio_flags_t::LowLevelTrigger;
|
||||||
|
|
||||||
if(obj.containsKey("shadeType")) {
|
if(obj.containsKey("shadeType")) {
|
||||||
if(obj["shadeType"].is<const char *>()) {
|
if(obj["shadeType"].is<const char *>()) {
|
||||||
if(strncmp(obj["shadeType"].as<const char *>(), "roller", 7) == 0)
|
if(strncmp(obj["shadeType"].as<const char *>(), "roller", 7) == 0)
|
||||||
|
|
@ -2904,6 +2914,8 @@ bool SomfyShade::toJSON(JsonObject &obj) {
|
||||||
obj["gpioUp"] = this->gpioUp;
|
obj["gpioUp"] = this->gpioUp;
|
||||||
obj["gpioDown"] = this->gpioDown;
|
obj["gpioDown"] = this->gpioDown;
|
||||||
obj["gpioMy"] = this->gpioMy;
|
obj["gpioMy"] = this->gpioMy;
|
||||||
|
obj["gpioLLTrigger"] = ((this->gpioFlags & (uint8_t)gpio_flags_t::LowLevelTrigger) == 0) ? false : true;
|
||||||
|
Serial.println(this->gpioFlags);
|
||||||
SomfyRemote::toJSON(obj);
|
SomfyRemote::toJSON(obj);
|
||||||
JsonArray arr = obj.createNestedArray("linkedRemotes");
|
JsonArray arr = obj.createNestedArray("linkedRemotes");
|
||||||
for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) {
|
for(uint8_t i = 0; i < SOMFY_MAX_LINKED_REMOTES; i++) {
|
||||||
|
|
|
||||||
4
Somfy.h
4
Somfy.h
|
|
@ -153,6 +153,9 @@ enum class somfy_flags_t : byte {
|
||||||
Sunny = 0x20,
|
Sunny = 0x20,
|
||||||
Lighted = 0x40
|
Lighted = 0x40
|
||||||
};
|
};
|
||||||
|
enum class gpio_flags_t : byte {
|
||||||
|
LowLevelTrigger = 0x01
|
||||||
|
};
|
||||||
struct somfy_frame_t {
|
struct somfy_frame_t {
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
bool processed = false;
|
bool processed = false;
|
||||||
|
|
@ -186,6 +189,7 @@ class SomfyRemote {
|
||||||
uint32_t m_remoteAddress = 0;
|
uint32_t m_remoteAddress = 0;
|
||||||
public:
|
public:
|
||||||
radio_proto proto = radio_proto::RTS;
|
radio_proto proto = radio_proto::RTS;
|
||||||
|
uint8_t gpioFlags = 0;
|
||||||
int8_t gpioDir = 0;
|
int8_t gpioDir = 0;
|
||||||
uint8_t gpioUp = 0;
|
uint8_t gpioUp = 0;
|
||||||
uint8_t gpioDown = 0;
|
uint8_t gpioDown = 0;
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -3,11 +3,11 @@
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" href="main.css?v=2.2.2c" type="text/css" />
|
<link rel="stylesheet" href="main.css?v=2.2.2d" type="text/css" />
|
||||||
<link rel="stylesheet" href="widgets.css?v=2.2.2c" type="text/css" />
|
<link rel="stylesheet" href="widgets.css?v=2.2.2d" type="text/css" />
|
||||||
<link rel="stylesheet" href="icons.css?v=2.2.2c" type="text/css" />
|
<link rel="stylesheet" href="icons.css?v=2.2.2d" type="text/css" />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
<script type="text/javascript" src="index.js?v=2.2.2c"></script>
|
<script type="text/javascript" src="index.js?v=2.2.2d"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="divContainer" class="container main" data-auth="false">
|
<div id="divContainer" class="container main" data-auth="false">
|
||||||
|
|
@ -415,6 +415,12 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="divLLTrigger" style="margin-top:-10px;">
|
||||||
|
<div class="field-group">
|
||||||
|
<input id="cbLLTrigger" name="hasLLTrigger" data-bind="gpioLLTrigger" type="checkbox" style="" />
|
||||||
|
<label for="cbLLTrigger" style="display:block;font-size:1em;margin-top:0px;margin-left:7px;display:inline-block;">Low Level Trigger</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="divSunSensor" style="margin-top:-10px;">
|
<div id="divSunSensor" style="margin-top:-10px;">
|
||||||
<div class="field-group">
|
<div class="field-group">
|
||||||
<input id="cbHasSunsensor" name="hasSunSensor" data-bind="sunSensor" type="checkbox" style="" />
|
<input id="cbHasSunsensor" name="hasSunSensor" data-bind="sunSensor" type="checkbox" style="" />
|
||||||
|
|
|
||||||
|
|
@ -1252,7 +1252,7 @@ var security = new Security();
|
||||||
|
|
||||||
class General {
|
class General {
|
||||||
initialized = false;
|
initialized = false;
|
||||||
appVersion = 'v2.2.2c';
|
appVersion = 'v2.2.2d';
|
||||||
reloadApp = false;
|
reloadApp = false;
|
||||||
init() {
|
init() {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,14 @@
|
||||||
width: 70px;
|
width: 70px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
#somfyShade #divLLTrigger {
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#somfyShade[data-proto="8"] #divLLTrigger,
|
||||||
|
#somfyShade[data-proto="9"] #divLLTrigger {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
#somfyShade[data-proto="8"] #divStepSettings,
|
#somfyShade[data-proto="8"] #divStepSettings,
|
||||||
#somfyShade[data-proto="9"] #divStepSettings,
|
#somfyShade[data-proto="9"] #divStepSettings,
|
||||||
#somfyShade[data-proto="8"] #divGPIOMy,
|
#somfyShade[data-proto="8"] #divGPIOMy,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue