mirror of
https://github.com/HendrikRauh/dmx-interface.git
synced 2025-05-19 10:32:56 +00:00
Merge pull request #20 from HendrikRauh/main
Merge main into DMX-3-configuration-Web-UI
This commit is contained in:
commit
5c5e8e9e5a
16 changed files with 573 additions and 158 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,7 +1,5 @@
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
.pio
|
.pio
|
||||||
.vscode/.browse.c_cpp.db*
|
.vscode
|
||||||
.vscode/c_cpp_properties.json
|
!.vscode\extensions.json
|
||||||
.vscode/launch.json
|
|
||||||
.vscode/ipch
|
|
||||||
|
|
8
.vscode/extensions.json
vendored
8
.vscode/extensions.json
vendored
|
@ -1,6 +1,10 @@
|
||||||
{
|
{
|
||||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||||
// for the documentation about the extensions.json format
|
// for the documentation about the extensions.json format
|
||||||
"recommendations": ["platformio.platformio-ide"],
|
"recommendations": [
|
||||||
"unwantedRecommendations": ["ms-vscode.cpptools-extension-pack"]
|
"platformio.platformio-ide"
|
||||||
|
],
|
||||||
|
"unwantedRecommendations": [
|
||||||
|
"ms-vscode.cpptools-extension-pack"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
33
README.md
Normal file
33
README.md
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# DMX-Interface
|
||||||
|
|
||||||
|
Art-Net-Interface
|
||||||
|
|
||||||
|
## Case
|
||||||
|
|
||||||
|
[OnShape](https://cad.onshape.com/documents/7363818fd18bf0cbf094790e/w/52455282b39e47fbde5d0e53/e/9bec98aa83a813dc9a4d6ab2)
|
||||||
|
|
||||||
|
STL's coming soon, case still being developed.
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Pin usage
|
||||||
|
|
||||||
|
| GPIO | Usage |
|
||||||
|
| ---- | -------------- |
|
||||||
|
| GND | GND to others |
|
||||||
|
| 3,5V | VIN on RS485 |
|
||||||
|
| 5V | VIN on W5500 |
|
||||||
|
| 0 | Onboard Button |
|
||||||
|
| 5 | Ext. Button |
|
||||||
|
| 7 | Ext. LED |
|
||||||
|
| 15 | Onboard LED |
|
||||||
|
| 17 | U1TXD |
|
||||||
|
| 18 | U1RXD |
|
||||||
|
| 21 | U0TXD |
|
||||||
|
| 33 | U0RXD |
|
||||||
|
| 34 | SPI CS |
|
||||||
|
| 35 | SPI MOS |
|
||||||
|
| 36 | SPI SCK |
|
||||||
|
| 37 | SPI MISO |
|
BIN
assets/circuit/handwritten/circuit diagram.jpeg
Normal file
BIN
assets/circuit/handwritten/circuit diagram.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 750 KiB |
|
@ -6,13 +6,30 @@
|
||||||
<title>Konfiguration</title>
|
<title>Konfiguration</title>
|
||||||
<link rel="stylesheet" href="/style.css" />
|
<link rel="stylesheet" href="/style.css" />
|
||||||
<script type="module" src="/input-visibility.js" defer></script>
|
<script type="module" src="/input-visibility.js" defer></script>
|
||||||
|
<script type="module" src="/loading-screen.js" defer></script>
|
||||||
<script type="module" src="/load-data.js" defer></script>
|
<script type="module" src="/load-data.js" defer></script>
|
||||||
<script type="module" src="/submit.js" defer></script>
|
<script type="module" src="/submit.js" defer></script>
|
||||||
|
<script type="module" src="/reset.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
<section class="loading-screen">
|
||||||
|
<div class="spinner-container">
|
||||||
|
<!-- h2 is filled dynamically -->
|
||||||
|
<h2></h2>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<button
|
||||||
|
class="reload"
|
||||||
|
type="button"
|
||||||
|
onclick="window.location.reload()"
|
||||||
|
>
|
||||||
|
Seite neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="hidden">
|
||||||
<h1>Konfiguration</h1>
|
<h1>Konfiguration</h1>
|
||||||
<form>
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Verbindung</legend>
|
<legend>Verbindung</legend>
|
||||||
<label for="ip-method"
|
<label for="ip-method"
|
||||||
|
@ -22,18 +39,36 @@
|
||||||
id="input-ip-method"
|
id="input-ip-method"
|
||||||
title="IP-"
|
title="IP-"
|
||||||
>
|
>
|
||||||
<option value="static">Statisch</option>
|
<option value="0">Statisch</option>
|
||||||
<option value="dhcp">DHCP</option>
|
<option value="1">DHCP</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div data-field="input-ip-method" data-values="static">
|
<div data-field="input-ip-method" data-values="0">
|
||||||
<label>
|
<label>
|
||||||
IP-Adresse/CIDR:
|
IP-Adresse:
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="ip"
|
name="ip"
|
||||||
id="input-ip"
|
id="input-ip"
|
||||||
placeholder="IP-Adresse/CIDR"
|
placeholder="IP-Adresse"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Subnetzmaske:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="subnet"
|
||||||
|
id="input-subnet"
|
||||||
|
placeholder="Subnetzmaske"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Gateway:
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="gateway"
|
||||||
|
id="input-gateway"
|
||||||
|
placeholder="Gateway"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,12 +79,12 @@
|
||||||
id="input-connection"
|
id="input-connection"
|
||||||
title="Verbindung"
|
title="Verbindung"
|
||||||
>
|
>
|
||||||
<option value="wifi-sta">WiFi-Station</option>
|
<option value="0">WiFi-AccessPoint</option>
|
||||||
<option value="wifi-ap">WiFi-AccessPoint</option>
|
<option value="1">WiFi-Station</option>
|
||||||
<option value="ethernet">Ethernet</option>
|
<option value="2">Ethernet</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div data-field="input-connection" data-values="wifi-sta">
|
<div data-field="input-connection" data-values="0">
|
||||||
<label>
|
<label>
|
||||||
SSID:
|
SSID:
|
||||||
<input
|
<input
|
||||||
|
@ -60,20 +95,17 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div data-field="input-connection" data-values="wifi-ap">
|
<div data-field="input-connection" data-values="1">
|
||||||
<label>
|
<label>
|
||||||
Netzwerk:
|
Netzwerk:
|
||||||
<select
|
<select
|
||||||
name="network"
|
name="ssid"
|
||||||
id="input-network"
|
id="input-network"
|
||||||
title="Netzwerk"
|
title="Netzwerk"
|
||||||
></select>
|
></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div data-field="input-connection" data-values="0|1">
|
||||||
data-field="input-connection"
|
|
||||||
data-values="wifi-sta|wifi-ap"
|
|
||||||
>
|
|
||||||
<label>
|
<label>
|
||||||
Password:
|
Password:
|
||||||
<input
|
<input
|
||||||
|
@ -91,8 +123,8 @@
|
||||||
<span>Output</span>
|
<span>Output</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="input-or-output-1"
|
name="direction-1"
|
||||||
id="input-input-or-output-1"
|
id="input-direction-1"
|
||||||
data-value-not-checked="output"
|
data-value-not-checked="output"
|
||||||
data-value-checked="input"
|
data-value-checked="input"
|
||||||
/>
|
/>
|
||||||
|
@ -117,8 +149,8 @@
|
||||||
<span>Output</span>
|
<span>Output</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="input-or-output-2"
|
name="direction-2"
|
||||||
id="input-input-or-output-2"
|
id="input-direction-2"
|
||||||
data-value-not-checked="output"
|
data-value-not-checked="output"
|
||||||
data-value-checked="input"
|
data-value-checked="input"
|
||||||
/>
|
/>
|
||||||
|
@ -137,7 +169,10 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<div class="buttons">
|
||||||
|
<button type="reset">Zurücksetzen</button>
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
|
import {
|
||||||
|
showLoadingScreen,
|
||||||
|
showError,
|
||||||
|
hideLoadingScreen,
|
||||||
|
} from "./loading-screen.js";
|
||||||
|
|
||||||
const form = document.querySelector("form");
|
const form = document.querySelector("form");
|
||||||
|
|
||||||
async function loadData() {
|
export async function loadData(timeout = null) {
|
||||||
try {
|
|
||||||
const req = await fetch("/config", {
|
const req = await fetch("/config", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
signal: timeout !== null ? AbortSignal.timeout(timeout) : undefined,
|
||||||
});
|
});
|
||||||
if (!req.ok) {
|
if (!req.ok) {
|
||||||
throw new Error(`Response status: ${req.status}`);
|
throw new Error(`Response status: ${req.status}`);
|
||||||
|
@ -12,24 +18,30 @@ async function loadData() {
|
||||||
const json = await req.json();
|
const json = await req.json();
|
||||||
console.log(json);
|
console.log(json);
|
||||||
return json;
|
return json;
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeDataToInput(data) {
|
export function writeDataToInput(data) {
|
||||||
console.log("write data", typeof data);
|
console.log("write data");
|
||||||
for (const [key, value] of Object.entries(data)) {
|
for (const [key, value] of Object.entries(data)) {
|
||||||
const element = document.querySelector(`[name=${key}]`);
|
const element = document.querySelector(`[name=${key}]`);
|
||||||
console.log(element);
|
console.log(key, element);
|
||||||
|
|
||||||
|
if (element.type === "checkbox") {
|
||||||
|
element.checked = value;
|
||||||
|
} else {
|
||||||
element.value = value;
|
element.value = value;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// send "change" event
|
// send "change" event
|
||||||
form.dispatchEvent(new Event("change", { bubbles: true }));
|
form.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await loadData();
|
showLoadingScreen("Konfiguration wird geladen...");
|
||||||
if (data !== null) {
|
try {
|
||||||
|
const data = await loadData();
|
||||||
|
hideLoadingScreen();
|
||||||
writeDataToInput(data);
|
writeDataToInput(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error.message);
|
||||||
|
showError("Die Konfiguration konnte nicht geladen werden.");
|
||||||
}
|
}
|
||||||
|
|
40
data/loading-screen.js
Normal file
40
data/loading-screen.js
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
const form = document.querySelector("form");
|
||||||
|
const loadingScreen = document.querySelector(".loading-screen");
|
||||||
|
const loadingMsg = loadingScreen.querySelector("h2");
|
||||||
|
const spinner = loadingScreen.querySelector(".spinner");
|
||||||
|
const reloadBtn = loadingScreen.querySelector(".reload");
|
||||||
|
|
||||||
|
export function showLoadingScreen(msg) {
|
||||||
|
hide(form, reloadBtn);
|
||||||
|
show(loadingScreen, spinner);
|
||||||
|
loadingMsg.classList.remove("error");
|
||||||
|
loadingMsg.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showError(msg) {
|
||||||
|
showLoadingScreen(msg);
|
||||||
|
loadingMsg.innerHTML +=
|
||||||
|
"<br/>Stelle sicher, dass du mit dem DMX-Interface verbunden bist und die IP-Adresse stimmt.";
|
||||||
|
show(reloadBtn);
|
||||||
|
hide(spinner);
|
||||||
|
loadingMsg.classList.add("error");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideLoadingScreen() {
|
||||||
|
hide(loadingScreen, reloadBtn);
|
||||||
|
show(form);
|
||||||
|
loadingMsg.classList.remove("error");
|
||||||
|
loadingMsg.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(...elements) {
|
||||||
|
for (const element of elements) {
|
||||||
|
element.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(...elements) {
|
||||||
|
for (const element of elements) {
|
||||||
|
element.classList.add("hidden");
|
||||||
|
}
|
||||||
|
}
|
16
data/reset.js
Normal file
16
data/reset.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { updateConfig } from "/submit.js";
|
||||||
|
|
||||||
|
const form = document.querySelector("form");
|
||||||
|
|
||||||
|
form.addEventListener("reset", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const ok = confirm(
|
||||||
|
"Sicher, dass du alle Einstellungen zurücksetzen möchtest?"
|
||||||
|
);
|
||||||
|
if (ok) {
|
||||||
|
updateConfig({
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
|
@ -1,6 +1,8 @@
|
||||||
:root {
|
:root {
|
||||||
--color-primary: #087e8b;
|
--color-primary: #087e8b;
|
||||||
--color-on-primary: white;
|
--color-on-primary: white;
|
||||||
|
--color-background: #222;
|
||||||
|
--color-danger: #fa2b58;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
@ -13,7 +15,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
background-color: #222;
|
background-color: var(--color-background);
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
padding: 8px max(5%, 8px);
|
padding: 8px max(5%, 8px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
@ -44,7 +46,7 @@ label {
|
||||||
input,
|
input,
|
||||||
select {
|
select {
|
||||||
width: clamp(200px, 100%, 400px);
|
width: clamp(200px, 100%, 400px);
|
||||||
background-color: #222;
|
background-color: var(--color-background);
|
||||||
color: white;
|
color: white;
|
||||||
border: 1px solid white;
|
border: 1px solid white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
@ -59,14 +61,16 @@ select:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
display: block;
|
|
||||||
border: none;
|
border: none;
|
||||||
inset: none;
|
inset: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
color: var(--color-on-primary);
|
color: var(--color-on-primary);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
margin: 0 auto;
|
}
|
||||||
|
|
||||||
|
button[type="reset"] {
|
||||||
|
background-color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
:is(div:has(:is(input, select)), input, select, label)
|
:is(div:has(:is(input, select)), input, select, label)
|
||||||
|
@ -75,7 +79,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
label.switch {
|
label.switch {
|
||||||
|
@ -126,3 +130,68 @@ label.switch input:checked + .slider::before {
|
||||||
left: 100%;
|
left: 100%;
|
||||||
translate: -100% -50%;
|
translate: -100% -50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-screen {
|
||||||
|
display: grid;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.reload {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container {
|
||||||
|
width: min(max-content, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
position: relative;
|
||||||
|
margin: 10px auto;
|
||||||
|
background: conic-gradient(transparent 150deg, var(--color-primary));
|
||||||
|
--outer-diameter: 50px;
|
||||||
|
width: var(--outer-diameter);
|
||||||
|
height: var(--outer-diameter);
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
animation-name: spin;
|
||||||
|
animation-duration: 1s;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
--spinner-border: 5px;
|
||||||
|
top: var(--spinner-border);
|
||||||
|
left: var(--spinner-border);
|
||||||
|
|
||||||
|
--inner-diameter: calc(var(--outer-diameter) - 2 * var(--spinner-border));
|
||||||
|
width: var(--inner-diameter);
|
||||||
|
height: var(--inner-diameter);
|
||||||
|
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
|
import { loadData, writeDataToInput } from "./load-data.js";
|
||||||
|
import {
|
||||||
|
showLoadingScreen,
|
||||||
|
hideLoadingScreen,
|
||||||
|
showError,
|
||||||
|
} from "./loading-screen.js";
|
||||||
|
|
||||||
const form = document.querySelector("form");
|
const form = document.querySelector("form");
|
||||||
|
|
||||||
function parseValue(input) {
|
function parseValue(input) {
|
||||||
|
@ -13,7 +20,7 @@ function parseValue(input) {
|
||||||
|
|
||||||
if (input.type === "number") {
|
if (input.type === "number") {
|
||||||
const number = Number(input.value);
|
const number = Number(input.value);
|
||||||
return isNaN(number) ? null : number;
|
return Number.isNaN(number) ? null : number;
|
||||||
}
|
}
|
||||||
|
|
||||||
return input.value;
|
return input.value;
|
||||||
|
@ -31,25 +38,35 @@ form.addEventListener("submit", (event) => {
|
||||||
}, {});
|
}, {});
|
||||||
console.log(data);
|
console.log(data);
|
||||||
|
|
||||||
putData(data);
|
updateConfig({
|
||||||
});
|
|
||||||
|
|
||||||
async function putData(data) {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/config", {
|
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateConfig(fetchOptions) {
|
||||||
|
showLoadingScreen("Konfiguration anwenden und ESP neustarten...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/config", fetchOptions);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Response status: ${res.status}`);
|
throw new Error(`Response status: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await res.json();
|
// wait for the esp to restart
|
||||||
console.log(json);
|
const delay = new Promise((resolve) =>
|
||||||
|
setTimeout(() => resolve(), 500)
|
||||||
|
);
|
||||||
|
await delay;
|
||||||
|
|
||||||
|
const data = await loadData(30 * 1000);
|
||||||
|
writeDataToInput(data);
|
||||||
|
hideLoadingScreen();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message);
|
console.error(error.message);
|
||||||
|
showError(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,4 +15,4 @@ framework = arduino
|
||||||
lib_deps =
|
lib_deps =
|
||||||
hideakitai/ArtNet @ ^0.8.0
|
hideakitai/ArtNet @ ^0.8.0
|
||||||
bblanchon/ArduinoJson @ ^7.2.0
|
bblanchon/ArduinoJson @ ^7.2.0
|
||||||
ESP Async WebServer
|
me-no-dev/ESP Async WebServer
|
||||||
|
|
|
@ -1,94 +1,71 @@
|
||||||
// - - - - -
|
|
||||||
// ESPDMX - A Arduino library for sending and receiving DMX using the builtin serial hardware port.
|
|
||||||
// ESPDMX.cpp: Library implementation file
|
|
||||||
//
|
|
||||||
// Copyright (C) 2015 Rick <ricardogg95@gmail.com>
|
|
||||||
// This work is licensed under a GNU style license.
|
|
||||||
//
|
|
||||||
// Last change: Marcel Seerig <https://github.com/mseerig>
|
|
||||||
//
|
|
||||||
// Documentation and samples are available at https://github.com/Rickgg/ESP-Dmx
|
|
||||||
// - - - - -
|
|
||||||
|
|
||||||
/* ----- LIBRARIES ----- */
|
/* ----- LIBRARIES ----- */
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include "ESPDMX.h"
|
#include "ESPDMX.h"
|
||||||
|
|
||||||
#define DMXSPEED 250000
|
|
||||||
#define DMXFORMAT SERIAL_8N2
|
|
||||||
#define BREAKSPEED 83333
|
|
||||||
#define BREAKFORMAT SERIAL_8N1
|
|
||||||
#define SERIALPORT Serial0
|
|
||||||
#define DMXCHANNELS 512
|
|
||||||
|
|
||||||
bool dmxStarted = false;
|
|
||||||
int sendPin = 18;
|
|
||||||
int receivePin = -1;
|
|
||||||
|
|
||||||
// DMX value array and size. Entry 0 will hold startbyte, so we need 512+1 elements
|
// DMX value array and size. Entry 0 will hold startbyte, so we need 512+1 elements
|
||||||
uint8_t dmxDataStore[DMXCHANNELS + 1] = {};
|
// std::vector<uint8_t[DMXCHANNELS + 1]> dmxDataStores(MAX_IDS);
|
||||||
|
// uint8_t dmxDataStores[MAX_IDS][DMXCHANNELS + 1];
|
||||||
|
|
||||||
// Set up the DMX-Protocol
|
// Set up the DMX-Protocol
|
||||||
void DMXESPSerial::init()
|
void DMXESPSerial::init(int pinSend = 19, int pinRecv = -1)
|
||||||
{
|
{
|
||||||
SERIALPORT.begin(DMXSPEED, DMXFORMAT, receivePin, sendPin);
|
sendPin = pinSend;
|
||||||
|
recvPin = pinRecv;
|
||||||
|
SERIALPORT.begin(DMXSPEED, DMXFORMAT, recvPin, sendPin);
|
||||||
pinMode(sendPin, OUTPUT);
|
pinMode(sendPin, OUTPUT);
|
||||||
dmxStarted = true;
|
dmxStarted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to read DMX data
|
// Function to read DMX data
|
||||||
uint8_t DMXESPSerial::read(int Channel)
|
uint8_t DMXESPSerial::read(int channel)
|
||||||
{
|
{
|
||||||
if (dmxStarted == false)
|
if (dmxStarted == false)
|
||||||
init();
|
init();
|
||||||
|
|
||||||
if (Channel < 1)
|
if (channel < 1)
|
||||||
Channel = 1;
|
channel = 1;
|
||||||
if (Channel > DMXCHANNELS)
|
if (channel > DMXCHANNELS)
|
||||||
Channel = DMXCHANNELS;
|
channel = DMXCHANNELS;
|
||||||
return (dmxDataStore[Channel]);
|
return (dmxDataStore[channel]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to send DMX data
|
// Function to send DMX data
|
||||||
void DMXESPSerial::write(int Channel, uint8_t value)
|
void DMXESPSerial::write(int channel, uint8_t value)
|
||||||
{
|
{
|
||||||
|
|
||||||
if (dmxStarted == false)
|
if (dmxStarted == false)
|
||||||
init();
|
init();
|
||||||
|
|
||||||
if (Channel < 1)
|
if (channel < 1)
|
||||||
Channel = 1;
|
channel = 1;
|
||||||
if (Channel > DMXCHANNELS)
|
if (channel > DMXCHANNELS)
|
||||||
Channel = DMXCHANNELS;
|
channel = DMXCHANNELS;
|
||||||
if (value < 0)
|
if (value < 0)
|
||||||
value = 0;
|
value = 0;
|
||||||
if (value > 255)
|
if (value > 255)
|
||||||
value = 255;
|
value = 255;
|
||||||
|
|
||||||
dmxDataStore[Channel] = value;
|
dmxDataStore[channel] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DMXESPSerial::end()
|
void DMXESPSerial::end()
|
||||||
{
|
{
|
||||||
SERIALPORT.end();
|
SERIALPORT.end();
|
||||||
dmxStarted = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to update the DMX bus
|
// Function to update the DMX bus
|
||||||
void DMXESPSerial::update()
|
void DMXESPSerial::update()
|
||||||
{
|
{
|
||||||
if (dmxStarted == false)
|
|
||||||
init();
|
|
||||||
|
|
||||||
// Send break
|
// Send break
|
||||||
digitalWrite(sendPin, HIGH);
|
digitalWrite(sendPin, HIGH);
|
||||||
SERIALPORT.begin(BREAKSPEED, BREAKFORMAT, receivePin, sendPin);
|
SERIALPORT.begin(BREAKSPEED, BREAKFORMAT, recvPin, sendPin);
|
||||||
SERIALPORT.write(0);
|
SERIALPORT.write(0);
|
||||||
SERIALPORT.flush();
|
SERIALPORT.flush();
|
||||||
delay(1);
|
delay(1);
|
||||||
SERIALPORT.end();
|
SERIALPORT.end();
|
||||||
|
|
||||||
// send data
|
// send data
|
||||||
SERIALPORT.begin(DMXSPEED, DMXFORMAT, receivePin, sendPin);
|
SERIALPORT.begin(DMXSPEED, DMXFORMAT, recvPin, sendPin);
|
||||||
digitalWrite(sendPin, LOW);
|
digitalWrite(sendPin, LOW);
|
||||||
SERIALPORT.write(dmxDataStore, DMXCHANNELS);
|
SERIALPORT.write(dmxDataStore, DMXCHANNELS);
|
||||||
SERIALPORT.flush();
|
SERIALPORT.flush();
|
||||||
|
|
35
src/ESPDMX.h
35
src/ESPDMX.h
|
@ -1,28 +1,27 @@
|
||||||
// - - - - -
|
|
||||||
// ESPDMX - A Arduino library for sending and receiving DMX using the builtin serial hardware port.
|
|
||||||
// ESPDMX.cpp: Library implementation file
|
|
||||||
//
|
|
||||||
// Copyright (C) 2015 Rick <ricardogg95@gmail.com>
|
|
||||||
// This work is licensed under a GNU style license.
|
|
||||||
//
|
|
||||||
// Last change: Marcel Seerig <https://github.com/mseerig>
|
|
||||||
//
|
|
||||||
// Documentation and samples are available at https://github.com/Rickgg/ESP-Dmx
|
|
||||||
// - - - - -
|
|
||||||
|
|
||||||
#include <inttypes.h>
|
#include <inttypes.h>
|
||||||
|
|
||||||
|
|
||||||
#ifndef ESPDMX_h
|
#ifndef ESPDMX_h
|
||||||
#define ESPDMX_h
|
#define ESPDMX_h
|
||||||
|
|
||||||
// ---- Methods ----
|
#define DMXSPEED 250000
|
||||||
|
#define DMXFORMAT SERIAL_8N2
|
||||||
|
#define BREAKSPEED 83333
|
||||||
|
#define BREAKFORMAT SERIAL_8N1
|
||||||
|
#define SERIALPORT Serial0
|
||||||
|
#define DMXCHANNELS 512
|
||||||
|
|
||||||
class DMXESPSerial {
|
class DMXESPSerial
|
||||||
public:
|
{
|
||||||
void init();
|
public:
|
||||||
|
int sendPin;
|
||||||
|
int recvPin;
|
||||||
|
bool dmxStarted;
|
||||||
|
uint8_t dmxDataStore[DMXCHANNELS + 1];
|
||||||
|
|
||||||
|
void init(int pinSend, int pinRecv);
|
||||||
uint8_t read(int Channel);
|
uint8_t read(int Channel);
|
||||||
void write(int channel, uint8_t value);void update();
|
void write(int channel, uint8_t value);
|
||||||
|
void update();
|
||||||
void end();
|
void end();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
66
src/main.cpp
66
src/main.cpp
|
@ -1,18 +1,18 @@
|
||||||
#include <ArtnetWiFi.h>
|
#include <ArtnetWiFi.h>
|
||||||
// #include <ArtnetEther.h>
|
// #include <ArtnetEther.h>
|
||||||
|
|
||||||
#include <ArduinoJson.h>
|
|
||||||
#include "ESPDMX.h"
|
#include "ESPDMX.h"
|
||||||
#include <ESPAsyncWebServer.h>
|
#include <ESPAsyncWebServer.h>
|
||||||
#include <SPIFFS.h>
|
#include <SPIFFS.h>
|
||||||
#include <Preferences.h>
|
#include "routes/config.h"
|
||||||
|
|
||||||
Preferences config;
|
DMXESPSerial dmx1;
|
||||||
DMXESPSerial dmx;
|
DMXESPSerial dmx2;
|
||||||
|
|
||||||
AsyncWebServer server(80);
|
AsyncWebServer server(80);
|
||||||
|
|
||||||
ArtnetWiFi artnet;
|
ArtnetWiFi artnet;
|
||||||
|
DMXESPSerial dmx;
|
||||||
|
|
||||||
const uint16_t size = 512;
|
const uint16_t size = 512;
|
||||||
uint8_t data[size];
|
uint8_t data[size];
|
||||||
|
|
||||||
|
@ -20,20 +20,27 @@ void setup()
|
||||||
{
|
{
|
||||||
Serial.begin(9600);
|
Serial.begin(9600);
|
||||||
|
|
||||||
config.begin("dmx", false);
|
config.begin("dmx", true);
|
||||||
|
|
||||||
uint8_t universe = config.getUChar("universe", 1);
|
uint8_t universe1 = config.getUChar("universe-1", 1);
|
||||||
|
uint8_t universe2 = config.getUChar("universe-2", 1);
|
||||||
|
|
||||||
|
Direction direction1 = static_cast<Direction>(config.getUInt("direction-1", 0));
|
||||||
|
Direction direction2 = static_cast<Direction>(config.getUInt("direction-2", 1));
|
||||||
|
|
||||||
|
Connection connection = static_cast<Connection>(config.getUInt("connection", WiFiSta));
|
||||||
|
IpMethod ipMethod = static_cast<IpMethod>(config.getUInt("ip-method"), Static);
|
||||||
|
|
||||||
String ssid = config.getString("ssid", "artnet");
|
String ssid = config.getString("ssid", "artnet");
|
||||||
String pwd = config.getString("pwd", "mbgmbgmbg");
|
String pwd = config.getString("password", "mbgmbgmbg");
|
||||||
IPAddress defaultIp(192, 168, 1, 201);
|
IPAddress defaultIp(192, 168, 1, 201);
|
||||||
IPAddress ip = config.getUInt("ip", defaultIp);
|
IPAddress ip = config.getUInt("ip", defaultIp);
|
||||||
|
IPAddress defaultSubnet(255, 255, 255, 0);
|
||||||
|
IPAddress subnet = config.getUInt("subnet", defaultSubnet);
|
||||||
|
IPAddress defaultGateway(192, 168, 1, 1);
|
||||||
|
IPAddress gateway = config.getUInt("gateway", defaultGateway);
|
||||||
|
|
||||||
IPAddress cidr = config.getUChar("cidr", 24);
|
config.end();
|
||||||
|
|
||||||
// TODO: \/ Herleiten \/ @psxde
|
|
||||||
const IPAddress gateway(192, 168, 1, 1);
|
|
||||||
const IPAddress subnet(255, 255, 255, 0);
|
|
||||||
|
|
||||||
// WiFi stuff
|
// WiFi stuff
|
||||||
// WiFi.begin(ssid, pwd);
|
// WiFi.begin(ssid, pwd);
|
||||||
|
@ -51,17 +58,17 @@ void setup()
|
||||||
artnet.begin();
|
artnet.begin();
|
||||||
|
|
||||||
// Initialize DMX ports
|
// Initialize DMX ports
|
||||||
dmx.init();
|
dmx1.init(19, -1);
|
||||||
|
|
||||||
// if Artnet packet comes to this universe, this function is called
|
// if Artnet packet comes to this universe, this function is called
|
||||||
artnet.subscribeArtDmxUniverse(universe, [&](const uint8_t *data, uint16_t size, const ArtDmxMetadata &metadata, const ArtNetRemoteInfo &remote)
|
artnet.subscribeArtDmxUniverse(universe1, [&](const uint8_t *data, uint16_t size, const ArtDmxMetadata &metadata, const ArtNetRemoteInfo &remote)
|
||||||
{
|
{
|
||||||
for (size_t i = 0; i < size; ++i)
|
for (size_t i = 0; i < size; ++i)
|
||||||
{
|
{
|
||||||
dmx.write((i + 1), data[i]);
|
dmx1.write((i + 1), data[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
dmx.update(); });
|
dmx1.update(); });
|
||||||
|
|
||||||
// if Artnet packet comes, this function is called to every universe
|
// if Artnet packet comes, this function is called to every universe
|
||||||
artnet.subscribeArtDmx([&](const uint8_t *data, uint16_t size, const ArtDmxMetadata &metadata, const ArtNetRemoteInfo &remote) {});
|
artnet.subscribeArtDmx([&](const uint8_t *data, uint16_t size, const ArtDmxMetadata &metadata, const ArtNetRemoteInfo &remote) {});
|
||||||
|
@ -74,19 +81,26 @@ void setup()
|
||||||
|
|
||||||
server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");
|
server.serveStatic("/", SPIFFS, "/").setDefaultFile("index.html");
|
||||||
|
|
||||||
server.on("/config", HTTP_GET, [&, defaultIp, ssid, pwd, universe](AsyncWebServerRequest *request)
|
server.on("/config", HTTP_GET, [](AsyncWebServerRequest *request)
|
||||||
|
{ onGetConfig(request); });
|
||||||
|
|
||||||
|
server.on("/config", HTTP_DELETE, [](AsyncWebServerRequest *request)
|
||||||
{
|
{
|
||||||
DynamicJsonDocument doc(1024);
|
config.begin("dmx", false);
|
||||||
|
config.clear();
|
||||||
|
config.end();
|
||||||
|
// respond with default config
|
||||||
|
onGetConfig(request);
|
||||||
|
|
||||||
doc["ssid"] = ssid;
|
ESP.restart(); });
|
||||||
doc["pwd"] = pwd;
|
|
||||||
doc["ip"] = defaultIp;
|
|
||||||
doc["universe"] = universe;
|
|
||||||
|
|
||||||
String jsonString;
|
server.onRequestBody([](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total)
|
||||||
serializeJson(doc, jsonString);
|
{
|
||||||
|
if (request->url() == "/config" && request->method() == HTTP_PUT) {
|
||||||
|
onPutConfig(request, data, len, index, total);
|
||||||
|
ESP.restart();
|
||||||
|
} });
|
||||||
|
|
||||||
request->send(200, "application/json", jsonString); });
|
|
||||||
delay(1000);
|
delay(1000);
|
||||||
server.begin();
|
server.begin();
|
||||||
Serial.println("Server started!");
|
Serial.println("Server started!");
|
||||||
|
|
163
src/routes/config.cpp
Normal file
163
src/routes/config.cpp
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
#include "config.h"
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
|
Preferences config;
|
||||||
|
|
||||||
|
#pragma region Utility
|
||||||
|
|
||||||
|
uint32_t parseIp(String str)
|
||||||
|
{
|
||||||
|
const int size = 4;
|
||||||
|
|
||||||
|
String ipStrings[size];
|
||||||
|
uint8_t ipIndex = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < str.length(); i++)
|
||||||
|
{
|
||||||
|
if (str[i] == '.')
|
||||||
|
{
|
||||||
|
ipIndex++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ipStrings[ipIndex] += str[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
String ip = "";
|
||||||
|
for (int i = 0; i < size; i++)
|
||||||
|
{
|
||||||
|
String paddedString = ipStrings[i];
|
||||||
|
while (paddedString.length() < 3)
|
||||||
|
{
|
||||||
|
paddedString = "0" + paddedString;
|
||||||
|
}
|
||||||
|
ip.concat(paddedString);
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("ip string: " + ip);
|
||||||
|
return atoi(ip.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
IpMethod parseIpMethod(uint8_t ipMethod)
|
||||||
|
{
|
||||||
|
if (ipMethod > 0 || ipMethod < IP_METHOD_SIZE)
|
||||||
|
{
|
||||||
|
return static_cast<IpMethod>(ipMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ::std::invalid_argument("Invalid IP method value" + ipMethod);
|
||||||
|
}
|
||||||
|
|
||||||
|
Connection parseConnection(uint8_t connection)
|
||||||
|
{
|
||||||
|
if (connection > 0 || connection < CONNECTION_SIZE)
|
||||||
|
{
|
||||||
|
return static_cast<Connection>(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ::std::invalid_argument("Invalid connection value: " + connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
Direction parseDirection(uint8_t direction)
|
||||||
|
{
|
||||||
|
if (direction > 0 || direction < DIRECTION_SIZE)
|
||||||
|
{
|
||||||
|
return static_cast<Direction>(direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ::std::invalid_argument("Invalid direction value: " + direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pragma endregion
|
||||||
|
|
||||||
|
void onGetConfig(AsyncWebServerRequest *request)
|
||||||
|
{
|
||||||
|
config.begin("dmx", true);
|
||||||
|
|
||||||
|
IPAddress defaultIp(192, 168, 1, 201);
|
||||||
|
IPAddress ip = config.getUInt("ip", defaultIp);
|
||||||
|
|
||||||
|
IPAddress defaultSubnet(255, 255, 255, 0);
|
||||||
|
IPAddress subnet = config.getUInt("subnet", defaultSubnet);
|
||||||
|
|
||||||
|
IPAddress defaultGateway(192, 168, 1, 1);
|
||||||
|
IPAddress gateway = config.getUInt("gateway", defaultGateway);
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["connection"] = config.getUInt("connection", WiFiSta);
|
||||||
|
doc["ssid"] = config.getString("ssid", "artnet");
|
||||||
|
doc["password"] = config.getString("password", "mbgmbgmbg");
|
||||||
|
doc["ip-method"] = config.getUInt("ip-method"), Static;
|
||||||
|
doc["ip"] = ip.toString();
|
||||||
|
doc["subnet"] = subnet.toString();
|
||||||
|
doc["gateway"] = gateway.toString();
|
||||||
|
doc["universe-1"] = config.getUInt("universe-1", 1);
|
||||||
|
doc["direction-1"] = config.getUInt("direction-1", Output);
|
||||||
|
doc["universe-2"] = config.getUInt("universe-2", 1);
|
||||||
|
doc["direction-2"] = config.getUInt("direction-2", Input);
|
||||||
|
|
||||||
|
config.end();
|
||||||
|
|
||||||
|
String jsonString;
|
||||||
|
serializeJson(doc, jsonString);
|
||||||
|
|
||||||
|
request->send(200, "application/json", jsonString);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onPutConfig(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total)
|
||||||
|
{
|
||||||
|
Serial.printf("[REQUEST]\t%s\r\n", (const char *)data);
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
deserializeJson(doc, data);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
config.begin("dmx", false);
|
||||||
|
|
||||||
|
IpMethod ipMethod = parseIpMethod(doc["ip-method"].as<uint8_t>());
|
||||||
|
config.putUInt("ip-method", ipMethod);
|
||||||
|
|
||||||
|
if (ipMethod == Static)
|
||||||
|
{
|
||||||
|
IPAddress ipAddress;
|
||||||
|
ipAddress.fromString(doc["ip"].as<String>());
|
||||||
|
config.putUInt("ip", ipAddress);
|
||||||
|
|
||||||
|
IPAddress subnet;
|
||||||
|
subnet.fromString(doc["subnet"].as<String>());
|
||||||
|
config.putUInt("subnet", subnet);
|
||||||
|
|
||||||
|
IPAddress gateway;
|
||||||
|
gateway.fromString(doc["gateway"].as<String>());
|
||||||
|
config.putUInt("gateway", gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
Connection connection = parseConnection(doc["connection"].as<uint8_t>());
|
||||||
|
config.putUInt("connection", connection);
|
||||||
|
|
||||||
|
if (connection == WiFiSta || connection == WiFiAP)
|
||||||
|
{
|
||||||
|
config.putString("ssid", doc["ssid"].as<String>());
|
||||||
|
config.putString("password", doc["password"].as<String>());
|
||||||
|
}
|
||||||
|
|
||||||
|
Direction direction1 = parseDirection(doc["direction-1"].as<uint8_t>());
|
||||||
|
config.putUInt("direction-1", direction1);
|
||||||
|
|
||||||
|
Direction direction2 = parseDirection(doc["direction-2"].as<uint8_t>());
|
||||||
|
config.putUInt("direction-2", direction2);
|
||||||
|
|
||||||
|
config.putUInt("universe-1", doc["universe-1"]);
|
||||||
|
config.putUInt("universe-2", doc["universe-2"]);
|
||||||
|
|
||||||
|
config.end();
|
||||||
|
|
||||||
|
request->send(200);
|
||||||
|
}
|
||||||
|
catch (::std::invalid_argument &e)
|
||||||
|
{
|
||||||
|
config.end();
|
||||||
|
request->send(400, "text/plain", e.what());
|
||||||
|
}
|
||||||
|
}
|
38
src/routes/config.h
Normal file
38
src/routes/config.h
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <ESPAsyncWebServer.h>
|
||||||
|
#include <ESPDMX.h>
|
||||||
|
#include <Preferences.h>
|
||||||
|
|
||||||
|
// #ifndef CONFIG_h
|
||||||
|
// #define CONFIG_h
|
||||||
|
|
||||||
|
extern Preferences config;
|
||||||
|
|
||||||
|
enum IpMethod
|
||||||
|
{
|
||||||
|
Static,
|
||||||
|
DHCP
|
||||||
|
};
|
||||||
|
const uint8_t IP_METHOD_SIZE = 2;
|
||||||
|
|
||||||
|
enum Connection
|
||||||
|
{
|
||||||
|
WiFiSta,
|
||||||
|
WiFiAP,
|
||||||
|
Ethernet
|
||||||
|
};
|
||||||
|
const uint8_t CONNECTION_SIZE = 3;
|
||||||
|
|
||||||
|
enum Direction
|
||||||
|
{
|
||||||
|
Output,
|
||||||
|
Input
|
||||||
|
};
|
||||||
|
const uint8_t DIRECTION_SIZE = 2;
|
||||||
|
|
||||||
|
void onGetConfig(AsyncWebServerRequest *request);
|
||||||
|
|
||||||
|
void onPutConfig(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
|
||||||
|
|
||||||
|
// #endif
|
Loading…
Add table
Reference in a new issue