This commit is contained in:
Hendrik Rauh 2026-03-20 21:57:31 +00:00 committed by GitHub
commit 5577b17e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 2481 additions and 1922 deletions

9
.clang-format Normal file
View file

@ -0,0 +1,9 @@
---
BasedOnStyle: LLVM
# Include sorting
SortIncludes: true
# Spacing
MaxEmptyLinesToKeep: 2
SeparateDefinitionBlocks: Always

9
.codespellignore Normal file
View file

@ -0,0 +1,9 @@
# Words that codespell should ignore
# Add common false positives here
inout
uart
dout
din
Tage
alle
Aktion

29
.editorconfig Normal file
View file

@ -0,0 +1,29 @@
# EditorConfig is awesome: https://EditorConfig.org
# Ensures consistent coding styles for multiple developers working on the same project across various editors and IDEs.
root = true
# All files
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# Python files - 4 space indent
[*.py]
indent_size = 4
# Markdown files - preserve whitespace for formatting
[*.md]
trim_trailing_whitespace = false
# Makefile - requires tabs
[Makefile]
indent_style = tab
# Make files - requires tabs
[*.make]
indent_style = tab

6
.envrc Normal file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
if command -v nix &> /dev/null; then
use flake
else
echo "Nix not found, skipping flake support"
fi

16
.github/actions/install-nix/action.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: install-nix
description: Install Nix with flakes enabled and pre-warm the repository's flake devShell
runs:
using: composite
steps:
- name: Install Nix (with flakes)
uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
- name: Pre-warm flake devShell
run: |
# Use the repository's `flake.nix` devShell to fetch dependencies.
# This speeds up later `nix develop` invocations in workflow steps.
nix develop --command true
shell: bash

39
.github/workflows/check.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: check
"on":
workflow_dispatch: {}
push:
branches: ["main"]
pull_request:
branches: ["**"]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install Nix and pre-warm flake devShell
uses: ./.github/actions/install-nix
- name: Run pre-commit from flake devShell
run: |
# Use the flake devShell defined in ./flake.nix (x86_64 runner)
nix develop --command pre-commit run --all-files
shell: bash
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Install Nix and pre-warm flake devShell
uses: ./.github/actions/install-nix
- name: Run build from flake devShell
run: |
# Use the flake devShell defined in ./flake.nix (x86_64 runner)
nix develop --command invoke build
shell: bash

66
.gitignore vendored Normal file → Executable file
View file

@ -1,5 +1,63 @@
# Development # .gitignore für ESP-IDF / CMake-Projekt
.pio # --- Build artefacts -----------------------------------------------------
.vscode /build/
!.vscode\extensions.json *.elf
*.bin
*.uf2
*.hex
*.map
*.img
# CMake / build system
CMakeCache.txt
CMakeFiles/
cmake_install.cmake
compile_commands.json
# ESP-IDF specific
sdkconfig
sdkconfig.old
flasher_args.json
flash_args*
flash_app_args
flash_bootloader_args
flash_project_args
bootloader-prefix/
project_description.json
managed_components/
# Component build directories
components/**/build/
partition_table/build/
# IDEs / editors / OS
.vscode/
.idea/
*.swp
*~
.DS_Store
# direnv (local environment/profile cache)
.direnv/
# Python / virtualenvs
__pycache__/
*.pyc
venv/
venv*/
.env
# Logs / temp
*.log
*.tmp
*.bak
# Documentation
docs/doxygen/*
# Misc
*.local
# Keep Snyk instructions file tracked (project policy)
# .github/instructions/snyk_rules.instructions.md

9
.gitmodules vendored
View file

@ -1,6 +1,3 @@
[submodule "lib/ArtNet"] [submodule "docs/external/doxygen-awesome-css"]
path = lib/ArtNet path = docs/external/doxygen-awesome-css
url = https://github.com/psxde/ArtNet.git url = https://github.com/jothepro/doxygen-awesome-css
[submodule "lib/AsyncWebServer_ESP32_W5500"]
path = lib/AsyncWebServer_ESP32_W5500
url = https://github.com/psxde/AsyncWebServer_ESP32_W5500.git

32
.markdownlint.json Normal file
View file

@ -0,0 +1,32 @@
{
"default": true,
"MD013": {
"line_length": 300,
"tables": true,
"code_blocks": true
},
"MD033": false,
"MD012": {
"maximum": 2
},
"MD026": {
"punctuation": ".,;:!"
},
"MD024": {
"siblings_only": false
},
"MD029": {
"style": "ordered"
},
"MD046": {
"style": "fenced"
},
"MD049": {
"style": "underscore"
},
"MD050": {
"style": "asterisk"
},
"MD052": false,
"MD053": false
}

120
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,120 @@
# yaml-language-server: $schema=https://json.schemastore.org/pre-commit-config.json
# Global exclude pattern for build artifacts and dependencies
exclude: |
(?x)^(
build/|
managed_components/|
.*\.bin|
.*\.elf|
.*\.hex|
.*\.o|
flake.lock|
dependencies.lock|
assets/case/|
docs/doxygen/
)
repos:
# General file checks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
# Line ending checks
- id: end-of-file-fixer
- id: fix-byte-order-marker
- id: mixed-line-ending
args: [--fix=lf]
# Whitespace & formatting
- id: trailing-whitespace
# File format checks
- id: check-added-large-files
args: [--maxkb=1000]
- id: check-ast
- id: check-case-conflict
- id: check-json
- id: check-merge-conflict
- id: detect-private-key
- id: debug-statements
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py38-plus]
# Spell checking
- repo: https://github.com/codespell-project/codespell
rev: v2.4.2
hooks:
- id: codespell
args: [--ignore-words=.codespellignore]
# YAML linting
- repo: https://github.com/adrienverge/yamllint
rev: v1.37.1
hooks:
- id: yamllint
args:
[
--strict,
-d,
"{extends: default, rules: {line-length: {max: 120}, document-start: disable}}",
]
# Markdown linting
- repo: https://github.com/DavidAnson/markdownlint-cli2
rev: v0.17.1
hooks:
- id: markdownlint-cli2
# Python formatting
- repo: https://github.com/psf/black
rev: 26.1.0
hooks:
- id: black
language_version: python3
args: [--quiet]
# JavaScript/JSON/CSS/HTML/YAML formatting and C/C++ formatting
- repo: local
hooks:
- id: prettier
name: prettier
entry: prettier
language: system
types_or: [javascript, jsx, json, css, scss, html, yaml]
exclude: \.md$
args: [--write, --ignore-unknown]
- id: clang-format
name: clang-format
entry: clang-format
language: system
types_or: [c, c++]
args: [-i]
- id: nixfmt
name: nixfmt
entry: nixfmt
language: system
types: [nix]
# CMake formatting and linting
- repo: https://github.com/cheshirekow/cmake-format-precommit
rev: v0.6.10
hooks:
- id: cmake-format
- id: cmake-lint
# Doxygen code coverage
- repo: local
hooks:
- id: doxygen-coverage
name: doxygen code coverage
language: system
entry: tools/doxy-coverage.py
args: [docs/doxygen/xml, --threshold=100, --generate-docs]
types_or: [c, c++, header]
verbose: false
require_serial: true

16
.prettierignore Normal file
View file

@ -0,0 +1,16 @@
# Ignore build artifacts and generated files
build/
managed_components/
docs/doxygen/
docs/external/
.direnv/
*.lock
# Ignore sdkconfig (generated)
sdkconfig
sdkconfig.old
# SVGs are formatted with svgo instead
*.svg
# Ignore dependencies
dependencies.lock

12
.prettierrc.json Normal file
View file

@ -0,0 +1,12 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"proseWrap": "preserve"
}

View file

@ -1,10 +0,0 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}

15
CMakeLists.txt Normal file
View file

@ -0,0 +1,15 @@
cmake_minimum_required(VERSION 3.16)
# Clear any stale EXTRA_COMPONENT_DIRS entries (e.g. leftover from previous runs
# or PlatformIO)
set(EXTRA_COMPONENT_DIRS "")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(dmx-interface)
# Enable LittleFS filesystem image creation for web assets
if(COMMAND littlefs_create_partition_image)
littlefs_create_partition_image(storage ${CMAKE_SOURCE_DIR}/data
FLASH_IN_PROJECT)
endif()

62
Doxyfile Normal file
View file

@ -0,0 +1,62 @@
PROJECT_NAME = "DMX-Interface"
PROJECT_BRIEF = "ChaosDMX"
OUTPUT_DIRECTORY = docs/doxygen
# Input settings
INPUT = main \
components \
data \
README.md
FILE_PATTERNS = *.c *.h *.cpp *.hpp *.md *.py *.js *.css *.html
RECURSIVE = YES
EXCLUDE_PATTERNS = */build/* \
*/managed_components/*
USE_MDFILE_AS_MAINPAGE = README.md
# Documentation settings
GENERATE_LATEX = NO
GENERATE_HTML = YES
GENERATE_XML=YES
# doxygen-awesome-css settings
HTML_EXTRA_STYLESHEET = docs/external/doxygen-awesome-css/doxygen-awesome.css \
docs/external/doxygen-awesome-css/doxygen-awesome-sidebar-only.css
HTML_EXTRA_FILES = docs/external/doxygen-awesome-css/doxygen-awesome-darkmode-toggle.js \
docs/external/doxygen-awesome-css/doxygen-awesome-fragment-copy-button.js \
docs/external/doxygen-awesome-css/doxygen-awesome-paragraph-link.js \
docs/external/doxygen-awesome-css/doxygen-awesome-interactive-toc.js \
docs/external/doxygen-awesome-css/doxygen-awesome-tabs.js
# Custom header for JS integration
# Better HTML output
HTML_COLORSTYLE = LIGHT
GENERATE_TREEVIEW = YES
DISABLE_INDEX = NO
FULL_SIDEBAR = NO
# Extraction settings
EXTRACT_ALL = YES
EXTRACT_PRIVATE = YES
EXTRACT_STATIC = YES
EXTRACT_LOCAL_CLASSES = YES
# Graphviz / Dot settings
HAVE_DOT = YES
DOT_IMAGE_FORMAT = svg
INTERACTIVE_SVG = YES
DOT_FONTNAME = Helvetica
DOT_FONTSIZE = 10
# Graphs to generate
CALL_GRAPH = YES
CALLER_GRAPH = YES
GRAPHICAL_HIERARCHY = YES
DIRECTORY_GRAPH = YES
CLASS_GRAPH = YES
COLLABORATION_GRAPH = YES
GROUP_GRAPHS = YES
INCLUDE_GRAPH = YES
INCLUDED_BY_GRAPH = YES

View file

@ -21,9 +21,9 @@
## 📱 Implemented microcontrollers ## 📱 Implemented microcontrollers
- [x] Lolin S2 mini - [x] Lolin S2 mini
- [ ] ESP 32 WROOM - [ ] ESP 32 WROOM
- [ ] ESP 32 C3 - [ ] ESP 32 C3
> For other microcontrollers you may need to adjust the `platformio.ini` > For other microcontrollers you may need to adjust the `platformio.ini`
@ -65,6 +65,58 @@ You have to short-circuit `R0` on the RS485 boards to enable the termination res
--- ---
## 🧑‍💻 Development
### Required tools
- `ESP-IDF` (includes `idf.py`)
- `invoke` (for project tasks)
- Optional but recommended for development:
- `pre-commit` (for code quality hooks)
- `clang-format` (C/C++)
- `prettier` (JavaScript/CSS/HTML/YAML)
- `svgo` (SVG optimization)
- `nixfmt` (Nix formatting)
### Environment setup
This repository includes a `flake.nix` with a ready-to-use development shell.
```bash
nix develop
```
Alternatively, you can use `direnv` to automatically enter the development shell when you `cd` into the project directory.
Without Nix, install ESP-IDF and Python dependencies manually (especially `invoke`) and ensure `idf.py` is available in your shell.
Run `invoke --list` to see all available tasks.
Examples:
```bash
invoke flash
invoke reset
invoke config
```
### Pre-commit hooks
This project uses [pre-commit](https://pre-commit.com/) to automatically check code quality, formatting, and common mistakes before committing.
**Setup:**
```bash
# Install pre-commit hooks
pre-commit install
# Optionally, run all hooks on all files
pre-commit run --all-files
```
---
## 📦 Case ## 📦 Case
All print files (STL, STEP, X_T) can be found in [assets/case](/assets/case/). Alternatively you can view the project on [OnShape](https://cad.onshape.com/documents/7363818fd18bf0cbf094790e/w/52455282b39e47fbde5d0e53/e/9bec98aa83a813dc9a4d6ab2) where you can export the files in a format you like. All print files (STL, STEP, X_T) can be found in [assets/case](/assets/case/). Alternatively you can view the project on [OnShape](https://cad.onshape.com/documents/7363818fd18bf0cbf094790e/w/52455282b39e47fbde5d0e53/e/9bec98aa83a813dc9a4d6ab2) where you can export the files in a format you like.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 512 512"><rect width="512" height="512" fill="#022225" rx="128"/><path fill="#087e8b" d="M384.806 144.708v-14.393a14.717 14.717 0 0 0-14.717-14.717h-17.353a170.606 170.606 0 1 0 32.07 28.826zM169.116 294.07a29.68 29.68 0 0 1-27.419-18.321 29.68 29.68 0 0 1 6.433-32.342 29.68 29.68 0 0 1 32.343-6.434 29.68 29.68 0 0 1 9.628 48.404 29.68 29.68 0 0 1-20.985 8.693M256 371.548a29.676 29.676 0 0 1-29.107-35.468 29.67 29.67 0 0 1 8.122-15.195 29.67 29.67 0 0 1 32.342-6.433A29.677 29.677 0 0 1 256 371.548m86.195-77.478a29.676 29.676 0 0 1-29.107-35.468 29.67 29.67 0 0 1 8.122-15.195 29.672 29.672 0 0 1 45.661 4.497 29.676 29.676 0 0 1-24.676 46.166"/><path fill="#087e8b" fill-rule="evenodd" d="M256 56a200 200 0 1 0 200 200 200.24 200.24 0 0 0-58.65-141.35A200.24 200.24 0 0 0 256 56M149.395 415.595A191.9 191.9 0 0 0 256 447.932v-.041a192.14 192.14 0 0 0 135.617-56.274A192.14 192.14 0 0 0 447.891 256a191.891 191.891 0 1 0-298.496 159.595" clip-rule="evenodd"/></svg>
<rect width="512" height="512" rx="128" fill="#022225"/>
<path d="M384.806 144.708V130.315C384.806 126.412 383.255 122.669 380.495 119.909C377.735 117.149 373.992 115.598 370.089 115.598C365.345 115.598 357.926 115.598 352.736 115.598C317.07 90.9098 273.338 80.7344 230.436 87.1415C187.534 93.5486 148.684 116.057 121.787 150.089C94.89 184.121 81.9662 227.12 85.6441 270.342C89.322 313.563 109.326 353.761 141.586 382.759C173.847 411.756 215.942 427.377 259.309 426.443C302.677 425.509 344.06 408.091 375.042 377.731C406.025 347.371 424.279 306.349 426.093 263.01C427.906 219.67 413.143 177.267 384.806 144.424V144.708ZM169.116 294.07C163.246 294.07 157.508 292.329 152.628 289.068C147.747 285.807 143.943 281.172 141.697 275.749C139.451 270.326 138.863 264.359 140.008 258.602C141.153 252.845 143.98 247.557 148.13 243.407C152.281 239.256 157.569 236.43 163.326 235.285C169.083 234.139 175.05 234.727 180.473 236.973C185.896 239.22 190.531 243.023 193.792 247.904C197.053 252.784 198.793 258.522 198.793 264.392C198.793 272.263 195.667 279.812 190.101 285.377C184.535 290.943 176.987 294.07 169.116 294.07ZM256 371.548C250.13 371.548 244.392 369.807 239.512 366.546C234.631 363.285 230.828 358.65 228.581 353.227C226.335 347.804 225.747 341.837 226.893 336.08C228.038 330.323 230.864 325.035 235.015 320.885C239.165 316.734 244.453 313.908 250.21 312.763C255.967 311.618 261.934 312.205 267.357 314.452C272.78 316.698 277.415 320.502 280.676 325.382C283.937 330.263 285.678 336 285.678 341.87C285.678 349.741 282.551 357.29 276.985 362.855C271.42 368.421 263.871 371.548 256 371.548ZM342.195 294.07C336.325 294.07 330.587 292.329 325.707 289.068C320.826 285.807 317.023 281.172 314.776 275.749C312.53 270.326 311.942 264.359 313.088 258.602C314.233 252.845 317.059 247.557 321.21 243.407C325.36 239.256 330.648 236.43 336.405 235.285C342.162 234.139 348.129 234.727 353.552 236.973C358.975 239.22 363.61 243.023 366.871 247.904C370.132 252.784 371.873 258.522 371.873 264.392C371.873 272.263 368.746 279.812 363.18 285.377C357.615 290.943 350.066 294.07 342.195 294.07Z" fill="#087E8B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M256 56C216.444 56 177.776 67.7298 144.886 89.7061C111.996 111.682 86.3617 142.918 71.2242 179.463C56.0867 216.008 52.126 256.222 59.843 295.018C67.5601 333.814 86.6082 369.451 114.579 397.421C142.549 425.392 178.186 444.44 216.982 452.157C255.778 459.874 295.992 455.913 332.537 440.776C369.082 425.638 400.318 400.004 422.294 367.114C444.27 334.224 456 295.556 456 256C455.936 202.976 434.844 152.143 397.35 114.65C359.857 77.1564 309.024 56.0644 256 56ZM149.395 415.595C180.951 436.679 218.049 447.932 256 447.932V447.891C306.873 447.827 355.644 427.589 391.617 391.617C427.589 355.644 447.827 306.873 447.891 256C447.883 218.049 436.622 180.953 415.532 149.402C394.442 117.851 364.47 93.262 329.406 78.7444C294.341 64.2268 255.76 60.4325 218.539 67.8413C181.318 75.25 147.131 93.5291 120.298 120.367C93.4657 147.205 75.1938 181.397 67.7929 218.619C60.392 255.841 64.1945 294.422 78.7195 329.483C93.2445 364.545 117.84 394.512 149.395 415.595Z" fill="#087E8B"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

View file

@ -0,0 +1 @@
idf_component_register(SRCS "src/dmx.c" INCLUDE_DIRS "include")

View file

@ -0,0 +1,9 @@
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#ifdef __cplusplus
}
#endif

0
components/dmx/src/dmx.c Normal file
View file

View file

@ -0,0 +1 @@
idf_component_register(INCLUDE_DIRS include)

View file

@ -0,0 +1,50 @@
/**
* @file logger.h
* @brief Project-wide logging macros based on ESP-IDF's logging library.
*
* This header provides a set of simple logging macros (LOGE, LOGW, LOGI, etc.)
* that wrap the underlying ESP-IDF logging functions (esp_log_e, esp_log_w,
* etc.).
*
* @section usage Usage
* To use these macros, a `LOG_TAG` should be defined before including this
* header. The `LOG_TAG` is a string that identifies the source of the log
* messages, typically the component or file name. If `LOG_TAG` is not defined,
* a default tag "CHAOS" will be used.
*
* @example
* #define LOG_TAG "MY_COMPONENT"
* #include "logger.h"
*
* void my_function() {
* LOGI("This is an informational message.");
* LOGE("This is an error message with a value: %d", 42);
* }
*/
#pragma once
#include "esp_log.h"
#ifdef __cplusplus
extern "C" {
#endif
#ifndef LOG_TAG
#define LOG_TAG "CHAOS" ///< Default log tag
#endif
/** @brief Log a message at Error level. */
#define LOGE(...) ESP_LOGE(LOG_TAG, __VA_ARGS__)
/** @brief Log a message at Warning level. */
#define LOGW(...) ESP_LOGW(LOG_TAG, __VA_ARGS__)
/** @brief Log a message at Info level. */
#define LOGI(...) ESP_LOGI(LOG_TAG, __VA_ARGS__)
/** @brief Log a message at Debug level. */
#define LOGD(...) ESP_LOGD(LOG_TAG, __VA_ARGS__)
/** @brief Log a message at Verbose level. */
#define LOGV(...) ESP_LOGV(LOG_TAG, __VA_ARGS__)
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,10 @@
idf_component_register(
SRCS
"src/storage.c"
INCLUDE_DIRS
"include"
REQUIRES
joltwallet__littlefs
PRIV_REQUIRES
vfs
logger)

View file

@ -0,0 +1,25 @@
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Initialize and mount LittleFS filesystem
*
* @return ESP_OK on success, error code otherwise
*/
esp_err_t storage_init(void);
/**
* @brief Get the mount point for the LittleFS filesystem
*
* @return Pointer to the mount point string (e.g., "/data")
*/
const char *storage_get_mount_point(void);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,44 @@
#define LOG_TAG "STORE" ///< "STORE" log tag for this file
#include "storage.h"
#include "esp_littlefs.h"
#include "esp_vfs.h"
#include "logger.h"
static const char *LITTLEFS_MOUNT_POINT =
"/data"; ///< Mount point for LittleFS filesystem
esp_err_t storage_init(void) {
esp_vfs_littlefs_conf_t conf = {
.base_path = LITTLEFS_MOUNT_POINT,
.partition_label = "storage",
.format_if_mount_failed = false,
.read_only = false,
};
esp_err_t ret = esp_vfs_littlefs_register(&conf);
if (ret != ESP_OK) {
if (ret == ESP_FAIL) {
LOGE("Failed to mount LittleFS or format filesystem");
} else if (ret == ESP_ERR_INVALID_STATE) {
LOGE("ESP_ERR_INVALID_STATE");
} else {
LOGE("Failed to initialize LittleFS: %s", esp_err_to_name(ret));
}
return ret;
}
size_t total = 0, used = 0;
ret = esp_littlefs_info(conf.partition_label, &total, &used);
if (ret == ESP_OK) {
LOGI("LittleFS mounted at %s. Total: %d bytes, Used: %d bytes",
LITTLEFS_MOUNT_POINT, total, used);
} else {
LOGE("Failed to get LittleFS information");
}
return ESP_OK;
}
const char *storage_get_mount_point(void) { return LITTLEFS_MOUNT_POINT; }

View file

@ -0,0 +1,16 @@
idf_component_register(
SRCS
"src/web_server.c"
"src/wifi.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_http_server
storage
PRIV_REQUIRES
freertos
esp_wifi
esp_event
esp_netif
nvs_flash
logger)

View file

@ -0,0 +1,59 @@
/**
* @file web_server.h
* @brief Simple HTTP web server component for ESP32 with async FreeRTOS
* support.
*/
#pragma once
#include "esp_http_server.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Web server configuration structure.
*/
typedef struct {
uint16_t port; ///< HTTP server port (default: 80)
size_t max_uri_handlers; ///< Maximum number of URI handlers
size_t stack_size; ///< FreeRTOS task stack size
UBaseType_t task_priority; ///< FreeRTOS task priority
} webserver_config_t;
/**
* @brief Initialize and start the HTTP web server.
*
* This function creates a FreeRTOS task that manages the HTTP server.
* It serves static files from the data/ folder and supports dynamic handler
* registration.
*
* @param config Configuration structure. If NULL, default values are used.
* @return HTTP server handle on success, NULL on failure.
*/
httpd_handle_t webserver_start(const webserver_config_t *config);
/**
* @brief Stop the web server and cleanup resources.
*
* @param server HTTP server handle returned by webserver_start().
* Safe to pass NULL.
*/
void webserver_stop(httpd_handle_t server);
/**
* @brief Register a custom URI handler.
*
* This allows dynamic registration of API endpoints and other custom handlers.
*
* @param server HTTP server handle.
* @param uri_handler Pointer to httpd_uri_t structure.
* @return ESP_OK on success, error code otherwise.
*/
esp_err_t webserver_register_handler(httpd_handle_t server,
const httpd_uri_t *uri_handler);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,33 @@
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Start WiFi Access Point (AP) mode.
*
* Initializes and starts the WiFi AP with the given SSID and password.
*
* @param ssid SSID for the AP (1-32 characters)
* @param password Password for the AP (min. 8 characters, optional)
* @param channel WiFi channel to use
* @param max_connections Maximum number of client connections
* @return ESP_OK on success, ESP_ERR_INVALID_ARG or other error codes on
* failure
*/
esp_err_t wifi_start_ap(const char *ssid, const char *password, uint8_t channel,
uint8_t max_connections);
/**
* @brief Stop WiFi Access Point (AP) mode.
*
* Deinitializes and stops the WiFi AP.
*/
void wifi_stop_ap(void);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,293 @@
#define LOG_TAG "WEBSRV"
/**
* @def LOG_TAG
* @brief Tag used for web server logging.
*/
#include "web_server.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "logger.h"
#include "storage.h"
// Default configuration values
/**
* @brief Default port for the web server.
*/
#define WEBSERVER_DEFAULT_PORT 80
/**
* @brief Default port for the web server.
*/
#define WEBSERVER_DEFAULT_MAX_HANDLERS 32
/**
* @brief Default maximum number of URI handlers.
*/
#define WEBSERVER_DEFAULT_STACK_SIZE (8 * 1024)
/**
* @brief Default stack size for the web server task.
*/
#define WEBSERVER_DEFAULT_TASK_PRIORITY 5
/**
* @brief Default task priority for the web server task.
*/
/**
* @brief Handle for the HTTP server instance.
*/
static httpd_handle_t s_server_handle = NULL;
/**
* @brief Handle for the FreeRTOS web server task.
*/
static TaskHandle_t s_server_task_handle = NULL;
/**
* @brief Get MIME type based on file extension
*/
static const char *get_mime_type(const char *filename) {
const char *dot = strrchr(filename, '.');
if (!dot)
return "application/octet-stream";
if (strcmp(dot, ".html") == 0)
return "text/html";
if (strcmp(dot, ".css") == 0)
return "text/css";
if (strcmp(dot, ".js") == 0)
return "application/javascript";
if (strcmp(dot, ".json") == 0)
return "application/json";
if (strcmp(dot, ".png") == 0)
return "image/png";
if (strcmp(dot, ".jpg") == 0 || strcmp(dot, ".jpeg") == 0)
return "image/jpeg";
if (strcmp(dot, ".gif") == 0)
return "image/gif";
if (strcmp(dot, ".svg") == 0)
return "image/svg+xml";
if (strcmp(dot, ".ico") == 0)
return "image/x-icon";
if (strcmp(dot, ".txt") == 0)
return "text/plain";
if (strcmp(dot, ".xml") == 0)
return "application/xml";
if (strcmp(dot, ".wav") == 0)
return "audio/wav";
if (strcmp(dot, ".mp3") == 0)
return "audio/mpeg";
return "application/octet-stream";
}
/**
* @brief HTTP handler for static files from LittleFS
*/
static esp_err_t static_file_handler(httpd_req_t *req) {
// Build the file path
char filepath[1024];
snprintf(filepath, sizeof(filepath), "%s%s", storage_get_mount_point(),
req->uri);
// Handle root path
if (strcmp(req->uri, "/") == 0) {
snprintf(filepath, sizeof(filepath), "%s/index.html",
storage_get_mount_point());
}
FILE *f = fopen(filepath, "r");
if (!f) {
LOGW("File not found: %s", filepath);
httpd_resp_send_404(req);
return ESP_OK;
}
// Get MIME type and set content type
const char *mime_type = get_mime_type(filepath);
httpd_resp_set_type(req, mime_type);
// Send file in chunks
char buf[1024];
size_t read_len;
while ((read_len = fread(buf, 1, sizeof(buf), f)) > 0) {
if (httpd_resp_send_chunk(req, buf, read_len) != ESP_OK) {
LOGW("Failed to send data chunk for %s", filepath);
break;
}
}
fclose(f);
httpd_resp_send_chunk(req, NULL, 0); // Send end marker
return ESP_OK;
}
/**
* @brief HTTP handler for API health check (GET /api/health)
*/
static esp_err_t health_check_handler(httpd_req_t *req) {
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, "{\"status\":\"ok\"}");
return ESP_OK;
}
/**
* @brief FreeRTOS task function for the HTTP server.
* Allows non-blocking server operation and future extensibility.
*/
static void webserver_task(void *arg) {
(void)arg; // Unused parameter
LOGI("Web server task started");
// Keep task alive - the server runs in the background
while (s_server_handle != NULL) {
vTaskDelay(pdMS_TO_TICKS(10000)); // 10 second check interval
}
LOGI("Web server task ending");
vTaskDelete(NULL);
}
/**
* @brief Start the web server with the given configuration.
*
* Initializes storage, configures the HTTP server, registers default handlers,
* and starts the FreeRTOS task for async operation.
*
* @param config Pointer to webserver configuration struct (optional)
* @return Handle to the running HTTP server, or NULL on failure
*/
httpd_handle_t webserver_start(const webserver_config_t *config) {
if (s_server_handle != NULL) {
LOGW("Web server already running");
return s_server_handle;
}
// Initialize LittleFS
esp_err_t ret = storage_init();
if (ret != ESP_OK) {
LOGE("Failed to initialize storage");
return NULL;
}
// Use provided config or defaults
uint16_t port = WEBSERVER_DEFAULT_PORT;
size_t max_handlers = WEBSERVER_DEFAULT_MAX_HANDLERS;
size_t stack_size = WEBSERVER_DEFAULT_STACK_SIZE;
UBaseType_t task_priority = WEBSERVER_DEFAULT_TASK_PRIORITY;
if (config) {
port = config->port;
max_handlers = config->max_uri_handlers;
stack_size = config->stack_size;
task_priority = config->task_priority;
}
// Create HTTP server configuration
httpd_config_t http_config = HTTPD_DEFAULT_CONFIG();
http_config.server_port = port;
http_config.max_uri_handlers = max_handlers;
http_config.stack_size = stack_size;
http_config.uri_match_fn = httpd_uri_match_wildcard;
// Start HTTP server
ret = httpd_start(&s_server_handle, &http_config);
if (ret != ESP_OK) {
LOGE("Failed to start HTTP server: %s", esp_err_to_name(ret));
s_server_handle = NULL;
return NULL;
}
LOGI("HTTP server started on port %d", port);
// Register default handlers
// Health check endpoint
httpd_uri_t health_uri = {
.uri = "/api/health",
.method = HTTP_GET,
.handler = health_check_handler,
.user_ctx = NULL,
};
httpd_register_uri_handler(s_server_handle, &health_uri);
// Wildcard handler for static files from LittleFS (must be last)
httpd_uri_t file_uri = {
.uri = "/*",
.method = HTTP_GET,
.handler = static_file_handler,
.user_ctx = NULL,
};
httpd_register_uri_handler(s_server_handle, &file_uri);
// Create FreeRTOS task for the server
// This allows other tasks to continue running and makes the server
// async-ready
BaseType_t task_ret = xTaskCreate(webserver_task, "webserver", stack_size,
(void *)s_server_handle, task_priority,
&s_server_task_handle);
if (task_ret != pdPASS) {
LOGE("Failed to create web server task");
httpd_stop(s_server_handle);
s_server_handle = NULL;
return NULL;
}
LOGI("Web server initialized successfully");
return s_server_handle;
}
/**
* @brief Stop the web server and clean up resources.
*
* Stops the HTTP server and deletes the FreeRTOS task.
*
* @param server Handle to the HTTP server instance
*/
void webserver_stop(httpd_handle_t server) {
if (server == NULL) {
return;
}
httpd_stop(server);
s_server_handle = NULL;
// Wait for task to finish
if (s_server_task_handle != NULL) {
vTaskDelay(pdMS_TO_TICKS(100));
s_server_task_handle = NULL;
}
LOGI("Web server stopped");
}
/**
* @brief Register a URI handler with the web server.
*
* @param server Handle to the HTTP server instance
* @param uri_handler Pointer to the URI handler struct
* @return ESP_OK on success, ESP_ERR_INVALID_ARG or other error codes on
* failure
*/
esp_err_t webserver_register_handler(httpd_handle_t server,
const httpd_uri_t *uri_handler) {
if (server == NULL || uri_handler == NULL) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t ret = httpd_register_uri_handler(server, uri_handler);
if (ret == ESP_OK) {
LOGI("Registered handler: %s [%d]", uri_handler->uri, uri_handler->method);
} else {
LOGE("Failed to register handler %s: %s", uri_handler->uri,
esp_err_to_name(ret));
}
return ret;
}

View file

@ -0,0 +1,105 @@
#define LOG_TAG "WIFI" ///< "WIFI" log tag for this file
#include <string.h>
#include "esp_event.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "logger.h"
#include "nvs_flash.h"
#include "wifi.h"
/**
* @brief Indicates whether the WiFi AP is started.
*/
static bool s_wifi_started = false;
/**
* @brief Start WiFi Access Point (AP) mode.
*
* Initializes and starts the WiFi AP with the given SSID and password.
*
* @param ssid SSID for the AP (1-32 characters)
* @param password Password for the AP (min. 8 characters, optional)
* @param channel WiFi channel to use
* @param max_connections Maximum number of client connections
* @return ESP_OK on success, ESP_ERR_INVALID_ARG or other error codes on
* failure
*/
esp_err_t wifi_start_ap(const char *ssid, const char *password, uint8_t channel,
uint8_t max_connections) {
if (s_wifi_started) {
return ESP_OK;
}
if (!ssid || strlen(ssid) == 0 || strlen(ssid) > 32) {
return ESP_ERR_INVALID_ARG;
}
const bool has_password = password && strlen(password) > 0;
if (has_password && strlen(password) < 8) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES ||
err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
if (err != ESP_OK) {
return err;
}
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_ap();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
wifi_config_t wifi_config = {
.ap =
{
.channel = channel,
.max_connection = max_connections,
.authmode = has_password ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN,
.pmf_cfg =
{
.required = false,
},
},
};
strlcpy((char *)wifi_config.ap.ssid, ssid, sizeof(wifi_config.ap.ssid));
wifi_config.ap.ssid_len = strlen(ssid);
if (has_password) {
strlcpy((char *)wifi_config.ap.password, password,
sizeof(wifi_config.ap.password));
}
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
s_wifi_started = true;
LOGI("WiFi AP started: SSID=%s channel=%u", ssid, channel);
return ESP_OK;
}
/**
* @brief Stop WiFi Access Point (AP) mode.
*
* Deinitializes and stops the WiFi AP.
*/
void wifi_stop_ap(void) {
if (!s_wifi_started) {
return;
}
esp_wifi_stop();
esp_wifi_deinit();
s_wifi_started = false;
LOGI("WiFi AP stopped");
}

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#fff" d="M6.375 5.668 1.207.5H.5v.707l5.168 5.168H1.375v1h6v-6h-1zM10.207 9.5H14.5v-1h-6v6h1v-4.293l5.293 5.293h.707v-.707z"/></svg>
<path
d="M6.375 5.66787L1.20712 0.5H0.5V1.20712L5.66787 6.375H1.375V7.375H7.375V1.375H6.375V5.66787ZM10.2071 9.5H14.5V8.5H8.5V14.5H9.5V10.2071L14.7929 15.5H15.5V14.7929L10.2071 9.5Z"
fill="#ffffff" />
</svg>

Before

Width:  |  Height:  |  Size: 323 B

After

Width:  |  Height:  |  Size: 240 B

Before After
Before After

View file

@ -1,11 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#087e8b" d="M2.063 8v1c2.757 0 5 2.243 5 5h1c0-3.308-2.692-6-6-6"/><path fill="#087e8b" d="M2.063 4.375v1c4.755 0 8.625 3.87 8.625 8.625h1a9.56 9.56 0 0 0-2.82-6.806 9.56 9.56 0 0 0-6.805-2.819"/><path fill="#087e8b" d="M14.271 8.842a13.2 13.2 0 0 0-2.84-4.211A13.2 13.2 0 0 0 2.064.75v1c6.754 0 12.25 5.495 12.25 12.25h1a13.2 13.2 0 0 0-1.042-5.158M2.813 11.25a2 2 0 1 0 0 4 2 2 0 0 0 0-4m0 3a1 1 0 1 1 0-2 1 1 0 0 1 0 2"/></svg>
<path
d="M2.0625 8.00001V9.00001C4.81953 9.00001 7.0625 11.243 7.0625 14H8.0625C8.0625 10.6916 5.37091 8.00001 2.0625 8.00001Z"
fill="#087E8B" />
<path
d="M2.0625 4.37501V5.37501C6.81834 5.37501 10.6875 9.24417 10.6875 14H11.6875C11.691 12.7355 11.4436 11.4829 10.9597 10.3147C10.4758 9.14644 9.76498 8.08578 8.86841 7.1941C7.97671 6.29756 6.91605 5.58677 5.74782 5.10287C4.57958 4.61898 3.32698 4.37158 2.0625 4.37501Z"
fill="#087E8B" />
<path
d="M14.2711 8.84235C13.606 7.26802 12.6417 5.83774 11.4317 4.63085C10.2026 3.3987 8.74218 2.42156 7.13432 1.75556C5.52646 1.08956 3.80284 0.747829 2.0625 0.75001V1.75001C8.81716 1.75001 14.3125 7.24535 14.3125 14H15.3125C15.3159 12.2282 14.9616 10.474 14.2711 8.84235ZM2.8125 11.25C2.41694 11.25 2.03026 11.3673 1.70136 11.5871C1.37246 11.8068 1.11612 12.1192 0.964742 12.4846C0.813367 12.8501 0.77376 13.2522 0.85093 13.6402C0.928101 14.0282 1.11858 14.3845 1.39829 14.6642C1.67799 14.9439 2.03436 15.1344 2.42232 15.2116C2.81028 15.2888 3.21242 15.2491 3.57787 15.0978C3.94332 14.9464 4.25568 14.69 4.47544 14.3612C4.6952 14.0323 4.8125 13.6456 4.8125 13.25C4.81192 12.7198 4.60102 12.2114 4.22608 11.8364C3.85113 11.4615 3.34276 11.2506 2.8125 11.25ZM2.8125 14.25C2.61472 14.25 2.42138 14.1914 2.25693 14.0815C2.09248 13.9716 1.96431 13.8154 1.88862 13.6327C1.81293 13.45 1.79313 13.2489 1.83172 13.0549C1.8703 12.8609 1.96554 12.6828 2.10539 12.5429C2.24525 12.4031 2.42343 12.3078 2.61741 12.2692C2.81139 12.2306 3.01246 12.2504 3.19518 12.3261C3.37791 12.4018 3.53409 12.53 3.64397 12.6944C3.75385 12.8589 3.8125 13.0522 3.8125 13.25C3.8122 13.5151 3.70675 13.7693 3.51928 13.9568C3.33181 14.1443 3.07763 14.2497 2.8125 14.25Z"
fill="#087E8B" />
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 538 B

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#087e8b" d="M15.5 8.5v-1h-7V6h1.75a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0-.75.75v4a.75.75 0 0 0 .75.75H7.5v1.5h-7v1H3V10H1.293a.75.75 0 0 0-.75.75v4a.75.75 0 0 0 .75.75H5.75a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 0-.75-.75H4V8.5h8V10h-1.75a.75.75 0 0 0-.75.75v4a.75.75 0 0 0 .75.75h4.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 0-.75-.75H13V8.5zM6 1.5h4V5H6zm-.5 13H1.543V11H5.5zm9 0h-4V11h4z"/></svg>
<path
d="M15.5 8.5V7.5H8.5V6H10.25C10.4488 5.99978 10.6395 5.92069 10.7801 5.78008C10.9207 5.63948 10.9998 5.44884 11 5.25V1.25C10.9998 1.05116 10.9207 0.86052 10.7801 0.719917C10.6395 0.579313 10.4488 0.500223 10.25 0.5H5.75C5.55116 0.500223 5.36052 0.579313 5.21992 0.719917C5.07931 0.86052 5.00022 1.05116 5 1.25V5.25C5.00022 5.44884 5.07931 5.63948 5.21992 5.78008C5.36052 5.92069 5.55116 5.99978 5.75 6H7.5V7.5H0.5V8.5H3V10H1.29347C1.09463 10.0002 0.904011 10.0793 0.763413 10.2199C0.622814 10.3605 0.543717 10.5512 0.543469 10.75V14.75C0.543717 14.9488 0.622814 15.1395 0.763413 15.2801C0.904011 15.4207 1.09463 15.4998 1.29347 15.5H5.75C5.94884 15.4998 6.13948 15.4207 6.28008 15.2801C6.42069 15.1395 6.49978 14.9488 6.5 14.75V10.75C6.49978 10.5512 6.42069 10.3605 6.28008 10.2199C6.13948 10.0793 5.94884 10.0002 5.75 10H4V8.5H12V10H10.25C10.0512 10.0002 9.86052 10.0793 9.71992 10.2199C9.57931 10.3605 9.50022 10.5512 9.5 10.75V14.75C9.50022 14.9488 9.57931 15.1395 9.71992 15.2801C9.86052 15.4207 10.0512 15.4998 10.25 15.5H14.75C14.9488 15.4998 15.1395 15.4207 15.2801 15.2801C15.4207 15.1395 15.4998 14.9488 15.5 14.75V10.75C15.4998 10.5512 15.4207 10.3605 15.2801 10.2199C15.1395 10.0793 14.9488 10.0002 14.75 10H13V8.5H15.5ZM6 1.5H10V5H6V1.5ZM5.5 14.5H1.54347V11H5.5V14.5ZM14.5 14.5H10.5V11H14.5V14.5Z"
fill="#087E8B" />
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 522 B

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#fff" d="M6.5 1.5v-1h-6v6h1V2.207L6.521 7.23l.708-.708L2.207 1.5zm8 8v4.293L9.354 8.646l-.708.708 5.147 5.146H9.5v1h6v-6z"/></svg>
<path
d="M6.5 1.5V0.5H0.5V6.5H1.5V2.20709L6.52147 7.22853L7.22853 6.52147L2.20709 1.5H6.5ZM14.5 9.5V13.7929L9.35353 8.64647L8.64647 9.35353L13.7929 14.5H9.5V15.5H15.5V9.5H14.5Z"
fill="#ffffff" />
</svg>

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 238 B

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#fff" d="m12.818 4.158-.905-.905L14.5 3.25v-1l-4.25.005V6.5h1V4.004l.86.86A4.766 4.766 0 0 1 8.75 13l.002 1a5.765 5.765 0 0 0 4.067-9.842M7.25 3.25l-.002-1a5.766 5.766 0 0 0-4.067 9.842L4.09 13H1.5v1h4.25V9.75h-1v2.496l-.86-.86A4.766 4.766 0 0 1 7.25 3.25"/></svg>
<path
d="M12.8177 4.15769L11.9127 3.25275L14.5004 3.25L14.4994 2.25L10.2499 2.2545V6.5H11.2499V4.00413L12.1106 4.86478C12.7765 5.53078 13.2301 6.37915 13.4142 7.30279C13.5983 8.22642 13.5046 9.1839 13.145 10.0543C12.7853 10.9247 12.1758 11.6691 11.3934 12.1934C10.611 12.7176 9.69083 12.9983 8.74903 13L8.75078 14C9.89021 13.998 11.0035 13.6584 11.9501 13.0241C12.8966 12.3898 13.634 11.4893 14.0692 10.4362C14.5043 9.38312 14.6177 8.22473 14.3949 7.10728C14.1722 5.98983 13.6233 4.96343 12.8177 4.15769ZM7.25078 3.25L7.24903 2.25C6.10959 2.25203 4.99631 2.59162 4.04974 3.22591C3.10317 3.86019 2.36577 4.76073 1.93064 5.8138C1.4955 6.86688 1.38215 8.02527 1.6049 9.14272C1.82764 10.2602 2.37649 11.2866 3.18215 12.0923L4.08984 13H1.4999V14H5.7499V9.75H4.7499V12.2459L3.88925 11.3852C3.22333 10.7192 2.76968 9.87085 2.58557 8.94721C2.40147 8.02358 2.49516 7.0661 2.85483 6.19568C3.21449 5.32525 3.824 4.58091 4.60639 4.05664C5.38878 3.53237 6.30897 3.25168 7.25078 3.25Z"
fill="#ffffff" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 372 B

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#087e8b" d="M7.43 15h1.14l7.18-10.255v-.857l-.007-.005a13.5 13.5 0 0 0-15.486 0l-.007.005v.857zM8 2.442c2.395-.004 4.74.684 6.755 1.981L8 14.07 1.246 4.423A12.43 12.43 0 0 1 8 2.442"/></svg>
<path
d="M7.43056 15H8.56944L15.75 4.74509V3.88752L15.7433 3.88284C13.4738 2.29385 10.7704 1.44153 8 1.44153C5.22956 1.44153 2.52618 2.29385 0.256719 3.88284L0.25 3.88752V4.74496L7.43056 15ZM8 2.44152C10.3954 2.43769 12.7409 3.12589 14.7545 4.42337L8 14.0698L1.2455 4.42337C3.25908 3.12589 5.6046 2.43769 8 2.44152Z"
fill="#087E8B" />
</svg>

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 298 B

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#087e8b" d="M7.43 15h1.14l7.18-10.255v-.857l-.007-.005a13.5 13.5 0 0 0-15.486 0l-.007.005v.857zm-1.84-4.373a4.94 4.94 0 0 1 4.82 0L8 14.07zM8 2.442c2.395-.004 4.74.684 6.755 1.981l-3.769 5.382a5.94 5.94 0 0 0-5.972 0L1.245 4.423A12.43 12.43 0 0 1 8 2.442"/></svg>
<path
d="M7.43056 15H8.56944L15.75 4.74509V3.88752L15.7433 3.88284C13.4738 2.29385 10.7704 1.44153 8 1.44153C5.22956 1.44153 2.52618 2.29385 0.256719 3.88284L0.25 3.88752V4.74496L7.43056 15ZM5.58959 10.6274C6.32637 10.216 7.15616 10 8 10C8.84384 10 9.67363 10.216 10.4104 10.6274L8 14.0698L5.58959 10.6274ZM8 2.44152C10.3954 2.43769 12.7409 3.12589 14.7545 4.42337L10.9863 9.80496C10.0794 9.27774 9.04903 9.00001 8 9.00001C6.95097 9.00001 5.92064 9.27774 5.01372 9.80496L1.2455 4.42337C3.25908 3.12589 5.6046 2.43769 8 2.44152Z"
fill="#087E8B" />
</svg>

Before

Width:  |  Height:  |  Size: 669 B

After

Width:  |  Height:  |  Size: 371 B

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#087e8b" d="M15.743 3.883a13.5 13.5 0 0 0-15.486 0l-.007.005v.857L7.43 15h1.14l7.18-10.255v-.857zm-9.811 7.233 2.374-3.668a7.5 7.5 0 0 1 1.352.178l-2.975 4.562zm-.619-.884L4.117 8.525c.904-.55 1.915-.9 2.966-1.028zm1.988 2.839 3.355-5.145q.64.243 1.227.599L8 14.07zm5.156-5.367a8.5 8.5 0 0 0-8.914 0l-2.297-3.28a12.5 12.5 0 0 1 13.509 0z"/></svg>
<path
d="M15.7433 3.88296C13.4738 2.29397 10.7704 1.44165 8 1.44165C5.22956 1.44165 2.52618 2.29397 0.256719 3.88296L0.25 3.88752V4.74496L7.43056 15H8.56944L15.75 4.74508V3.88752L15.7433 3.88296ZM5.932 11.1164L8.30575 7.4479C8.761 7.46593 9.21367 7.5254 9.65812 7.62558L6.6825 12.1881L5.932 11.1164ZM5.31297 10.2323L4.11719 8.52452C5.02149 7.97522 6.0325 7.62501 7.08281 7.49724L5.31297 10.2323ZM7.30066 13.071L10.6561 7.92602C11.0824 8.08771 11.493 8.28804 11.8828 8.52452L8 14.0698L7.30066 13.071ZM12.4575 7.70387C11.1172 6.87852 9.57404 6.4415 8 6.4415C6.42596 6.4415 4.88283 6.87852 3.54253 7.70387L1.24566 4.42337C3.26085 3.12941 5.6053 2.44153 8.00016 2.44153C10.395 2.44153 12.7395 3.12941 14.7547 4.42337L12.4575 7.70387Z"
fill="#087E8B" />
</svg>

Before

Width:  |  Height:  |  Size: 871 B

After

Width:  |  Height:  |  Size: 454 B

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#087e8b" d="M15.743 3.883a13.5 13.5 0 0 0-15.486 0l-.007.005v.857L7.43 15h1.14l7.18-10.255v-.857zm-9.81 7.235L9.86 5.114q.671.127 1.318.344l-4.49 6.736zm-.617-.882-.827-1.18 2.798-4.09a10 10 0 0 1 1.472.004zM3.876 8.18 2.681 6.473a10 10 0 0 1 3.267-1.32zm3.426 4.893 4.827-7.24q.616.28 1.19.64L8 14.07zm6.591-7.42a11 11 0 0 0-11.786 0l-.862-1.23a12.5 12.5 0 0 1 13.51 0z"/></svg>
<path
d="M15.7433 3.88296C13.4738 2.29397 10.7704 1.44165 8 1.44165C5.22956 1.44165 2.52618 2.29397 0.256719 3.88296L0.25 3.88752V4.74496L7.43056 15H8.56944L15.7502 4.74508V3.88752L15.7433 3.88296ZM5.93344 11.1184L9.85909 5.11446C10.3059 5.19846 10.7463 5.31312 11.1773 5.45762L6.6865 12.1938L5.93344 11.1184ZM5.31563 10.2361L4.48897 9.05549L7.28656 4.96665C7.5231 4.94998 7.76092 4.94165 8 4.94165C8.25417 4.94165 8.50704 4.95121 8.75863 4.97033L5.31563 10.2361ZM3.87613 8.18024L2.68088 6.4733C3.68242 5.84205 4.7887 5.39483 5.94756 5.15274L3.87613 8.18024ZM7.30216 13.0732L12.1293 5.83255C12.5399 6.01916 12.9374 6.23328 13.3191 6.47346L8 14.0698L7.30216 13.0732ZM13.8931 5.65358C12.131 4.53534 10.087 3.94154 8 3.94154C5.91299 3.94154 3.86905 4.53534 2.10691 5.65358L1.2455 4.42337C3.26069 3.12941 5.60515 2.44153 8 2.44153C10.3949 2.44153 12.7393 3.12941 14.7545 4.42337L13.8931 5.65358Z"
fill="#087E8B" />
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 487 B

Before After
Before After

View file

@ -1,5 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16"><path fill="#087e8b" d="M15.743 3.883a13.5 13.5 0 0 0-15.486 0l-.007.005v.857L7.43 15h1.14l7.18-10.255v-.857zm-3.139-.565-5.917 8.876-.753-1.075 5.406-8.226q.643.178 1.264.425m-3.654-.84a13 13 0 0 1 1.351.175l-4.984 7.585-.83-1.185zM3.874 8.176 3.16 7.159l3.062-4.592q.766-.11 1.541-.123zm.96-5.33L2.547 6.28l-1.3-1.857a12.4 12.4 0 0 1 3.589-1.577M8 14.07l-.698-.997 6.229-9.343q.631.313 1.223.693z"/></svg>
<path
d="M15.7433 3.88296C13.4738 2.29397 10.7704 1.44165 8 1.44165C5.22956 1.44165 2.52618 2.29397 0.256719 3.88296L0.25 3.88752V4.74493L7.43056 15H8.56944L15.75 4.74505V3.88752L15.7433 3.88296ZM12.604 3.31771L6.6865 12.1938L5.93412 11.1194L11.3399 2.89324C11.7686 3.01191 12.1905 3.15361 12.604 3.31771ZM8.95 2.47746C9.4032 2.51151 9.8542 2.57026 10.301 2.65343L5.31694 10.2378L4.48769 9.05343L8.95 2.47746ZM3.87384 8.17702L3.16134 7.15943L6.22291 2.56708C6.73369 2.49445 7.24845 2.45326 7.76428 2.44377L3.87384 8.17702ZM4.83481 2.84643L2.54566 6.28018L1.24566 4.42337C2.35118 3.71134 3.5626 3.17908 4.83481 2.84643ZM8 14.0698L7.30216 13.0731L13.5308 3.73027C13.9513 3.93827 14.3598 4.16966 14.7545 4.42337L8 14.0698Z"
fill="#087E8B" />
</svg>

Before

Width:  |  Height:  |  Size: 861 B

After

Width:  |  Height:  |  Size: 503 B

Before After
Before After

View file

@ -1,328 +1,313 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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="/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="/networks.js" defer></script> <script type="module" src="/networks.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> <script type="module" src="/reset.js" defer></script>
<script type="module" src="/range-input.js" defer></script> <script type="module" src="/range-input.js" defer></script>
<script type="module" src="/status.js" defer></script> <script type="module" src="/status.js" defer></script>
<script type="module" src="/websocket.js" defer></script> <script type="module" src="/websocket.js" defer></script>
</head> </head>
<body> <body>
<main> <main>
<section class="loading-screen"> <section class="loading-screen">
<div class="spinner-container"> <div class="spinner-container">
<!-- h2 is filled dynamically --> <!-- h2 is filled dynamically -->
<h2></h2> <h2></h2>
<div class="spinner"></div> <div class="spinner"></div>
<button <button
class="reload" class="reload"
type="button" type="button"
onclick="window.location.reload()" onclick="window.location.reload()"
> >
Seite neu laden Seite neu laden
</button> </button>
</div> </div>
</section> </section>
<section class="content hidden"> <section class="content hidden">
<div class="status"> <div class="status">
<!-- placeholder for wifi icon --> <!-- placeholder for wifi icon -->
<img class="connection-icon" src="" alt="" /> <img class="connection-icon" src="" alt="" />
<span>Temp.: <span class="cpu-temp"></span> °C</span> <span>Temp.: <span class="cpu-temp"></span> °C</span>
<span>Heap: <span class="heap-percentage"></span> %</span> <span>Heap: <span class="heap-percentage"></span> %</span>
<span>PSRAM: <span class="psram-percentage"></span> %</span> <span>PSRAM: <span class="psram-percentage"></span> %</span>
<span>Uptime: <span class="uptime"></span></span> <span>Uptime: <span class="uptime"></span></span>
<button type="button" class="expand-status icon-button"> <button type="button" class="expand-status icon-button">
<img src="/icons/open.svg" alt="Mehr" /> <img src="/icons/open.svg" alt="Mehr" />
</button> </button>
</div> </div>
<dialog class="dialog-status"> <dialog class="dialog-status">
<div class="dialog-header"> <div class="dialog-header">
<h2 class="model"></h2> <h2 class="model"></h2>
<form method="dialog"> <form method="dialog">
<button type="submit" class="icon-button"> <button type="submit" class="icon-button">
<img src="/icons/close.svg" alt="Schließen" /> <img src="/icons/close.svg" alt="Schließen" />
</button> </button>
</form> </form>
</div> </div>
<div class="dialog-status-content"> <div class="dialog-status-content">
<div class="card"> <div class="card">
<span>Signalstärke</span> <span>Signalstärke</span>
<span class="centered-vertical"> <span class="centered-vertical">
<img <img class="connection-icon small" src="" alt="" />
class="connection-icon small" <span><span class="rssi"></span> dBm</span>
src="" </span>
alt="" </div>
/>
<span><span class="rssi"></span> dBm</span>
</span>
</div>
<div class="card"> <div class="card">
<span>Uptime</span> <span>Uptime</span>
<span class="uptime"></span> <span class="uptime"></span>
</div> </div>
<div class="card"> <div class="card">
<span>CPU-Temperatur</span> <span>CPU-Temperatur</span>
<span><span class="cpu-temp"></span> °C</span> <span><span class="cpu-temp"></span> °C</span>
</div> </div>
<div class="card"> <div class="card">
<span>CPU Cycle Count</span> <span>CPU Cycle Count</span>
<span class="cpu-cycle-count"></span> <span class="cpu-cycle-count"></span>
</div> </div>
<div class="card"> <div class="card">
<span>Heap</span> <span>Heap</span>
<span><span class="heap-percentage"></span> %</span> <span><span class="heap-percentage"></span> %</span>
<span <span
><span class="heap-used"></span> / ><span class="heap-used"></span> /
<span class="heap-total"></span <span class="heap-total"></span
></span> ></span>
</div> </div>
<div class="card"> <div class="card">
<span>PSRAM</span> <span>PSRAM</span>
<span <span><span class="psram-percentage"></span> %</span>
><span class="psram-percentage"></span> %</span <span
> ><span class="psram-used"></span> /
<span <span class="psram-total"></span
><span class="psram-used"></span> / ></span>
<span class="psram-total"></span </div>
></span>
</div>
<div class="card"> <div class="card">
<span>CPU-Taktfrequenz</span> <span>CPU-Taktfrequenz</span>
<span><span class="cpu-freq"></span> MHz</span> <span><span class="cpu-freq"></span> MHz</span>
</div> </div>
<div class="card"> <div class="card">
<span>MAC-Adresse</span> <span>MAC-Adresse</span>
<span class="mac"></span> <span class="mac"></span>
</div> </div>
<div class="card"> <div class="card">
<span>Script-Hash</span> <span>Script-Hash</span>
<span class="hash"></span> <span class="hash"></span>
</div> </div>
<div class="card"> <div class="card">
<span>SDK-Version</span> <span>SDK-Version</span>
<span class="sdk-version"></span> <span class="sdk-version"></span>
</div> </div>
</div> </div>
</dialog> </dialog>
<form class="config"> <form class="config">
<h1>Konfiguration</h1> <h1>Konfiguration</h1>
<fieldset> <fieldset>
<legend>Verbindung</legend> <legend>Verbindung</legend>
<label> <label>
<span>IP-Zuweisung:</span> <span>IP-Zuweisung:</span>
<select <select
name="ip-method" name="ip-method"
id="input-ip-method" id="input-ip-method"
title="IP-" title="IP-"
required required
> >
<option value="0">Statisch</option> <option value="0">Statisch</option>
<option value="1">DHCP</option> <option value="1">DHCP</option>
</select> </select>
</label> </label>
<div data-field="input-ip-method" data-values="0"> <div data-field="input-ip-method" data-values="0">
<label> <label>
<span>IP-Adresse:</span> <span>IP-Adresse:</span>
<input <input
type="text" type="text"
name="ip" name="ip"
id="input-ip" id="input-ip"
placeholder="IP-Adresse" placeholder="IP-Adresse"
required required
/> />
</label> </label>
<label> <label>
<span>Subnetzmaske:</span> <span>Subnetzmaske:</span>
<input <input
type="text" type="text"
name="subnet" name="subnet"
id="input-subnet" id="input-subnet"
placeholder="Subnetzmaske" placeholder="Subnetzmaske"
required required
/> />
</label> </label>
<label> <label>
<span>Gateway:</span> <span>Gateway:</span>
<input <input
type="text" type="text"
name="gateway" name="gateway"
id="input-gateway" id="input-gateway"
placeholder="Gateway" placeholder="Gateway"
required required
/> />
</label> </label>
</div> </div>
<label> <label>
<span>Verbindungsmethode:</span> <span>Verbindungsmethode:</span>
<select <select
name="connection" name="connection"
id="input-connection" id="input-connection"
title="Verbindung" title="Verbindung"
required required
> >
<option value="0">WiFi-Station</option> <option value="0">WiFi-Station</option>
<option value="1">WiFi-AccessPoint</option> <option value="1">WiFi-AccessPoint</option>
<option value="2">Ethernet</option> <option value="2">Ethernet</option>
</select> </select>
</label> </label>
<div data-field="input-connection" data-values="1"> <div data-field="input-connection" data-values="1">
<label> <label>
<span>SSID:</span> <span>SSID:</span>
<input <input
type="text" type="text"
name="ssid" name="ssid"
id="input-ssid" id="input-ssid"
placeholder="SSID" placeholder="SSID"
required required
/> />
</label> </label>
</div> </div>
<div data-field="input-connection" data-values="0"> <div data-field="input-connection" data-values="0">
<label> <label>
<span>Netzwerk:</span> <span>Netzwerk:</span>
<select <select
name="ssid" name="ssid"
id="select-network" id="select-network"
title="Netzwerk" title="Netzwerk"
required required
></select> ></select>
<button <button type="button" id="refresh-networks" class="icon-button">
type="button" <img src="/icons/refresh.svg" alt="Neu laden" />
id="refresh-networks" </button>
class="icon-button" </label>
> </div>
<img <div data-field="input-connection" data-values="0|1">
src="/icons/refresh.svg" <label>
alt="Neu laden" <span>Password:</span>
/> <input
</button> type="password"
</label> name="password"
</div> id="input-password"
<div data-field="input-connection" data-values="0|1"> placeholder="Passwort"
<label> />
<span>Password:</span> </label>
<input </div>
type="password" </fieldset>
name="password" <fieldset>
id="input-password" <legend>Input/Output 1</legend>
placeholder="Passwort" <label class="switch">
/> <span>Output</span>
</label> <input
</div> type="checkbox"
</fieldset> name="direction-1"
<fieldset> id="input-direction-1"
<legend>Input/Output 1</legend> data-value-not-checked="0"
<label class="switch"> data-value-checked="1"
<span>Output</span> />
<input <span class="slider"></span>
type="checkbox" <span>Input</span>
name="direction-1" </label>
id="input-direction-1" <label>
data-value-not-checked="0" ArtNet-Universe:
data-value-checked="1" <input
/> type="number"
<span class="slider"></span> name="universe-1"
<span>Input</span> id="universe-1"
</label> placeholder="Universe"
<label> min="0"
ArtNet-Universe: max="15"
<input />
type="number" </label>
name="universe-1" </fieldset>
id="universe-1" <fieldset>
placeholder="Universe" <legend>Input/Output 2</legend>
min="0" <label class="switch">
max="15" <span>Output</span>
/> <input
</label> type="checkbox"
</fieldset> name="direction-2"
<fieldset> id="input-direction-2"
<legend>Input/Output 2</legend> data-value-not-checked="0"
<label class="switch"> data-value-checked="1"
<span>Output</span> />
<input <span class="slider"></span>
type="checkbox" <span>Input</span>
name="direction-2" </label>
id="input-direction-2" <label>
data-value-not-checked="0" ArtNet-Universe:
data-value-checked="1" <input
/> type="number"
<span class="slider"></span> name="universe-2"
<span>Input</span> id="universe-2"
</label> placeholder="Universe"
<label> min="0"
ArtNet-Universe: max="15"
<input />
type="number" </label>
name="universe-2" </fieldset>
id="universe-2" <fieldset>
placeholder="Universe" <legend>Sonstiges</legend>
min="0" <label>
max="15" LED-Helligkeit
/> <div>
</label> <input
</fieldset> type="range"
<fieldset> name="led-brightness"
<legend>Sonstiges</legend> id="led-brightness"
<label> min="0"
LED-Helligkeit max="255"
<div> class="range"
<input />
type="range" <span class="range-value"></span>
name="led-brightness" </div>
id="led-brightness" </label>
min="0" <label>
max="255" <span>Aktion bei Knopfdruck:</span>
class="range" <select
/> name="button-action"
<span class="range-value"></span> id="input-button-action"
</div> title="Aktion bei Knopfdruck"
</label> required
<label> >
<span>Aktion bei Knopfdruck:</span> <option value="0">Nichts</option>
<select <option value="1">Konfiguration zurücksetzen</option>
name="button-action" <option value="2">Neustart</option>
id="input-button-action" </select>
title="Aktion bei Knopfdruck" </label>
required </fieldset>
>
<option value="0">Nichts</option>
<option value="1">
Konfiguration zurücksetzen
</option>
<option value="2">Neustart</option>
</select>
</label>
</fieldset>
<div class="buttons"> <div class="buttons">
<button type="reset">Zurücksetzen</button> <button type="reset">Zurücksetzen</button>
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
</div> </div>
</form> </form>
</section> </section>
</main> </main>
</body> </body>
</html> </html>

View file

@ -4,20 +4,20 @@ const dynamicInputs = form.querySelectorAll("[data-field][data-values]");
document.addEventListener("change", updateVisibility); document.addEventListener("change", updateVisibility);
function updateVisibility() { function updateVisibility() {
dynamicInputs.forEach((element) => { dynamicInputs.forEach(element => {
const input = form.querySelector(`#${element.dataset.field}`); const input = form.querySelector(`#${element.dataset.field}`);
if (element.dataset.values.split("|").includes(input.value)) { if (element.dataset.values.split("|").includes(input.value)) {
element.classList.remove("hidden"); element.classList.remove("hidden");
element element
.querySelectorAll("input, select, button, textarea") .querySelectorAll("input, select, button, textarea")
.forEach((childInput) => (childInput.disabled = false)); .forEach(childInput => (childInput.disabled = false));
} else { } else {
element.classList.add("hidden"); element.classList.add("hidden");
element element
.querySelectorAll("input, select, button, textarea") .querySelectorAll("input, select, button, textarea")
.forEach((childInput) => (childInput.disabled = true)); .forEach(childInput => (childInput.disabled = true));
} }
}); });
} }
updateVisibility(); updateVisibility();

View file

@ -1,7 +1,7 @@
import { import {
showLoadingScreen, showLoadingScreen,
showError, showError,
hideLoadingScreen, hideLoadingScreen,
} from "./loading-screen.js"; } from "./loading-screen.js";
const form = document.querySelector("form.config"); const form = document.querySelector("form.config");
@ -9,46 +9,46 @@ const form = document.querySelector("form.config");
export let data = {}; export let data = {};
export async function loadData(timeout = null) { export async function loadData(timeout = null) {
const req = await fetch("/config", { const req = await fetch("/config", {
method: "GET", method: "GET",
signal: timeout !== null ? AbortSignal.timeout(timeout) : undefined, 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}`);
} }
const json = await req.json(); const json = await req.json();
console.log(json); console.log(json);
return json; return json;
} }
export function writeDataToInput(data) { export function writeDataToInput(data) {
console.log("write 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(key, element); console.log(key, element);
if (element.type === "checkbox") { if (element.type === "checkbox") {
element.checked = value; element.checked = value;
} else { } else {
element.value = value; element.value = value;
}
if (element.type === "range") {
// update text next to the slider by sending an event
element.dispatchEvent(new Event("input", { bubbles: true }));
}
} }
// send "change" event
form.dispatchEvent(new Event("change", { bubbles: true })); if (element.type === "range") {
// update text next to the slider by sending an event
element.dispatchEvent(new Event("input", { bubbles: true }));
}
}
// send "change" event
form.dispatchEvent(new Event("change", { bubbles: true }));
} }
showLoadingScreen("Konfiguration wird geladen..."); showLoadingScreen("Konfiguration wird geladen...");
try { try {
data = await loadData(); data = await loadData();
hideLoadingScreen(); hideLoadingScreen();
writeDataToInput(data); writeDataToInput(data);
} catch (error) { } catch (error) {
console.log(error.message); console.log(error.message);
showError("Die Konfiguration konnte nicht geladen werden."); showError("Die Konfiguration konnte nicht geladen werden.");
} }

View file

@ -5,36 +5,36 @@ const spinner = loadingScreen.querySelector(".spinner");
const reloadBtn = loadingScreen.querySelector(".reload"); const reloadBtn = loadingScreen.querySelector(".reload");
export function showLoadingScreen(msg) { export function showLoadingScreen(msg) {
hide(content, reloadBtn); hide(content, reloadBtn);
show(loadingScreen, spinner); show(loadingScreen, spinner);
loadingMsg.classList.remove("error"); loadingMsg.classList.remove("error");
loadingMsg.textContent = msg; loadingMsg.textContent = msg;
} }
export function showError(msg) { export function showError(msg) {
showLoadingScreen(msg); showLoadingScreen(msg);
loadingMsg.innerHTML += loadingMsg.innerHTML +=
"<br/>Stelle sicher, dass du mit dem DMX-Interface verbunden bist und die IP-Adresse stimmt."; "<br/>Stelle sicher, dass du mit dem DMX-Interface verbunden bist und die IP-Adresse stimmt.";
show(reloadBtn); show(reloadBtn);
hide(spinner); hide(spinner);
loadingMsg.classList.add("error"); loadingMsg.classList.add("error");
} }
export function hideLoadingScreen() { export function hideLoadingScreen() {
hide(loadingScreen, reloadBtn); hide(loadingScreen, reloadBtn);
show(content); show(content);
loadingMsg.classList.remove("error"); loadingMsg.classList.remove("error");
loadingMsg.textContent = ""; loadingMsg.textContent = "";
} }
function show(...elements) { function show(...elements) {
for (const element of elements) { for (const element of elements) {
element.classList.remove("hidden"); element.classList.remove("hidden");
} }
} }
function hide(...elements) { function hide(...elements) {
for (const element of elements) { for (const element of elements) {
element.classList.add("hidden"); element.classList.add("hidden");
} }
} }

View file

@ -7,67 +7,67 @@ const refreshIcon = refreshButton.querySelector("img");
let isLoading = false; let isLoading = false;
refreshButton.addEventListener("click", async () => { refreshButton.addEventListener("click", async () => {
// check if interface is in WiFi-AccessPoint mode // check if interface is in WiFi-AccessPoint mode
if (data.connection == 1) { if (data.connection == 1) {
alert( alert(
"Beim WLAN-Scan wird die Verbindung hardwarebedingt kurzzeitig" + "Beim WLAN-Scan wird die Verbindung hardwarebedingt kurzzeitig" +
"unterbrochen.\n" + "unterbrochen.\n" +
"Möglicherweise muss das Interface neu verbunden werden." "Möglicherweise muss das Interface neu verbunden werden."
); );
} }
updateNetworks(); updateNetworks();
}); });
// check if connected via WiFi-Station // check if connected via WiFi-Station
if (data.connection === 0) { if (data.connection === 0) {
// show currently connected WiFi // show currently connected WiFi
insertNetworks([data.ssid]); insertNetworks([data.ssid]);
} }
function insertNetworks(networks) { function insertNetworks(networks) {
networkDropdown.textContent = ""; // clear dropdown networkDropdown.textContent = ""; // clear dropdown
for (const ssid of networks) { for (const ssid of networks) {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = ssid; option.value = ssid;
option.innerText = ssid; option.innerText = ssid;
networkDropdown.appendChild(option); networkDropdown.appendChild(option);
} }
} }
async function loadNetworks() { async function loadNetworks() {
if (isLoading) return; if (isLoading) return;
isLoading = true; isLoading = true;
refreshButton.classList.remove("error-bg"); refreshButton.classList.remove("error-bg");
refreshIcon.classList.add("spinning"); refreshIcon.classList.add("spinning");
try { try {
const res = await fetch("/networks", { const res = await fetch("/networks", {
method: "GET", method: "GET",
}); });
if (!res.ok) { if (!res.ok) {
throw Error(`response status: ${res.status}`); throw Error(`response status: ${res.status}`);
}
const networks = await res.json();
refreshIcon.classList.remove("spinning");
isLoading = false;
// remove duplicate values
return Array.from(new Set(networks));
} catch (e) {
refreshIcon.classList.remove("spinning");
refreshButton.classList.add("error-bg");
isLoading = false;
return [];
} }
const networks = await res.json();
refreshIcon.classList.remove("spinning");
isLoading = false;
// remove duplicate values
return Array.from(new Set(networks));
} catch (e) {
refreshIcon.classList.remove("spinning");
refreshButton.classList.add("error-bg");
isLoading = false;
return [];
}
} }
async function updateNetworks() { async function updateNetworks() {
const networks = await loadNetworks(); const networks = await loadNetworks();
if (networks) { if (networks) {
insertNetworks(["", ...networks]); insertNetworks(["", ...networks]);
} }
} }

View file

@ -1,14 +1,14 @@
document.querySelector("form.config").addEventListener("input", (event) => { document.querySelector("form.config").addEventListener("input", event => {
if (event.target.classList.contains("range")) { if (event.target.classList.contains("range")) {
updateValue(event.target); updateValue(event.target);
} }
}); });
function updateValue(slider) { function updateValue(slider) {
const percentage = Math.round((slider.value / slider.max) * 100); const percentage = Math.round((slider.value / slider.max) * 100);
slider.nextElementSibling.innerText = `${percentage}%`; slider.nextElementSibling.innerText = `${percentage}%`;
} }
document.querySelectorAll("input[type='range'].range").forEach((element) => { document.querySelectorAll("input[type='range'].range").forEach(element => {
updateValue(element); updateValue(element);
}); });

View file

@ -2,15 +2,15 @@ import { updateConfig } from "/submit.js";
const form = document.querySelector("form.config"); const form = document.querySelector("form.config");
form.addEventListener("reset", async (event) => { form.addEventListener("reset", async event => {
event.preventDefault(); event.preventDefault();
const ok = confirm( const ok = confirm(
"Sicher, dass du alle Einstellungen zurücksetzen möchtest?" "Sicher, dass du alle Einstellungen zurücksetzen möchtest?"
); );
if (ok) { if (ok) {
updateConfig({ updateConfig({
method: "DELETE", method: "DELETE",
}); });
} }
}); });

View file

@ -5,108 +5,105 @@ const statusDialog = document.querySelector(".dialog-status");
const expandButton = document.querySelector(".expand-status"); const expandButton = document.querySelector(".expand-status");
expandButton.addEventListener("click", () => { expandButton.addEventListener("click", () => {
statusDialog.showModal(); statusDialog.showModal();
}); });
registerCallback("status", setStatus); registerCallback("status", setStatus);
initWebSocket(); initWebSocket();
function setStatus(status) { function setStatus(status) {
setValue("model", status.chip.model); setValue("model", status.chip.model);
setValue("mac", formatMac(status.chip.mac)); setValue("mac", formatMac(status.chip.mac));
setValue("sdk-version", status.sdkVersion); setValue("sdk-version", status.sdkVersion);
setValue("rssi", status.connection.signalStrength); setValue("rssi", status.connection.signalStrength);
const icon = selectConnectionIcon(status.connection.signalStrength); const icon = selectConnectionIcon(status.connection.signalStrength);
document.querySelectorAll(".connection-icon").forEach((img) => { document.querySelectorAll(".connection-icon").forEach(img => {
img.src = `/icons/${icon}`; img.src = `/icons/${icon}`;
}); });
setValue("cpu-freq", status.chip.cpuFreqMHz); setValue("cpu-freq", status.chip.cpuFreqMHz);
setValue("cpu-cycle-count", status.chip.cycleCount); setValue("cpu-cycle-count", status.chip.cycleCount);
setValue("cpu-temp", status.chip.tempC); setValue("cpu-temp", status.chip.tempC);
const usedHeap = status.heap.total - status.heap.free; const usedHeap = status.heap.total - status.heap.free;
setValue("heap-used", formatBytes(usedHeap)); setValue("heap-used", formatBytes(usedHeap));
setValue("heap-total", formatBytes(status.heap.total)); setValue("heap-total", formatBytes(status.heap.total));
setValue( setValue("heap-percentage", Math.round((usedHeap / status.heap.total) * 100));
"heap-percentage",
Math.round((usedHeap / status.heap.total) * 100)
);
const usedPsram = status.psram.total - status.psram.free; const usedPsram = status.psram.total - status.psram.free;
setValue("psram-used", formatBytes(usedPsram)); setValue("psram-used", formatBytes(usedPsram));
setValue("psram-total", formatBytes(status.psram.total)); setValue("psram-total", formatBytes(status.psram.total));
setValue( setValue(
"psram-percentage", "psram-percentage",
Math.round((usedPsram / status.psram.total) * 100) Math.round((usedPsram / status.psram.total) * 100)
); );
setValue("uptime", parseDuration(status.uptime)); setValue("uptime", parseDuration(status.uptime));
setValue("hash", parseHash(status.sketch.md5)); setValue("hash", parseHash(status.sketch.md5));
} }
function setValue(className, value) { function setValue(className, value) {
document.querySelectorAll("." + className).forEach((element) => { document.querySelectorAll("." + className).forEach(element => {
element.innerText = value; element.innerText = value;
}); });
} }
function parseDuration(ms) { function parseDuration(ms) {
const date = new Date(ms); const date = new Date(ms);
const time = const time =
date.getUTCHours().toString().padStart(2, "0") + date.getUTCHours().toString().padStart(2, "0") +
":" + ":" +
date.getUTCMinutes().toString().padStart(2, "0") + date.getUTCMinutes().toString().padStart(2, "0") +
" h"; " h";
const days = Math.floor(date.getTime() / (1000 * 60 * 60 * 24)); const days = Math.floor(date.getTime() / (1000 * 60 * 60 * 24));
return days !== 0 ? `${days} Tage, ${time}` : time; return days !== 0 ? `${days} Tage, ${time}` : time;
} }
function parseHash(hash) { function parseHash(hash) {
return hash.toUpperCase().substring(0, 16); return hash.toUpperCase().substring(0, 16);
} }
function formatBytes(bytes) { function formatBytes(bytes) {
const units = ["Bytes", "KB", "MB", "GB"]; const units = ["Bytes", "KB", "MB", "GB"];
let value = bytes; let value = bytes;
let index = 0; let index = 0;
while (value >= 1000) { while (value >= 1000) {
value /= 1000; value /= 1000;
index++; index++;
} }
return `${Math.round(value * 10) / 10} ${units[index]}`; return `${Math.round(value * 10) / 10} ${units[index]}`;
} }
function formatMac(decimalMac) { function formatMac(decimalMac) {
const octets = decimalMac.toString(16).toUpperCase().match(/../g) || []; const octets = decimalMac.toString(16).toUpperCase().match(/../g) || [];
return octets.reverse().join(":"); return octets.reverse().join(":");
} }
function selectConnectionIcon(signalStrength) { function selectConnectionIcon(signalStrength) {
// access point // access point
if (data.connection == 1) { if (data.connection == 1) {
return "hotspot.svg"; return "hotspot.svg";
} }
// ethernet // ethernet
if (data.connection == 2) { if (data.connection == 2) {
return "lan.svg"; return "lan.svg";
} }
// station // station
if (signalStrength >= -50) { if (signalStrength >= -50) {
return "signal4.svg"; return "signal4.svg";
} }
if (signalStrength >= -60) { if (signalStrength >= -60) {
return "signal3.svg"; return "signal3.svg";
} }
if (signalStrength >= -70) { if (signalStrength >= -70) {
return "signal2.svg"; return "signal2.svg";
} }
return "signal1.svg"; return "signal1.svg";
} }

View file

@ -1,330 +1,330 @@
:root { :root {
--color-primary: #087e8b; --color-primary: #087e8b;
--color-on-primary: white; --color-on-primary: white;
--color-background: #222; --color-background: #222;
--color-surface: #333; --color-surface: #333;
--color-danger: #fa2b58; --color-danger: #fa2b58;
--appended-item-size: 2.5rem; --appended-item-size: 2.5rem;
color-scheme: dark; color-scheme: dark;
} }
body { body {
margin: 0; margin: 0;
padding: 0; padding: 0;
background: linear-gradient(to left, #065760, black, black, #065760); background: linear-gradient(to left, #065760, black, black, #065760);
color: white; color: white;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
overflow: hidden; overflow: hidden;
} }
main { main {
background-color: var(--color-background); 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;
height: 100vh; height: 100vh;
overflow: auto; overflow: auto;
} }
h1 { h1 {
text-align: center; text-align: center;
} }
form > * { form > * {
margin-bottom: 16px; margin-bottom: 16px;
} }
fieldset { fieldset {
border-radius: 8px; border-radius: 8px;
border-color: white; border-color: white;
} }
label { label {
display: block; display: block;
display: flex; display: flex;
gap: 8px; gap: 8px;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
label span { label span {
flex-grow: 1; flex-grow: 1;
} }
input, input,
select, select,
label div { label div {
width: clamp(200px, 100%, 400px); width: clamp(200px, 100%, 400px);
} }
input, input,
select { select {
background-color: var(--color-background); background-color: var(--color-background);
color: white; color: white;
border: 1px solid white; border: 1px solid white;
border-radius: 8px; border-radius: 8px;
padding: 8px; padding: 8px;
box-sizing: border-box; box-sizing: border-box;
} }
input:focus, input:focus,
select:focus { select:focus {
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
} }
select:has(+ .icon-button), select:has(+ .icon-button),
label div input[type="range"] { label div input[type="range"] {
width: calc(clamp(200px, 100%, 400px) - var(--appended-item-size) - 8px); width: calc(clamp(200px, 100%, 400px) - var(--appended-item-size) - 8px);
} }
input[type="range"] { input[type="range"] {
accent-color: var(--color-primary); accent-color: var(--color-primary);
} }
button { button {
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;
} }
button[type="reset"] { button[type="reset"] {
background-color: var(--color-danger); background-color: var(--color-danger);
} }
:is(div:has(:is(input, select)), input, select, label) :is(div:has(:is(input, select)), input, select, label)
+ :is(div:has(:is(input, select)), input, select, label) { + :is(div:has(:is(input, select)), input, select, label) {
margin-top: 8px; margin-top: 8px;
} }
.hidden { .hidden {
display: none !important; display: none !important;
} }
label.switch { label.switch {
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
} }
label.switch input { label.switch input {
display: none; display: none;
} }
label.switch .slider { label.switch .slider {
display: inline-block; display: inline-block;
position: relative; position: relative;
height: 1em; height: 1em;
width: 2em; width: 2em;
background-color: #444; background-color: #444;
border-radius: 1em; border-radius: 1em;
border: 4px solid #444; border: 4px solid #444;
} }
label.switch .slider::before { label.switch .slider::before {
content: ""; content: "";
position: absolute; position: absolute;
height: 100%; height: 100%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
border-radius: 50%; border-radius: 50%;
top: 50%; top: 50%;
background-color: white; background-color: white;
transition: all 0.1s linear; transition: all 0.1s linear;
} }
label.switch:active .slider::before { label.switch:active .slider::before {
transform: scale(1.3); transform: scale(1.3);
transform-origin: 50% 50%; transform-origin: 50% 50%;
} }
label.switch input:not(:checked) + .slider::before { label.switch input:not(:checked) + .slider::before {
left: 0%; left: 0%;
translate: 0 -50%; translate: 0 -50%;
} }
label.switch input:checked + .slider::before { label.switch input:checked + .slider::before {
left: 100%; left: 100%;
translate: -100% -50%; translate: -100% -50%;
} }
dialog { dialog {
width: 80%; width: 80%;
max-width: 500px; max-width: 500px;
max-height: 80%; max-height: 80%;
overflow: auto; overflow: auto;
background-color: var(--color-background); background-color: var(--color-background);
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 16px; padding: 16px;
} }
dialog::backdrop { dialog::backdrop {
background-color: #000a; background-color: #000a;
} }
.dialog-header { .dialog-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
& button { & button {
margin: 0; margin: 0;
} }
} }
.card { .card {
background-color: var(--color-surface); background-color: var(--color-surface);
padding: 8px; padding: 8px;
border-radius: 8px; border-radius: 8px;
} }
.card > * { .card > * {
display: block; display: block;
} }
.card > :first-child { .card > :first-child {
color: var(--color-primary); color: var(--color-primary);
margin-bottom: 8px; margin-bottom: 8px;
} }
.buttons { .buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
} }
.loading-screen { .loading-screen {
display: grid; display: grid;
justify-content: center; justify-content: center;
} }
.error { .error {
color: var(--color-danger); color: var(--color-danger);
} }
.error-bg { .error-bg {
background-color: var(--color-danger) !important; background-color: var(--color-danger) !important;
} }
button.reload { button.reload {
display: block; display: block;
margin: 0 auto; margin: 0 auto;
} }
.spinner-container { .spinner-container {
width: min(max-content, 100%); width: min(max-content, 100%);
} }
.spinner { .spinner {
position: relative; position: relative;
margin: 10px auto; margin: 10px auto;
background: conic-gradient(transparent 150deg, var(--color-primary)); background: conic-gradient(transparent 150deg, var(--color-primary));
--outer-diameter: 50px; --outer-diameter: 50px;
width: var(--outer-diameter); width: var(--outer-diameter);
height: var(--outer-diameter); height: var(--outer-diameter);
border-radius: 50%; border-radius: 50%;
animation-name: spin; animation-name: spin;
animation-duration: 1s; animation-duration: 1s;
animation-iteration-count: infinite; animation-iteration-count: infinite;
animation-timing-function: linear; animation-timing-function: linear;
} }
.spinner::after { .spinner::after {
position: absolute; position: absolute;
content: ""; content: "";
display: block; display: block;
--spinner-border: 5px; --spinner-border: 5px;
top: var(--spinner-border); top: var(--spinner-border);
left: var(--spinner-border); left: var(--spinner-border);
--inner-diameter: calc(var(--outer-diameter) - 2 * var(--spinner-border)); --inner-diameter: calc(var(--outer-diameter) - 2 * var(--spinner-border));
width: var(--inner-diameter); width: var(--inner-diameter);
height: var(--inner-diameter); height: var(--inner-diameter);
background-color: var(--color-background); background-color: var(--color-background);
border-radius: 50%; border-radius: 50%;
} }
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.icon-button { .icon-button {
padding: 8px; padding: 8px;
font-size: 0; font-size: 0;
width: var(--appended-item-size); width: var(--appended-item-size);
height: var(--appended-item-size); height: var(--appended-item-size);
} }
.spinning { .spinning {
animation-name: spin; animation-name: spin;
animation-duration: 0.5s; animation-duration: 0.5s;
animation-iteration-count: infinite; animation-iteration-count: infinite;
animation-timing-function: linear; animation-timing-function: linear;
} }
div:has(.range-value) { div:has(.range-value) {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
} }
.range-value { .range-value {
width: var(--appended-item-size); width: var(--appended-item-size);
height: var(--appended-item-size); height: var(--appended-item-size);
text-align: center; text-align: center;
line-height: var(--appended-item-size); line-height: var(--appended-item-size);
} }
.status { .status {
background-color: var(--color-surface); background-color: var(--color-surface);
padding: 8px; padding: 8px;
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.dialog-status-content { .dialog-status-content {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
} }
.connection-icon { .connection-icon {
width: 24px; width: 24px;
height: 24px; height: 24px;
padding: 8px; padding: 8px;
} }
.connection-icon.small { .connection-icon.small {
padding: 0; padding: 0;
height: 1em; height: 1em;
} }
.centered-vertical { .centered-vertical {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }

View file

@ -1,74 +1,74 @@
import { loadData, writeDataToInput } from "./load-data.js"; import { loadData, writeDataToInput } from "./load-data.js";
import { import {
showLoadingScreen, showLoadingScreen,
hideLoadingScreen, hideLoadingScreen,
showError, showError,
} from "./loading-screen.js"; } from "./loading-screen.js";
const form = document.querySelector("form.config"); const form = document.querySelector("form.config");
function parseValue(input) { function parseValue(input) {
if (input.type === "checkbox") { if (input.type === "checkbox") {
return input.checked return input.checked
? input.dataset.valueChecked ? input.dataset.valueChecked
: input.dataset.valueNotChecked; : input.dataset.valueNotChecked;
} }
if (input.value === "") { if (input.value === "") {
return null; return null;
} }
if (input.type === "number" || input.type === "range") { if (input.type === "number" || input.type === "range") {
const number = Number(input.value); const number = Number(input.value);
return Number.isNaN(number) ? null : number; return Number.isNaN(number) ? null : number;
} }
return input.value; return input.value;
} }
form.addEventListener("submit", (event) => { form.addEventListener("submit", event => {
event.preventDefault(); event.preventDefault();
const inputFields = document.querySelectorAll( const inputFields = document.querySelectorAll(
"form :is(input, select, textarea):not(:disabled)" "form :is(input, select, textarea):not(:disabled)"
); );
const data = Array.from(inputFields).reduce((data, input) => { const data = Array.from(inputFields).reduce((data, input) => {
data[input.name] = parseValue(input); data[input.name] = parseValue(input);
return data; return data;
}, {}); }, {});
console.log(data); console.log(data);
updateConfig({ updateConfig({
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) { export async function updateConfig(fetchOptions) {
showLoadingScreen("Konfiguration anwenden und ESP neustarten..."); showLoadingScreen("Konfiguration anwenden und ESP neustarten...");
try {
const res = await fetch("/config", fetchOptions);
if (!res.ok) {
throw new Error(`Response status: ${res.status}`);
}
} catch (error) {
console.error(error.message);
showError(error.message);
}
for (let i = 0; i < 10; i++) {
try { try {
const res = await fetch("/config", fetchOptions); const data = await loadData(5000);
if (!res.ok) { writeDataToInput(data);
throw new Error(`Response status: ${res.status}`); hideLoadingScreen();
}
break;
} catch (error) { } catch (error) {
console.error(error.message); // retry loading config until successful
showError(error.message);
}
for (let i = 0; i < 10; i++) {
try {
const data = await loadData(5000);
writeDataToInput(data);
hideLoadingScreen();
break;
} catch (error) {
// retry loading config until successful
}
} }
}
} }

View file

@ -4,34 +4,34 @@ let ws;
let callbacks = {}; let callbacks = {};
export function initWebSocket() { export function initWebSocket() {
if (ws) return; if (ws) return;
ws = new WebSocket(gateway); ws = new WebSocket(gateway);
ws.onopen = () => { ws.onopen = () => {
console.info("WebSocket connection opened"); console.info("WebSocket connection opened");
}; };
ws.onclose = (event) => { ws.onclose = event => {
console.info("WebSocket connection closed, reason:", event.reason); console.info("WebSocket connection closed, reason:", event.reason);
ws = null; ws = null;
}; };
ws.onerror = (event) => { ws.onerror = event => {
console.warn("WebSocket encountered error, closing socket.", event); console.warn("WebSocket encountered error, closing socket.", event);
ws.close(); ws.close();
ws = null; ws = null;
}; };
ws.onmessage = (event) => { ws.onmessage = event => {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
console.log("received websocket data", message); console.log("received websocket data", message);
if (message.type in callbacks) { if (message.type in callbacks) {
callbacks[message.type](message.data); callbacks[message.type](message.data);
} }
}; };
} }
export function registerCallback(type, callback) { export function registerCallback(type, callback) {
callbacks[type] = callback; callbacks[type] = callback;
} }

30
dependencies.lock Normal file
View file

@ -0,0 +1,30 @@
dependencies:
idf:
source:
type: idf
version: 5.5.2
joltwallet/littlefs:
component_hash: 150ca47225f7c9917f7f610a5f85e5e93fe3c15402234c23496ff045ad558b0b
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.20.2
someweisguy/esp_dmx:
component_hash: 9a7cdcf093ef6f44337f2a254bbadbe4c8089c12aec4991cf43a83831a8389f4
dependencies: []
source:
git: https://github.com/davispolito/esp_dmx.git
path: .
type: git
version: 93cd565bb07d6bf9a56b5c62c96f2552a8fc6194
direct_dependencies:
- idf
- joltwallet/littlefs
- someweisguy/esp_dmx
manifest_hash: 452ccdb963e60a5d4bb28f619a5058b387491bb886d6685d4d8ba97c5884abe2
target: esp32s2
version: 2.0.0

1
docs/external/doxygen-awesome-css vendored Submodule

@ -0,0 +1 @@
Subproject commit 1f3620084ff75734ed192101acf40e9dff01d848

96
flake.lock generated Normal file
View file

@ -0,0 +1,96 @@
{
"nodes": {
"esp-dev": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1767865407,
"narHash": "sha256-QWF1rZYd+HvNzLIeRS+OEBX7HF0EhWCGeLbMkgtbsIo=",
"owner": "mirrexagon",
"repo": "nixpkgs-esp-dev",
"rev": "5287d6e1ca9e15ebd5113c41b9590c468e1e001b",
"type": "github"
},
"original": {
"owner": "mirrexagon",
"repo": "nixpkgs-esp-dev",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1767799921,
"narHash": "sha256-r4GVX+FToWVE2My8VVZH4V0pTIpnu2ZE8/Z4uxGEMBE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d351d0653aeb7877273920cd3e823994e7579b0b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1772624091,
"narHash": "sha256-QKyJ0QGWBn6r0invrMAK8dmJoBYWoOWy7lN+UHzW1jc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "80bdc1e5ce51f56b19791b52b2901187931f5353",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"esp-dev": "esp-dev",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

38
flake.nix Normal file
View file

@ -0,0 +1,38 @@
{
description = "dmx-interface development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
esp-dev.url = "github:mirrexagon/nixpkgs-esp-dev";
};
outputs =
{
self,
nixpkgs,
esp-dev,
}:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
esp-idf = esp-dev.packages.${system}.esp-idf-full;
in
{
devShells.${system}.default = pkgs.mkShell {
buildInputs = [
esp-idf
pkgs.python3
pkgs.python3Packages.invoke
pkgs.pre-commit
pkgs.clang-tools
pkgs.svgo
pkgs.prettier
pkgs.nixfmt
pkgs.doxygen
pkgs.graphviz
];
};
};
}

@ -1 +0,0 @@
Subproject commit 5d9c42b531404ccfbcb14106d6312b03a166868a

@ -1 +0,0 @@
Subproject commit 38de6ac248c7f270ca3b77ba38512ba39919aed8

8
main/CMakeLists.txt Normal file
View file

@ -0,0 +1,8 @@
idf_component_register(
SRCS
"dmx-interface.c"
INCLUDE_DIRS
"."
REQUIRES
web_server
logger)

41
main/dmx-interface.c Normal file
View file

@ -0,0 +1,41 @@
#define LOG_TAG "MAIN" ///< "MAIN" log tag for this file
#include <stdio.h>
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "logger.h"
#include "web_server.h"
#include "wifi.h"
/**
* @brief Main entry point for the DMX Interface application.
*
* Initializes WiFi Access Point and starts the web server.
* Keeps the application running indefinitely.
*/
void app_main(void) {
LOGI("DMX Interface starting...");
esp_err_t wifi_err = wifi_start_ap("DMX", "mbgmbgmbg", 1, 4);
if (wifi_err != ESP_OK) {
LOGE("Failed to start WiFi AP: %s", esp_err_to_name(wifi_err));
return;
}
// Start HTTP web server
httpd_handle_t server = webserver_start(NULL);
if (server == NULL) {
LOGE("Failed to start web server!");
return;
}
LOGI("Web server started successfully");
LOGI("Open http://192.168.4.1 in your browser");
// Keep the app running
while (1) {
vTaskDelay(pdMS_TO_TICKS(1000));
}
}

8
main/idf_component.yml Normal file
View file

@ -0,0 +1,8 @@
## IDF Component Manager Manifest File
dependencies:
idf:
version: ">=4.1.0"
joltwallet/littlefs: ==1.20.2
someweisguy/esp_dmx:
git: https://github.com/davispolito/esp_dmx.git
# version: v4.1.0

5
partitions.csv Executable file
View file

@ -0,0 +1,5 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 1M,
storage, data, littlefs, , 0xF0000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x6000
3 phy_init data phy 0xf000 0x1000
4 factory app factory 0x10000 1M
5 storage data littlefs 0xF0000

View file

@ -1,29 +0,0 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[platformio]
default_envs = lolin_s2_mini
[env]
framework = arduino
board_build.filesystem = littlefs
[env:lolin_s2_mini]
platform = espressif32
board = lolin_s2_mini
lib_deps =
bblanchon/ArduinoJson @ ^7.2.0
someweisguy/esp_dmx
extra_scripts = pre:pre_extra_script.py
[env:esp32_wroom_32]
platform = espressif32
board = nodemcu-32s
lib_deps = bblanchon/ArduinoJson @ ^7.2.0

View file

@ -1,8 +0,0 @@
Import("env")
if env.IsIntegrationDump():
# stop the current script execution
Return()
env.Execute("git submodule update --init --recursive")
print("✅ SUBMODULES UPDATED")

7
sdkconfig.defaults Normal file
View file

@ -0,0 +1,7 @@
# This file was generated using idf.py save-defconfig. It can be edited manually.
# Espressif IoT Development Framework (ESP-IDF) 5.5.2 Project Minimal Configuration
#
CONFIG_IDF_TARGET="esp32s2"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_ESP_CONSOLE_USB_CDC=y

View file

@ -1,515 +0,0 @@
#ifdef ESP32
#include <WiFi.h>
#include <AsyncTCP.h>
// #include <USB.h>
// #include "USBCDC.h"
#include "driver/temp_sensor.h"
#elif defined(ESP8266)
#include <ESP8266WiFi.h>
#include <ESPAsyncTCP.h>
#endif
#include <AsyncWebServer_ESP32_W5500.h>
// #include "w5500/esp32_w5500.h"
// #include <ESPAsyncWebServer.h>
#include <ArtnetWiFi.h>
#include <ArduinoJson.h>
// #include "ESPDMX.h"
#include <Arduino.h>
#include <esp_dmx.h>
#include <LittleFS.h>
#include "websocket.h"
#include "routes/config.h"
#include "routes/networks.h"
#include "routes/status.h"
dmx_port_t dmx1 = DMX_NUM_0; // for esp32s2
dmx_port_t dmx2 = DMX_NUM_1;
byte dmx1_data[DMX_PACKET_SIZE];
byte dmx2_data[DMX_PACKET_SIZE];
// Button
#define PIN_LED 7
#define PIN_BUTTON 5
uint8_t brightness_led = 20;
bool led_on = true;
double lastMills = 0;
// Ethernet stuff
#define ETH_SCK 36
#define ETH_SS 34
#define ETH_MOSI 35
#define ETH_MISO 37
#define ETH_INT 38
#define ETH_SPI_CLOCK_MHZ 25
byte mac[6];
AsyncWebServer server(80);
ArtnetWiFi artnet;
String broadcastIp;
uint8_t universe1;
uint8_t universe2;
Direction direction1;
Direction direction2;
enum class Status
{
Starting,
Resetting,
Normal,
Warning,
Critical
};
Status status;
struct BlinkingConfig
{
int interval_ms;
bool is_blinking;
int brightness;
};
BlinkingConfig getBlinkingConfig(Status status)
{
switch (status)
{
case Status::Starting:
return {500, true, brightness_led};
case Status::Resetting:
return {100, true, brightness_led};
case Status::Normal:
return {1000, false, brightness_led};
case Status::Warning:
return {500, true, 255};
case Status::Critical:
return {100, true, 255};
default:
return {1000, false, 0};
}
}
BlinkingConfig led_config = getBlinkingConfig(status);
void updateTimer(int interval_ms)
{
// TODO: update the tickspeed of the timer
}
void updateLed() // TODO: callback for timer
{
if (millis() - lastMills >= led_config.interval_ms)
{
lastMills = millis();
led_config = getBlinkingConfig(status);
if (led_config.is_blinking)
{
led_on = !led_on;
analogWrite(PIN_LED, led_on ? led_config.brightness : 0);
}
else
{
analogWrite(PIN_LED, led_config.brightness);
return;
}
}
}
void setStatus(Status newStatus)
{
status = newStatus;
led_config = getBlinkingConfig(status);
updateTimer(led_config.interval_ms);
updateLed();
}
void onButtonPress()
{
config.begin("dmx", true);
ButtonAction action = static_cast<ButtonAction>(config.getUInt("button-action", DEFAULT_BUTTON_ACTION));
config.end();
switch (action)
{
case ResetConfig:
config.begin("dmx", false);
config.clear();
config.end();
ESP.restart();
break;
case Restart:
config.begin("dmx", false);
config.putBool("restart-via-btn", true);
config.end();
ESP.restart();
break;
case None:
// do nothing
break;
}
}
void setup()
{
setStatus(Status::Starting);
pinMode(PIN_LED, OUTPUT);
updateLed();
Serial.begin(9600);
// Get ETH mac
delay(1000);
esp_efuse_mac_get_default(mac);
esp_read_mac(mac, ESP_MAC_ETH);
Serial.printf("%02x:%02x:%02x:%02x:%02x:%02x ESP MAC ETH\n",
mac[0], mac[1], mac[2],
mac[3], mac[4], mac[5]);
esp_read_mac(mac, ESP_MAC_WIFI_SOFTAP);
Serial.printf("%02x:%02x:%02x:%02x:%02x:%02x ESP MAC SOFTAP\n",
mac[0], mac[1], mac[2],
mac[3], mac[4], mac[5]);
esp_read_mac(mac, ESP_MAC_WIFI_STA); // ESP_MAC_BASE
Serial.printf("%02x:%02x:%02x:%02x:%02x:%02x ESP MAC BASE\n",
mac[0], mac[1], mac[2],
mac[3], mac[4], mac[5]);
// LED
config.begin("dmx", true);
brightness_led = config.getUInt("led-brightness", DEFAULT_LED_BRIGHTNESS);
bool restartViaButton = config.getBool("restart-via-btn", false);
config.end();
updateLed();
// Button
pinMode(PIN_BUTTON, INPUT_PULLUP);
if (digitalRead(PIN_BUTTON) == LOW && !restartViaButton)
{
setStatus(Status::Resetting);
unsigned long startTime = millis();
while (digitalRead(PIN_BUTTON) == LOW && (millis() - startTime <= 3000))
{
updateLed();
}
if (digitalRead(PIN_BUTTON) == LOW)
{
Serial.println("Reset config");
config.begin("dmx", false);
config.clear();
config.end();
setStatus(Status::Normal);
unsigned long startTime = millis();
while (millis() - startTime <= 2000)
{
updateLed();
}
}
}
config.begin("dmx", false);
config.putBool("restart-via-btn", false);
config.end();
setStatus(Status::Starting);
attachInterrupt(PIN_BUTTON, onButtonPress, FALLING);
// wait for serial monitor
unsigned long startTime = millis();
while (millis() - startTime <= 5000)
{
updateLed();
}
Serial.println("Starting DMX-Interface...");
config.begin("dmx", true);
universe1 = config.getUInt("universe-1", DEFAULT_UNIVERSE1);
universe2 = config.getUInt("universe-2", DEFAULT_UNIVERSE2);
direction1 = static_cast<Direction>(config.getUInt("direction-1", DEFAULT_DIRECTION1));
direction2 = static_cast<Direction>(config.getUInt("direction-2", DEFAULT_DIRECTION2));
Serial.printf("Port A: Universe %d %s\n", universe1, (direction1 == Input) ? "DMX -> Art-Net" : "Art-Net -> DMX");
Serial.printf("Port B: Universe %d %s\n", universe2, (direction2 == Input) ? "DMX -> Art-Net" : "Art-Net -> DMX");
Connection connection = static_cast<Connection>(config.getUInt("connection", DEFAULT_CONNECTION));
IpMethod ipMethod = static_cast<IpMethod>(config.getUInt("ip-method"), DEFAULT_IP_METHOD);
char hostname[30];
snprintf(hostname, sizeof(hostname), "ChaosDMX-%02X%02X", mac[4], mac[5]);
DEFAULT_SSID = hostname;
Serial.print("Hostname: ");
Serial.println(hostname);
String ssid = config.getString("ssid", DEFAULT_SSID);
String pwd = config.getString("password", DEFAULT_PASSWORD);
// Default IP as defined in standard https://art-net.org.uk/downloads/art-net.pdf, page 13
IPAddress ip = config.getUInt("ip", DEFAULT_IP);
IPAddress subnet = config.getUInt("subnet", DEFAULT_SUBNET);
IPAddress gateway = config.getUInt("gateway", 0);
config.end();
switch (connection)
{
case WiFiSta:
Serial.println("Initialize as WiFi Station");
WiFi.setHostname(hostname);
WiFi.begin(ssid, pwd);
Serial.println("SSID: " + ssid + ", pwd: " + pwd);
if (ipMethod == Static)
{
WiFi.config(ip, gateway, subnet);
Serial.println("IP: " + ip.toString() + ", gateway: " + gateway + ", subnet: " + subnet);
}
while (WiFi.status() != WL_CONNECTED)
{
Serial.print(".");
delay(500);
}
broadcastIp = String(WiFi.broadcastIP().toString().c_str());
Serial.println("");
Serial.print("WiFi connected, IP = ");
Serial.println(WiFi.localIP());
Serial.print("MAC address: ");
Serial.println(WiFi.macAddress());
Serial.print("Broadcast IP: ");
Serial.println(broadcastIp);
break;
case Ethernet:
{
Serial.println("Initialize as ETH");
ESP32_W5500_onEvent();
if (ETH.begin(ETH_MISO, ETH_MOSI, ETH_SCK, ETH_SS, ETH_INT, ETH_SPI_CLOCK_MHZ, SPI2_HOST, mac))
{ // Dynamic IP setup
}
else
{
Serial.println("Failed to configure Ethernet");
}
ETH.setHostname(hostname);
// ESP32_W5500_waitForConnect();
uint8_t timeout = 5; // in s
Serial.print("Wait for connect");
while (!ESP32_W5500_eth_connected && timeout > 0)
{
delay(1000);
timeout--;
Serial.print(".");
}
Serial.println();
if (ESP32_W5500_eth_connected)
{
Serial.println("DHCP OK!");
}
else
{
Serial.println("Set static IP");
ETH.config(ip, gateway, subnet);
}
broadcastIp = ETH.broadcastIP().toString();
Serial.print("Local IP : ");
Serial.println(ETH.localIP());
Serial.print("Subnet Mask : ");
Serial.println(ETH.subnetMask());
Serial.print("Gateway IP : ");
Serial.println(ETH.gatewayIP());
Serial.print("DNS Server : ");
Serial.println(ETH.dnsIP());
Serial.print("MAC address : ");
Serial.println(ETH.macAddress());
Serial.print("Broadcast IP: ");
Serial.println(broadcastIp);
Serial.println("Ethernet Successfully Initialized");
break;
}
default:
Serial.println("Initialize as WiFi AccessPoint");
WiFi.softAPsetHostname(hostname);
WiFi.softAP(ssid, pwd);
// AP always with DHCP
// WiFi.softAPConfig(ip, gateway, subnet);
broadcastIp = WiFi.softAPBroadcastIP().toString();
Serial.print("WiFi AP enabled, IP = ");
Serial.println(WiFi.softAPIP());
Serial.print("MAC address: ");
Serial.println(WiFi.softAPmacAddress());
Serial.print("Broadcast IP: ");
Serial.println(broadcastIp);
break;
}
// Initialize DMX ports
Serial.println("Initialize DMX...");
#ifdef CONFIG_IDF_TARGET_ESP32S2
dmx_config_t dmx_config = DMX_CONFIG_DEFAULT;
dmx_personality_t personalities[] = {};
int personality_count = 0;
dmx_driver_install(dmx1, &dmx_config, personalities, personality_count);
dmx_set_pin(dmx1, 21, 33, -1);
dmx_driver_install(dmx2, &dmx_config, personalities, personality_count);
dmx_set_pin(dmx2, 17, 18, -1);
Serial.printf("DMX driver 1 installed: %d\n", dmx_driver_is_installed(dmx1));
Serial.printf("DMX driver 2 installed: %d\n", dmx_driver_is_installed(dmx2));
Serial.printf("DMX driver 1 enabled: %d\n", dmx_driver_is_enabled(dmx1));
Serial.printf("DMX driver 2 enabled: %d\n", dmx_driver_is_enabled(dmx2));
#else
dmx1.init(21, 33, Serial1);
dmx2.init(17, 18, Serial2);
#endif
// Initialize Art-Net
Serial.println("Initialize Art-Net...");
artnet.begin();
// if Artnet packet comes to this universe, this function is called
if (direction1 == Output)
{
artnet.subscribeArtDmxUniverse(universe1, [&](const uint8_t *data, uint16_t size, const ArtDmxMetadata &metadata, const ArtNetRemoteInfo &remote)
{
dmx_write_offset(dmx1, 1, data, size);
dmx_send(dmx1);
dmx_wait_sent(dmx1, DMX_TIMEOUT_TICK); });
}
if (direction2 == Output)
{
artnet.subscribeArtDmxUniverse(universe2, [&](const uint8_t *data, uint16_t size, const ArtDmxMetadata &metadata, const ArtNetRemoteInfo &remote)
{
dmx_write_offset(dmx2, 1, data, size);
dmx_send(dmx2);
dmx_wait_sent(dmx2, DMX_TIMEOUT_TICK); });
}
if (!LittleFS.begin(true))
{
Serial.println("An Error has occurred while mounting LittleFS");
return;
}
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
server.on("/config", HTTP_GET, [](AsyncWebServerRequest *request)
{ onGetConfig(request); });
server.on("/config", HTTP_DELETE, [](AsyncWebServerRequest *request)
{
config.begin("dmx", false);
config.clear();
config.end();
// respond with default config
onGetConfig(request);
ESP.restart(); });
server.on("/networks", HTTP_GET, [](AsyncWebServerRequest *request)
{ onGetNetworks(request); });
server.onRequestBody([](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total)
{
if (request->url() == "/config" && request->method() == HTTP_PUT) {
onPutConfig(request, data, len, index, total);
Serial.println("restarting ESP...");
ESP.restart();
} });
initWebSocket(&server);
server.begin();
Serial.println("Server started!");
// scan networks and cache them
WiFi.scanNetworks(true);
setStatus(Status::Normal);
// Internal temperature RP2040
/*float tempC = analogReadTemp(); // Get internal temperature
Serial.print("Temperature Celsius (ºC): ");
Serial.println(tempC);*/
// Internal temperature ESP32 https://www.espboards.dev/blog/esp32-inbuilt-temperature-sensor/
Serial.print("Temperature: ");
float result = 0;
temp_sensor_read_celsius(&result);
Serial.print(result);
Serial.println(" °C");
Serial.printf("Internal Total heap %d, internal Free Heap %d\n", ESP.getHeapSize(), ESP.getFreeHeap());
Serial.printf("SPIRam Total heap %d, SPIRam Free Heap %d\n", ESP.getPsramSize(), ESP.getFreePsram());
Serial.printf("ChipRevision %d, Cpu Freq %d, SDK Version %s\n", ESP.getChipRevision(), ESP.getCpuFreqMHz(), ESP.getSdkVersion());
Serial.printf("Flash Size %d, Flash Speed %d\n", ESP.getFlashChipSize(), ESP.getFlashChipSpeed());
}
void transmitDmxToArtnet(dmx_port_t dmxPort, byte *dmx_data, uint8_t artnetUniverse)
{
/* We need a place to store information about the DMX packets we receive. We
will use a dmx_packet_t to store that packet information. */
dmx_packet_t dmx_packet;
// check if there's a new DMX packet
if (dmx_receive(dmxPort, &dmx_packet, 0))
{
/* We should check to make sure that there weren't any DMX errors. */
if (!dmx_packet.err)
{
/* Don't forget we need to actually read the DMX data into our buffer so
that we can print it out. */
dmx_read_offset(dmxPort, 1, dmx_data, dmx_packet.size);
artnet.sendArtDmx(broadcastIp, artnetUniverse, dmx_data, dmx_packet.size);
}
else
{
/* Oops! A DMX error occurred! Don't worry, this can happen when you first
connect or disconnect your DMX devices. If you are consistently getting
DMX errors, then something may have gone wrong with your code or
something is seriously wrong with your DMX transmitter. */
Serial.printf("A DMX error occurred on port %d.\n", dmxPort);
}
}
}
void loop()
{
// only check for artnet packets if we expect to receive data
if (direction1 == Output || direction2 == Output)
{
// check if artnet packet has come and execute callback
artnet.parse();
}
if (direction1 == Input)
{
transmitDmxToArtnet(dmx1, dmx1_data, universe1);
}
if (direction2 == Input)
{
transmitDmxToArtnet(dmx2, dmx2_data, universe2);
}
webSocketLoop();
updateLed();
}

View file

@ -1,177 +0,0 @@
#include "config.h"
#include <stdexcept>
#include <ArduinoJson.h>
#include "WiFi.h"
Preferences config;
String DEFAULT_SSID = "";
#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);
}
ButtonAction parseButtonAction(uint8_t buttonAction)
{
if (buttonAction > 0 || buttonAction < BUTTON_ACTION_SIZE)
{
return static_cast<ButtonAction>(buttonAction);
}
throw ::std::invalid_argument("Invalid value for button action: " + buttonAction);
}
#pragma endregion
void onGetConfig(AsyncWebServerRequest *request)
{
config.begin("dmx", true);
IPAddress ip = config.getUInt("ip", DEFAULT_IP);
IPAddress subnet = config.getUInt("subnet", DEFAULT_SUBNET);
IPAddress gateway = config.getUInt("gateway", 0);
JsonDocument doc;
doc["connection"] = config.getUInt("connection", DEFAULT_CONNECTION);
doc["ssid"] = config.getString("ssid", DEFAULT_SSID);
doc["password"] = config.getString("password", DEFAULT_PASSWORD);
doc["ip-method"] = config.getUInt("ip-method", DEFAULT_IP_METHOD);
doc["ip"] = ip.toString();
doc["subnet"] = subnet.toString();
doc["gateway"] = gateway != 0 ? gateway.toString() : "";
doc["universe-1"] = config.getUInt("universe-1", DEFAULT_UNIVERSE1);
doc["direction-1"] = config.getUInt("direction-1", DEFAULT_DIRECTION1);
doc["universe-2"] = config.getUInt("universe-2", DEFAULT_UNIVERSE2);
doc["direction-2"] = config.getUInt("direction-2", DEFAULT_DIRECTION2);
doc["led-brightness"] = config.getUInt("led-brightness", DEFAULT_LED_BRIGHTNESS);
doc["button-action"] = config.getUInt("button-action", DEFAULT_BUTTON_ACTION);
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.putUInt("led-brightness", doc["led-brightness"]);
ButtonAction buttonAction = parseButtonAction(doc["button-action"].as<uint8_t>());
config.putUInt("button-action", buttonAction);
config.end();
request->send(200);
}
catch (::std::invalid_argument &e)
{
config.end();
request->send(400, "text/plain", e.what());
}
}

View file

@ -1,59 +0,0 @@
#include <AsyncWebServer_ESP32_W5500.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;
enum ButtonAction
{
None,
ResetConfig,
Restart
};
const uint8_t BUTTON_ACTION_SIZE = 3;
const Connection DEFAULT_CONNECTION = WiFiAP;
const IpMethod DEFAULT_IP_METHOD = DHCP;
extern String DEFAULT_SSID; // initialized in setup because it depends on the mac address
const String DEFAULT_PASSWORD = "mbgmbgmbg";
const IPAddress DEFAULT_IP(192, 168, 4, 1);
const IPAddress DEFAULT_SUBNET(255, 255, 255, 0);
const IPAddress DEFAULT_GATEWAY(2, 0, 0, 1);
const Direction DEFAULT_DIRECTION1 = Output;
const Direction DEFAULT_DIRECTION2 = Input;
const uint8_t DEFAULT_UNIVERSE1 = 1;
const uint8_t DEFAULT_UNIVERSE2 = 2;
const uint8_t DEFAULT_LED_BRIGHTNESS = 25;
const ButtonAction DEFAULT_BUTTON_ACTION = Restart;
void onGetConfig(AsyncWebServerRequest *request);
void onPutConfig(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total);
#endif

View file

@ -1,29 +0,0 @@
#include "networks.h"
void onGetNetworks(AsyncWebServerRequest *request)
{
JsonDocument doc;
JsonArray array = doc.to<JsonArray>();
int numberOfNetworks = WiFi.scanComplete();
if (numberOfNetworks == WIFI_SCAN_FAILED)
{
WiFi.scanNetworks(true);
}
else if (numberOfNetworks)
{
for (int i = 0; i < numberOfNetworks; ++i)
{
array.add(WiFi.SSID(i));
}
WiFi.scanDelete();
if (WiFi.scanComplete() == WIFI_SCAN_FAILED)
{
WiFi.scanNetworks(true);
}
}
String jsonString;
serializeJson(doc, jsonString);
request->send(200, "application/json", jsonString);
}

View file

@ -1,9 +0,0 @@
#include <AsyncWebServer_ESP32_W5500.h>
#include <ArduinoJson.h>
#ifndef NETWORKS_H
#define NETWORKS_H
void onGetNetworks(AsyncWebServerRequest *request);
#endif

View file

@ -1,43 +0,0 @@
#include "status.h"
int getTemperature()
{
float tempC = -1.0f;
temp_sensor_read_celsius(&tempC);
return static_cast<int>(round(tempC));
}
int8_t getWiFiStrength()
{
try
{
return WiFi.RSSI();
}
catch (...)
{
return 0;
}
}
JsonDocument buildStatusJson()
{
JsonDocument doc;
doc["uptime"] = millis();
doc["chip"]["model"] = ESP.getChipModel();
doc["chip"]["mac"] = ESP.getEfuseMac();
doc["chip"]["revision"] = ESP.getChipRevision();
doc["chip"]["cpuFreqMHz"] = ESP.getCpuFreqMHz();
doc["chip"]["cycleCount"] = ESP.getCycleCount();
doc["chip"]["tempC"] = getTemperature();
doc["sdkVersion"] = ESP.getSdkVersion();
doc["sketch"]["size"] = ESP.getSketchSize();
doc["sketch"]["md5"] = ESP.getSketchMD5();
doc["heap"]["free"] = ESP.getFreeHeap();
doc["heap"]["total"] = ESP.getHeapSize();
doc["psram"]["free"] = ESP.getFreePsram();
doc["psram"]["total"] = ESP.getPsramSize();
doc["connection"]["signalStrength"] = getWiFiStrength();
return doc;
}

View file

@ -1,5 +0,0 @@
#include <AsyncWebServer_ESP32_W5500.h>
#include <ArduinoJson.h>
#include <driver/temp_sensor.h>
JsonDocument buildStatusJson();

View file

@ -1,52 +0,0 @@
#include "websocket.h"
AsyncWebSocket ws("/ws");
long webSocketLastUpdate = 0;
const int WS_UPDATE_INTERVAL = 10 * 1000; // 10 seconds
String buildStatusString()
{
JsonDocument doc;
doc["type"] = "status";
doc["data"] = buildStatusJson();
String jsonString = "";
serializeJson(doc, jsonString);
return jsonString;
}
void onEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len)
{
switch (type)
{
case WS_EVT_CONNECT:
Serial.printf("[WS] Client %u connected from %s\n", client->id(), client->remoteIP().toString().c_str());
// directly send status to client
ws.text(client->id(), buildStatusString());
break;
case WS_EVT_DISCONNECT:
Serial.printf("[WS] Client %u disconnected\n", client->id());
break;
case WS_EVT_DATA:
Serial.printf("[WS] Data received from client %u: %s\n", client->id(), (char *)data);
break;
default:
break;
}
}
void webSocketLoop()
{
if (millis() - webSocketLastUpdate > WS_UPDATE_INTERVAL)
{
ws.textAll(buildStatusString());
webSocketLastUpdate = millis();
}
}
void initWebSocket(AsyncWebServer *server)
{
ws.onEvent(onEvent);
server->addHandler(&ws);
}

View file

@ -1,11 +0,0 @@
#include <AsyncWebServer_ESP32_W5500.h>
#include "routes/status.h"
#ifndef WEBSOCKET_H
#define WEBSOCKET_H
void initWebSocket(AsyncWebServer *server);
void webSocketLoop();
#endif

116
tasks.py Normal file
View file

@ -0,0 +1,116 @@
from invoke import task
from invoke.exceptions import Exit
import os
import shutil
import subprocess
import webbrowser
@task
def cleanbuild(c):
"""Clean build: fullclean and build the project"""
c.run("idf.py fullclean")
c.run("idf.py build")
@task
def build(c):
"""Build the project"""
c.run("idf.py build")
@task
def flash(c):
"""Flash the project to device"""
c.run("idf.py flash")
@task
def monitor(c, port="/dev/ttyUSB0"):
"""Monitor serial output from device"""
c.run(f"idf.py monitor -p {port}")
@task
def run(c, port="/dev/ttyUSB0"):
"""Build, flash, and monitor in sequence"""
build(c)
flash(c)
monitor(c, port)
@task
def clean(c):
"""Clean build artifacts"""
c.run("idf.py fullclean")
@task
def config(c):
"""Open menuconfig to edit project settings"""
is_windows = os.name == "nt"
if is_windows:
# Windows doesn't provide a POSIX pty, not sure how to open menuconfig interactive
print(
"Please run 'idf.py menuconfig' directly in the terminal to edit project settings. This option is not supported in the invoke task on Windows."
)
else:
c.run("idf.py menuconfig --color-scheme=monochrome", pty=True)
@task
def saveconfig(c):
"""Save current config as sdkconfig.defaults"""
c.run("idf.py save-defconfig")
@task
def update(c):
"""Update project dependencies"""
c.run("idf.py update-dependencies")
@task
def reset(c):
"""Reset project to clean state: remove build, config, and managed components"""
if os.path.exists("sdkconfig"):
os.remove("sdkconfig")
if os.path.exists("sdkconfig.old"):
os.remove("sdkconfig.old")
if os.path.exists("build"):
shutil.rmtree("build")
if os.path.exists("managed_components"):
shutil.rmtree("managed_components")
@task
def format(c):
"""Format all source files using pre-commit hooks"""
is_windows = os.name == "nt"
if is_windows:
# Windows doesn't provide a POSIX pty
c.run("pre-commit run --all-files")
else:
c.run("pre-commit run --all-files", pty=True)
@task(help={"o": "Open documentation in the default browser after generation."})
def docs(c, o=False):
"""Generate Doxygen documentation."""
proc = subprocess.run("doxygen Doxyfile", shell=True)
if proc.returncode == 0:
path = "docs/doxygen/html/index.html"
print(f"\n✓ Documentation generated in {path}")
if o:
webbrowser.open(f"file://{os.path.abspath(path)}")
return
raise Exit(code=1)
@task
def docs_coverage(c):
"""List doxygen coverage of documentation."""
subprocess.run(
"python tools/doxy-coverage.py docs/doxygen/xml --no-error", shell=True
)

244
tools/doxy-coverage.py Executable file
View file

@ -0,0 +1,244 @@
#!/usr/bin/env python
# -*- mode: python; coding: utf-8 -*-
# Based on https://github.com/davatorium/doxy-coverage
# which was forked from https://github.com/alobbs/doxy-coverage
# and modified for use in this project.
# All files in doxy-coverage are Copyright 2014 Alvaro Lopez Ortega.
#
# Authors:
# * Alvaro Lopez Ortega <alvaro@gnu.org>
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
__author__ = "Alvaro Lopez Ortega"
__email__ = "alvaro@alobbs.com"
__copyright__ = "Copyright (C) 2014 Alvaro Lopez Ortega"
from filecmp import cmp
import os
import subprocess
import sys
import argparse
import xml.etree.ElementTree as ET
# Defaults
ACCEPTABLE_COVERAGE = 80
# Global
ns = None
def ERROR(*objs):
print("ERROR: ", *objs, end="\n", file=sys.stderr)
def FATAL(*objs):
ERROR(*objs)
sys.exit(0 if ns.no_error else 1)
def generate_docs():
print("Generating Doxygen documentation...")
proc = subprocess.run(
"doxygen Doxyfile",
shell=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if proc.returncode == 0:
print("Documentation generated")
else:
FATAL("Failed to generate documentation. Exiting.")
def parse_file(fullpath):
tree = ET.parse(fullpath)
sourcefile = None
definitions = {}
for definition in tree.findall("./compounddef//memberdef"):
# Should it be documented
if definition.get("kind") == "function" and definition.get("static") == "yes":
continue
# Is the definition documented?
documented = False
for k in ("briefdescription", "detaileddescription", "inbodydescription"):
if definition.findall(f"./{k}/"):
documented = True
break
# Name
d_def = definition.find("./definition")
d_nam = definition.find("./name")
if not sourcefile:
l = definition.find("./location")
if l is not None:
sourcefile = l.get("file")
if d_def is not None:
name = d_def.text
elif d_nam is not None:
name = d_nam.text
else:
name = definition.get("id")
# Aggregate
definitions[name] = documented
if not sourcefile:
sourcefile = fullpath
return (sourcefile, definitions)
def parse(path):
index_fp = os.path.join(path, "index.xml")
if not os.path.exists(index_fp):
FATAL("Documentation not present. Exiting.", index_fp)
tree = ET.parse(index_fp)
files = {}
for entry in tree.findall("compound"):
if entry.get("kind") == "dir":
continue
file_fp = os.path.join(path, f"{entry.get('refid')}.xml")
sourcefile, definitions = parse_file(file_fp)
if definitions != {}:
files[sourcefile] = definitions
return files
def report(files, include_files, summary_only):
files_sorted = sorted(files.keys())
if len(include_files) != 0:
files_sorted = list(filter(lambda f: f in include_files, files_sorted))
if len(files_sorted) == 0:
FATAL("No files to report on. Exiting.")
files_sorted.reverse()
total_yes = 0
total_no = 0
for f in files_sorted:
defs = files[f]
doc_yes = len([d for d in defs.values() if d])
doc_no = len([d for d in defs.values() if not d])
doc_per = doc_yes * 100.0 / (doc_yes + doc_no)
total_yes += doc_yes
total_no += doc_no
if not summary_only:
print(f"{int(doc_per):3d}% - {f} - ({doc_yes} of {doc_yes + doc_no})")
if None in defs:
del defs[None]
if not summary_only:
defs_sorted = sorted(defs.keys())
for d in defs_sorted:
if not defs[d]:
print("\t", d)
total_all = total_yes + total_no
total_percentage = total_yes * 100 / total_all
print(
f"{"" if summary_only else "\n"}{int(total_percentage)}% API documentation coverage"
)
return (ns.threshold - total_percentage, 0)[total_percentage > ns.threshold]
def main():
# Arguments
parser = argparse.ArgumentParser()
parser.add_argument(
"dir", action="store", help="Path to Doxygen's XML doc directory"
)
parser.add_argument(
"include_files",
action="extend",
nargs="*",
help="List of files to check coverage for (Default: all files)",
type=str,
default=[],
)
parser.add_argument(
"--no-error",
action="store_true",
help="Do not return error code after execution",
)
parser.add_argument(
"--summary-only",
action="store_true",
help="Only print the summary of the coverage report, without listing the coverage of each file",
)
parser.add_argument(
"--threshold",
action="store",
help=f"Min acceptable coverage percentage (Default: {ACCEPTABLE_COVERAGE})",
default=ACCEPTABLE_COVERAGE,
type=int,
)
parser.add_argument(
"--generate-docs",
action="store_true",
help="Generate Doxygen documentation before checking coverage",
)
global ns
ns = parser.parse_args()
if not ns:
FATAL("ERROR: Couldn't parse parameters")
if ns.generate_docs:
generate_docs()
# Parse
files = parse(ns.dir)
# Print report
err = report(files, ns.include_files, ns.summary_only)
if ns.no_error:
return
sys.exit(round(err))
if __name__ == "__main__":
main()