diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..b4d7ff9 --- /dev/null +++ b/.clang-format @@ -0,0 +1,9 @@ +--- +BasedOnStyle: LLVM + +# Include sorting +SortIncludes: true + +# Spacing +MaxEmptyLinesToKeep: 2 +SeparateDefinitionBlocks: Always diff --git a/.codespellignore b/.codespellignore new file mode 100644 index 0000000..c714049 --- /dev/null +++ b/.codespellignore @@ -0,0 +1,9 @@ +# Words that codespell should ignore +# Add common false positives here +inout +uart +dout +din +Tage +alle +Aktion diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..adeabc7 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..9bc110d --- /dev/null +++ b/.envrc @@ -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 diff --git a/.github/actions/install-nix/action.yml b/.github/actions/install-nix/action.yml new file mode 100644 index 0000000..778cf99 --- /dev/null +++ b/.github/actions/install-nix/action.yml @@ -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 diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..5a86bd7 --- /dev/null +++ b/.github/workflows/check.yml @@ -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 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 516c50a..af425ae --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,63 @@ -# Development +# .gitignore für ESP-IDF / CMake-Projekt -.pio -.vscode -!.vscode\extensions.json +# --- Build artefacts ----------------------------------------------------- +/build/ +*.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 diff --git a/.gitmodules b/.gitmodules index 48580f7..3362621 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "lib/ArtNet"] - path = lib/ArtNet - url = https://github.com/psxde/ArtNet.git -[submodule "lib/AsyncWebServer_ESP32_W5500"] - path = lib/AsyncWebServer_ESP32_W5500 - url = https://github.com/psxde/AsyncWebServer_ESP32_W5500.git +[submodule "docs/external/doxygen-awesome-css"] + path = docs/external/doxygen-awesome-css + url = https://github.com/jothepro/doxygen-awesome-css diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..c64c8c2 --- /dev/null +++ b/.markdownlint.json @@ -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 +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..94013f0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..84199a1 --- /dev/null +++ b/.prettierignore @@ -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 diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..a68aa02 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "avoid", + "endOfLine": "lf", + "proseWrap": "preserve" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index 080e70d..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -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" - ] -} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..d632be3 --- /dev/null +++ b/CMakeLists.txt @@ -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() diff --git a/Doxyfile b/Doxyfile new file mode 100644 index 0000000..b875352 --- /dev/null +++ b/Doxyfile @@ -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 diff --git a/README.md b/README.md index 0a664d6..5489749 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ ## 📱 Implemented microcontrollers -- [x] Lolin S2 mini -- [ ] ESP 32 WROOM -- [ ] ESP 32 C3 +- [x] Lolin S2 mini +- [ ] ESP 32 WROOM +- [ ] ESP 32 C3 > 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 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. diff --git a/assets/circuit/diagram.svg b/assets/circuit/diagram.svg index 3ac7a7e..73df9af 100644 --- a/assets/circuit/diagram.svg +++ b/assets/circuit/diagram.svg @@ -1,113 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/assets/design/favicon.svg b/assets/design/favicon.svg index 530ea35..cfab809 100644 --- a/assets/design/favicon.svg +++ b/assets/design/favicon.svg @@ -1,5 +1 @@ - - - - - + diff --git a/assets/design/social.svg b/assets/design/social.svg index a773d34..9666bf1 100644 --- a/assets/design/social.svg +++ b/assets/design/social.svg @@ -1,25 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/components/dmx/CMakeLists.txt b/components/dmx/CMakeLists.txt new file mode 100644 index 0000000..7b3abdc --- /dev/null +++ b/components/dmx/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(SRCS "src/dmx.c" INCLUDE_DIRS "include") diff --git a/components/dmx/include/dmx.h b/components/dmx/include/dmx.h new file mode 100644 index 0000000..cd74574 --- /dev/null +++ b/components/dmx/include/dmx.h @@ -0,0 +1,9 @@ +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef __cplusplus +} +#endif diff --git a/components/dmx/src/dmx.c b/components/dmx/src/dmx.c new file mode 100644 index 0000000..e69de29 diff --git a/components/logger/CMakeLists.txt b/components/logger/CMakeLists.txt new file mode 100644 index 0000000..eafb7ac --- /dev/null +++ b/components/logger/CMakeLists.txt @@ -0,0 +1 @@ +idf_component_register(INCLUDE_DIRS include) diff --git a/components/logger/include/logger.h b/components/logger/include/logger.h new file mode 100644 index 0000000..ecb88ea --- /dev/null +++ b/components/logger/include/logger.h @@ -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 diff --git a/components/storage/CMakeLists.txt b/components/storage/CMakeLists.txt new file mode 100644 index 0000000..03f4b47 --- /dev/null +++ b/components/storage/CMakeLists.txt @@ -0,0 +1,10 @@ +idf_component_register( + SRCS + "src/storage.c" + INCLUDE_DIRS + "include" + REQUIRES + joltwallet__littlefs + PRIV_REQUIRES + vfs + logger) diff --git a/components/storage/include/storage.h b/components/storage/include/storage.h new file mode 100644 index 0000000..84b826e --- /dev/null +++ b/components/storage/include/storage.h @@ -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 diff --git a/components/storage/src/storage.c b/components/storage/src/storage.c new file mode 100644 index 0000000..c7fae3c --- /dev/null +++ b/components/storage/src/storage.c @@ -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; } diff --git a/components/web_server/CMakeLists.txt b/components/web_server/CMakeLists.txt new file mode 100644 index 0000000..e390e13 --- /dev/null +++ b/components/web_server/CMakeLists.txt @@ -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) diff --git a/components/web_server/include/web_server.h b/components/web_server/include/web_server.h new file mode 100644 index 0000000..58a0923 --- /dev/null +++ b/components/web_server/include/web_server.h @@ -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 diff --git a/components/web_server/include/wifi.h b/components/web_server/include/wifi.h new file mode 100644 index 0000000..6c348f5 --- /dev/null +++ b/components/web_server/include/wifi.h @@ -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 diff --git a/components/web_server/src/web_server.c b/components/web_server/src/web_server.c new file mode 100644 index 0000000..6e2f3dc --- /dev/null +++ b/components/web_server/src/web_server.c @@ -0,0 +1,293 @@ +#define LOG_TAG "WEBSRV" + +/** + * @def LOG_TAG + * @brief Tag used for web server logging. + */ + +#include "web_server.h" + +#include +#include +#include +#include + +#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; +} diff --git a/components/web_server/src/wifi.c b/components/web_server/src/wifi.c new file mode 100644 index 0000000..d6ea2f5 --- /dev/null +++ b/components/web_server/src/wifi.c @@ -0,0 +1,105 @@ +#define LOG_TAG "WIFI" ///< "WIFI" log tag for this file + +#include + +#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"); +} diff --git a/data/icons/close.svg b/data/icons/close.svg index c0e3e69..9c4645b 100644 --- a/data/icons/close.svg +++ b/data/icons/close.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/icons/hotspot.svg b/data/icons/hotspot.svg index 258ad6a..cac14cc 100644 --- a/data/icons/hotspot.svg +++ b/data/icons/hotspot.svg @@ -1,11 +1 @@ - - - - - \ No newline at end of file + diff --git a/data/icons/lan.svg b/data/icons/lan.svg index afb4cd5..69c6a5c 100644 --- a/data/icons/lan.svg +++ b/data/icons/lan.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/icons/open.svg b/data/icons/open.svg index b7e47a3..e00a27b 100644 --- a/data/icons/open.svg +++ b/data/icons/open.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/icons/refresh.svg b/data/icons/refresh.svg index bdeefb4..979887c 100644 --- a/data/icons/refresh.svg +++ b/data/icons/refresh.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/icons/signal0.svg b/data/icons/signal0.svg index ecbd6bc..09d082a 100644 --- a/data/icons/signal0.svg +++ b/data/icons/signal0.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/icons/signal1.svg b/data/icons/signal1.svg index 527cc6a..8d12a61 100644 --- a/data/icons/signal1.svg +++ b/data/icons/signal1.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/icons/signal2.svg b/data/icons/signal2.svg index 30e3176..c846528 100644 --- a/data/icons/signal2.svg +++ b/data/icons/signal2.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/icons/signal3.svg b/data/icons/signal3.svg index 554b638..d973c98 100644 --- a/data/icons/signal3.svg +++ b/data/icons/signal3.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/icons/signal4.svg b/data/icons/signal4.svg index 7cd931e..8a1100a 100644 --- a/data/icons/signal4.svg +++ b/data/icons/signal4.svg @@ -1,5 +1 @@ - - - \ No newline at end of file + diff --git a/data/index.html b/data/index.html index 9859fb4..41908bb 100644 --- a/data/index.html +++ b/data/index.html @@ -1,328 +1,313 @@ - + - - - - Konfiguration - - - - - - - - - - - - -
-
-
- -

-
- -
-
+ + + + Konfiguration + + + + + + + + + + + + +
+
+
+ +

+
+ +
+
- -
- +
+ + +
+ + +
+ diff --git a/data/input-visibility.js b/data/input-visibility.js index 32445a0..6ba7466 100644 --- a/data/input-visibility.js +++ b/data/input-visibility.js @@ -4,20 +4,20 @@ const dynamicInputs = form.querySelectorAll("[data-field][data-values]"); document.addEventListener("change", updateVisibility); function updateVisibility() { - dynamicInputs.forEach((element) => { - const input = form.querySelector(`#${element.dataset.field}`); - if (element.dataset.values.split("|").includes(input.value)) { - element.classList.remove("hidden"); - element - .querySelectorAll("input, select, button, textarea") - .forEach((childInput) => (childInput.disabled = false)); - } else { - element.classList.add("hidden"); - element - .querySelectorAll("input, select, button, textarea") - .forEach((childInput) => (childInput.disabled = true)); - } - }); + dynamicInputs.forEach(element => { + const input = form.querySelector(`#${element.dataset.field}`); + if (element.dataset.values.split("|").includes(input.value)) { + element.classList.remove("hidden"); + element + .querySelectorAll("input, select, button, textarea") + .forEach(childInput => (childInput.disabled = false)); + } else { + element.classList.add("hidden"); + element + .querySelectorAll("input, select, button, textarea") + .forEach(childInput => (childInput.disabled = true)); + } + }); } updateVisibility(); diff --git a/data/load-data.js b/data/load-data.js index bf4c1e0..54c114a 100644 --- a/data/load-data.js +++ b/data/load-data.js @@ -1,7 +1,7 @@ import { - showLoadingScreen, - showError, - hideLoadingScreen, + showLoadingScreen, + showError, + hideLoadingScreen, } from "./loading-screen.js"; const form = document.querySelector("form.config"); @@ -9,46 +9,46 @@ const form = document.querySelector("form.config"); export let data = {}; export async function loadData(timeout = null) { - const req = await fetch("/config", { - method: "GET", - signal: timeout !== null ? AbortSignal.timeout(timeout) : undefined, - }); - if (!req.ok) { - throw new Error(`Response status: ${req.status}`); - } + const req = await fetch("/config", { + method: "GET", + signal: timeout !== null ? AbortSignal.timeout(timeout) : undefined, + }); + if (!req.ok) { + throw new Error(`Response status: ${req.status}`); + } - const json = await req.json(); - console.log(json); - return json; + const json = await req.json(); + console.log(json); + return json; } export function writeDataToInput(data) { - console.log("write data"); - for (const [key, value] of Object.entries(data)) { - const element = document.querySelector(`[name=${key}]`); - console.log(key, element); + console.log("write data"); + for (const [key, value] of Object.entries(data)) { + const element = document.querySelector(`[name=${key}]`); + console.log(key, element); - if (element.type === "checkbox") { - element.checked = value; - } else { - element.value = value; - } - - if (element.type === "range") { - // update text next to the slider by sending an event - element.dispatchEvent(new Event("input", { bubbles: true })); - } + if (element.type === "checkbox") { + element.checked = value; + } else { + element.value = value; } - // 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..."); try { - data = await loadData(); - hideLoadingScreen(); - writeDataToInput(data); + data = await loadData(); + hideLoadingScreen(); + writeDataToInput(data); } catch (error) { - console.log(error.message); - showError("Die Konfiguration konnte nicht geladen werden."); + console.log(error.message); + showError("Die Konfiguration konnte nicht geladen werden."); } diff --git a/data/loading-screen.js b/data/loading-screen.js index 322c479..00749bf 100644 --- a/data/loading-screen.js +++ b/data/loading-screen.js @@ -5,36 +5,36 @@ const spinner = loadingScreen.querySelector(".spinner"); const reloadBtn = loadingScreen.querySelector(".reload"); export function showLoadingScreen(msg) { - hide(content, reloadBtn); - show(loadingScreen, spinner); - loadingMsg.classList.remove("error"); - loadingMsg.textContent = msg; + hide(content, reloadBtn); + show(loadingScreen, spinner); + loadingMsg.classList.remove("error"); + loadingMsg.textContent = msg; } export function showError(msg) { - showLoadingScreen(msg); - loadingMsg.innerHTML += - "
Stelle sicher, dass du mit dem DMX-Interface verbunden bist und die IP-Adresse stimmt."; - show(reloadBtn); - hide(spinner); - loadingMsg.classList.add("error"); + showLoadingScreen(msg); + loadingMsg.innerHTML += + "
Stelle sicher, dass du mit dem DMX-Interface verbunden bist und die IP-Adresse stimmt."; + show(reloadBtn); + hide(spinner); + loadingMsg.classList.add("error"); } export function hideLoadingScreen() { - hide(loadingScreen, reloadBtn); - show(content); - loadingMsg.classList.remove("error"); - loadingMsg.textContent = ""; + hide(loadingScreen, reloadBtn); + show(content); + loadingMsg.classList.remove("error"); + loadingMsg.textContent = ""; } function show(...elements) { - for (const element of elements) { - element.classList.remove("hidden"); - } + for (const element of elements) { + element.classList.remove("hidden"); + } } function hide(...elements) { - for (const element of elements) { - element.classList.add("hidden"); - } + for (const element of elements) { + element.classList.add("hidden"); + } } diff --git a/data/networks.js b/data/networks.js index 9bc4b17..8cb056e 100644 --- a/data/networks.js +++ b/data/networks.js @@ -7,67 +7,67 @@ const refreshIcon = refreshButton.querySelector("img"); let isLoading = false; refreshButton.addEventListener("click", async () => { - // check if interface is in WiFi-AccessPoint mode - if (data.connection == 1) { - alert( - "Beim WLAN-Scan wird die Verbindung hardwarebedingt kurzzeitig" + - "unterbrochen.\n" + - "Möglicherweise muss das Interface neu verbunden werden." - ); - } - updateNetworks(); + // check if interface is in WiFi-AccessPoint mode + if (data.connection == 1) { + alert( + "Beim WLAN-Scan wird die Verbindung hardwarebedingt kurzzeitig" + + "unterbrochen.\n" + + "Möglicherweise muss das Interface neu verbunden werden." + ); + } + updateNetworks(); }); // check if connected via WiFi-Station if (data.connection === 0) { - // show currently connected WiFi - insertNetworks([data.ssid]); + // show currently connected WiFi + insertNetworks([data.ssid]); } function insertNetworks(networks) { - networkDropdown.textContent = ""; // clear dropdown + networkDropdown.textContent = ""; // clear dropdown - for (const ssid of networks) { - const option = document.createElement("option"); - option.value = ssid; - option.innerText = ssid; - networkDropdown.appendChild(option); - } + for (const ssid of networks) { + const option = document.createElement("option"); + option.value = ssid; + option.innerText = ssid; + networkDropdown.appendChild(option); + } } async function loadNetworks() { - if (isLoading) return; + if (isLoading) return; - isLoading = true; - refreshButton.classList.remove("error-bg"); - refreshIcon.classList.add("spinning"); + isLoading = true; + refreshButton.classList.remove("error-bg"); + refreshIcon.classList.add("spinning"); - try { - const res = await fetch("/networks", { - method: "GET", - }); + try { + const res = await fetch("/networks", { + method: "GET", + }); - if (!res.ok) { - 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 []; + if (!res.ok) { + 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 []; + } } async function updateNetworks() { - const networks = await loadNetworks(); - if (networks) { - insertNetworks(["", ...networks]); - } + const networks = await loadNetworks(); + if (networks) { + insertNetworks(["", ...networks]); + } } diff --git a/data/range-input.js b/data/range-input.js index 7e42f1b..cc8db68 100644 --- a/data/range-input.js +++ b/data/range-input.js @@ -1,14 +1,14 @@ -document.querySelector("form.config").addEventListener("input", (event) => { - if (event.target.classList.contains("range")) { - updateValue(event.target); - } +document.querySelector("form.config").addEventListener("input", event => { + if (event.target.classList.contains("range")) { + updateValue(event.target); + } }); function updateValue(slider) { - const percentage = Math.round((slider.value / slider.max) * 100); - slider.nextElementSibling.innerText = `${percentage}%`; + const percentage = Math.round((slider.value / slider.max) * 100); + slider.nextElementSibling.innerText = `${percentage}%`; } -document.querySelectorAll("input[type='range'].range").forEach((element) => { - updateValue(element); +document.querySelectorAll("input[type='range'].range").forEach(element => { + updateValue(element); }); diff --git a/data/reset.js b/data/reset.js index ecf29ec..8bb6354 100644 --- a/data/reset.js +++ b/data/reset.js @@ -2,15 +2,15 @@ import { updateConfig } from "/submit.js"; const form = document.querySelector("form.config"); -form.addEventListener("reset", async (event) => { - event.preventDefault(); +form.addEventListener("reset", async event => { + event.preventDefault(); - const ok = confirm( - "Sicher, dass du alle Einstellungen zurücksetzen möchtest?" - ); - if (ok) { - updateConfig({ - method: "DELETE", - }); - } + const ok = confirm( + "Sicher, dass du alle Einstellungen zurücksetzen möchtest?" + ); + if (ok) { + updateConfig({ + method: "DELETE", + }); + } }); diff --git a/data/status.js b/data/status.js index ca42350..b44c30f 100644 --- a/data/status.js +++ b/data/status.js @@ -5,108 +5,105 @@ const statusDialog = document.querySelector(".dialog-status"); const expandButton = document.querySelector(".expand-status"); expandButton.addEventListener("click", () => { - statusDialog.showModal(); + statusDialog.showModal(); }); registerCallback("status", setStatus); initWebSocket(); function setStatus(status) { - setValue("model", status.chip.model); - setValue("mac", formatMac(status.chip.mac)); - setValue("sdk-version", status.sdkVersion); + setValue("model", status.chip.model); + setValue("mac", formatMac(status.chip.mac)); + setValue("sdk-version", status.sdkVersion); - setValue("rssi", status.connection.signalStrength); - const icon = selectConnectionIcon(status.connection.signalStrength); - document.querySelectorAll(".connection-icon").forEach((img) => { - img.src = `/icons/${icon}`; - }); + setValue("rssi", status.connection.signalStrength); + const icon = selectConnectionIcon(status.connection.signalStrength); + document.querySelectorAll(".connection-icon").forEach(img => { + img.src = `/icons/${icon}`; + }); - setValue("cpu-freq", status.chip.cpuFreqMHz); - setValue("cpu-cycle-count", status.chip.cycleCount); - setValue("cpu-temp", status.chip.tempC); + setValue("cpu-freq", status.chip.cpuFreqMHz); + setValue("cpu-cycle-count", status.chip.cycleCount); + setValue("cpu-temp", status.chip.tempC); - const usedHeap = status.heap.total - status.heap.free; - setValue("heap-used", formatBytes(usedHeap)); - setValue("heap-total", formatBytes(status.heap.total)); - setValue( - "heap-percentage", - Math.round((usedHeap / status.heap.total) * 100) - ); + const usedHeap = status.heap.total - status.heap.free; + setValue("heap-used", formatBytes(usedHeap)); + setValue("heap-total", formatBytes(status.heap.total)); + setValue("heap-percentage", Math.round((usedHeap / status.heap.total) * 100)); - const usedPsram = status.psram.total - status.psram.free; - setValue("psram-used", formatBytes(usedPsram)); - setValue("psram-total", formatBytes(status.psram.total)); - setValue( - "psram-percentage", - Math.round((usedPsram / status.psram.total) * 100) - ); + const usedPsram = status.psram.total - status.psram.free; + setValue("psram-used", formatBytes(usedPsram)); + setValue("psram-total", formatBytes(status.psram.total)); + setValue( + "psram-percentage", + 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) { - document.querySelectorAll("." + className).forEach((element) => { - element.innerText = value; - }); + document.querySelectorAll("." + className).forEach(element => { + element.innerText = value; + }); } function parseDuration(ms) { - const date = new Date(ms); - const time = - date.getUTCHours().toString().padStart(2, "0") + - ":" + - date.getUTCMinutes().toString().padStart(2, "0") + - " h"; - const days = Math.floor(date.getTime() / (1000 * 60 * 60 * 24)); + const date = new Date(ms); + const time = + date.getUTCHours().toString().padStart(2, "0") + + ":" + + date.getUTCMinutes().toString().padStart(2, "0") + + " h"; + 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) { - return hash.toUpperCase().substring(0, 16); + return hash.toUpperCase().substring(0, 16); } function formatBytes(bytes) { - const units = ["Bytes", "KB", "MB", "GB"]; + const units = ["Bytes", "KB", "MB", "GB"]; - let value = bytes; - let index = 0; - while (value >= 1000) { - value /= 1000; - index++; - } + let value = bytes; + let index = 0; + while (value >= 1000) { + value /= 1000; + index++; + } - return `${Math.round(value * 10) / 10} ${units[index]}`; + return `${Math.round(value * 10) / 10} ${units[index]}`; } function formatMac(decimalMac) { - const octets = decimalMac.toString(16).toUpperCase().match(/../g) || []; - return octets.reverse().join(":"); + const octets = decimalMac.toString(16).toUpperCase().match(/../g) || []; + return octets.reverse().join(":"); } function selectConnectionIcon(signalStrength) { - // access point - if (data.connection == 1) { - return "hotspot.svg"; - } + // access point + if (data.connection == 1) { + return "hotspot.svg"; + } - // ethernet - if (data.connection == 2) { - return "lan.svg"; - } + // ethernet + if (data.connection == 2) { + return "lan.svg"; + } - // station - if (signalStrength >= -50) { - return "signal4.svg"; - } - if (signalStrength >= -60) { - return "signal3.svg"; - } - if (signalStrength >= -70) { - return "signal2.svg"; - } - return "signal1.svg"; + // station + if (signalStrength >= -50) { + return "signal4.svg"; + } + if (signalStrength >= -60) { + return "signal3.svg"; + } + if (signalStrength >= -70) { + return "signal2.svg"; + } + return "signal1.svg"; } diff --git a/data/style.css b/data/style.css index 06bc06e..fabefb5 100644 --- a/data/style.css +++ b/data/style.css @@ -1,330 +1,330 @@ :root { - --color-primary: #087e8b; - --color-on-primary: white; - --color-background: #222; - --color-surface: #333; - --color-danger: #fa2b58; - --appended-item-size: 2.5rem; + --color-primary: #087e8b; + --color-on-primary: white; + --color-background: #222; + --color-surface: #333; + --color-danger: #fa2b58; + --appended-item-size: 2.5rem; - color-scheme: dark; + color-scheme: dark; } body { - margin: 0; - padding: 0; - background: linear-gradient(to left, #065760, black, black, #065760); - color: white; - font-family: Arial, Helvetica, sans-serif; - overflow: hidden; + margin: 0; + padding: 0; + background: linear-gradient(to left, #065760, black, black, #065760); + color: white; + font-family: Arial, Helvetica, sans-serif; + overflow: hidden; } main { - background-color: var(--color-background); - max-width: 700px; - padding: 8px max(5%, 8px); - margin: 0 auto; - height: 100vh; - overflow: auto; + background-color: var(--color-background); + max-width: 700px; + padding: 8px max(5%, 8px); + margin: 0 auto; + height: 100vh; + overflow: auto; } h1 { - text-align: center; + text-align: center; } form > * { - margin-bottom: 16px; + margin-bottom: 16px; } fieldset { - border-radius: 8px; - border-color: white; + border-radius: 8px; + border-color: white; } label { - display: block; - display: flex; - gap: 8px; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; + display: block; + display: flex; + gap: 8px; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; } label span { - flex-grow: 1; + flex-grow: 1; } input, select, label div { - width: clamp(200px, 100%, 400px); + width: clamp(200px, 100%, 400px); } input, select { - background-color: var(--color-background); - color: white; - border: 1px solid white; - border-radius: 8px; - padding: 8px; - box-sizing: border-box; + background-color: var(--color-background); + color: white; + border: 1px solid white; + border-radius: 8px; + padding: 8px; + box-sizing: border-box; } input:focus, select:focus { - outline: none; - border-color: var(--color-primary); + outline: none; + border-color: var(--color-primary); } select:has(+ .icon-button), 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"] { - accent-color: var(--color-primary); + accent-color: var(--color-primary); } button { - border: none; - inset: none; - border-radius: 8px; - background-color: var(--color-primary); - color: var(--color-on-primary); - padding: 8px 16px; + border: none; + inset: none; + border-radius: 8px; + background-color: var(--color-primary); + color: var(--color-on-primary); + padding: 8px 16px; } 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) { - margin-top: 8px; + + :is(div:has(:is(input, select)), input, select, label) { + margin-top: 8px; } .hidden { - display: none !important; + display: none !important; } label.switch { - display: inline-flex; - flex-direction: row; - align-items: center; - gap: 8px; - -webkit-user-select: none; - user-select: none; + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 8px; + -webkit-user-select: none; + user-select: none; } label.switch input { - display: none; + display: none; } label.switch .slider { - display: inline-block; - position: relative; - height: 1em; - width: 2em; - background-color: #444; - border-radius: 1em; - border: 4px solid #444; + display: inline-block; + position: relative; + height: 1em; + width: 2em; + background-color: #444; + border-radius: 1em; + border: 4px solid #444; } label.switch .slider::before { - content: ""; - position: absolute; - height: 100%; - aspect-ratio: 1 / 1; - border-radius: 50%; - top: 50%; - background-color: white; - transition: all 0.1s linear; + content: ""; + position: absolute; + height: 100%; + aspect-ratio: 1 / 1; + border-radius: 50%; + top: 50%; + background-color: white; + transition: all 0.1s linear; } label.switch:active .slider::before { - transform: scale(1.3); - transform-origin: 50% 50%; + transform: scale(1.3); + transform-origin: 50% 50%; } label.switch input:not(:checked) + .slider::before { - left: 0%; - translate: 0 -50%; + left: 0%; + translate: 0 -50%; } label.switch input:checked + .slider::before { - left: 100%; - translate: -100% -50%; + left: 100%; + translate: -100% -50%; } dialog { - width: 80%; - max-width: 500px; - max-height: 80%; - overflow: auto; - background-color: var(--color-background); - color: white; - border: none; - border-radius: 8px; - padding: 16px; + width: 80%; + max-width: 500px; + max-height: 80%; + overflow: auto; + background-color: var(--color-background); + color: white; + border: none; + border-radius: 8px; + padding: 16px; } dialog::backdrop { - background-color: #000a; + background-color: #000a; } .dialog-header { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; - & button { - margin: 0; - } + & button { + margin: 0; + } } .card { - background-color: var(--color-surface); - padding: 8px; - border-radius: 8px; + background-color: var(--color-surface); + padding: 8px; + border-radius: 8px; } .card > * { - display: block; + display: block; } .card > :first-child { - color: var(--color-primary); - margin-bottom: 8px; + color: var(--color-primary); + margin-bottom: 8px; } .buttons { - display: flex; - flex-direction: row; - justify-content: center; - gap: 8px; + display: flex; + flex-direction: row; + justify-content: center; + gap: 8px; } .loading-screen { - display: grid; - justify-content: center; + display: grid; + justify-content: center; } .error { - color: var(--color-danger); + color: var(--color-danger); } .error-bg { - background-color: var(--color-danger) !important; + background-color: var(--color-danger) !important; } button.reload { - display: block; - margin: 0 auto; + display: block; + margin: 0 auto; } .spinner-container { - width: min(max-content, 100%); + width: min(max-content, 100%); } .spinner { - position: relative; - margin: 10px auto; - background: conic-gradient(transparent 150deg, var(--color-primary)); - --outer-diameter: 50px; - width: var(--outer-diameter); - height: var(--outer-diameter); - border-radius: 50%; + position: relative; + margin: 10px auto; + background: conic-gradient(transparent 150deg, var(--color-primary)); + --outer-diameter: 50px; + width: var(--outer-diameter); + height: var(--outer-diameter); + border-radius: 50%; - animation-name: spin; - animation-duration: 1s; - animation-iteration-count: infinite; - animation-timing-function: linear; + animation-name: spin; + animation-duration: 1s; + animation-iteration-count: infinite; + animation-timing-function: linear; } .spinner::after { - position: absolute; - content: ""; - display: block; - --spinner-border: 5px; - top: var(--spinner-border); - left: var(--spinner-border); + position: absolute; + content: ""; + display: block; + --spinner-border: 5px; + top: var(--spinner-border); + left: var(--spinner-border); - --inner-diameter: calc(var(--outer-diameter) - 2 * var(--spinner-border)); - width: var(--inner-diameter); - height: var(--inner-diameter); + --inner-diameter: calc(var(--outer-diameter) - 2 * var(--spinner-border)); + width: var(--inner-diameter); + height: var(--inner-diameter); - background-color: var(--color-background); - border-radius: 50%; + background-color: var(--color-background); + border-radius: 50%; } @keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } .icon-button { - padding: 8px; - font-size: 0; - width: var(--appended-item-size); - height: var(--appended-item-size); + padding: 8px; + font-size: 0; + width: var(--appended-item-size); + height: var(--appended-item-size); } .spinning { - animation-name: spin; - animation-duration: 0.5s; - animation-iteration-count: infinite; - animation-timing-function: linear; + animation-name: spin; + animation-duration: 0.5s; + animation-iteration-count: infinite; + animation-timing-function: linear; } div:has(.range-value) { - display: flex; - flex-direction: row; - gap: 8px; + display: flex; + flex-direction: row; + gap: 8px; } .range-value { - width: var(--appended-item-size); - height: var(--appended-item-size); - text-align: center; - line-height: var(--appended-item-size); + width: var(--appended-item-size); + height: var(--appended-item-size); + text-align: center; + line-height: var(--appended-item-size); } .status { - background-color: var(--color-surface); - padding: 8px; - border-radius: 8px; + background-color: var(--color-surface); + padding: 8px; + border-radius: 8px; - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - gap: 16px; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 16px; } .dialog-status-content { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; } .connection-icon { - width: 24px; - height: 24px; - padding: 8px; + width: 24px; + height: 24px; + padding: 8px; } .connection-icon.small { - padding: 0; - height: 1em; + padding: 0; + height: 1em; } .centered-vertical { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; } diff --git a/data/submit.js b/data/submit.js index ee599e5..06a5fa1 100644 --- a/data/submit.js +++ b/data/submit.js @@ -1,74 +1,74 @@ import { loadData, writeDataToInput } from "./load-data.js"; import { - showLoadingScreen, - hideLoadingScreen, - showError, + showLoadingScreen, + hideLoadingScreen, + showError, } from "./loading-screen.js"; const form = document.querySelector("form.config"); function parseValue(input) { - if (input.type === "checkbox") { - return input.checked - ? input.dataset.valueChecked - : input.dataset.valueNotChecked; - } + if (input.type === "checkbox") { + return input.checked + ? input.dataset.valueChecked + : input.dataset.valueNotChecked; + } - if (input.value === "") { - return null; - } + if (input.value === "") { + return null; + } - if (input.type === "number" || input.type === "range") { - const number = Number(input.value); - return Number.isNaN(number) ? null : number; - } + if (input.type === "number" || input.type === "range") { + const number = Number(input.value); + return Number.isNaN(number) ? null : number; + } - return input.value; + return input.value; } -form.addEventListener("submit", (event) => { - event.preventDefault(); - const inputFields = document.querySelectorAll( - "form :is(input, select, textarea):not(:disabled)" - ); +form.addEventListener("submit", event => { + event.preventDefault(); + const inputFields = document.querySelectorAll( + "form :is(input, select, textarea):not(:disabled)" + ); - const data = Array.from(inputFields).reduce((data, input) => { - data[input.name] = parseValue(input); - return data; - }, {}); - console.log(data); + const data = Array.from(inputFields).reduce((data, input) => { + data[input.name] = parseValue(input); + return data; + }, {}); + console.log(data); - updateConfig({ - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); + updateConfig({ + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); }); 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 { - const res = await fetch("/config", fetchOptions); - if (!res.ok) { - throw new Error(`Response status: ${res.status}`); - } + const data = await loadData(5000); + writeDataToInput(data); + hideLoadingScreen(); + + break; } catch (error) { - console.error(error.message); - 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 - } + // retry loading config until successful } + } } diff --git a/data/websocket.js b/data/websocket.js index 268d69e..5fc3cea 100644 --- a/data/websocket.js +++ b/data/websocket.js @@ -4,34 +4,34 @@ let ws; let callbacks = {}; export function initWebSocket() { - if (ws) return; + if (ws) return; - ws = new WebSocket(gateway); + ws = new WebSocket(gateway); - ws.onopen = () => { - console.info("WebSocket connection opened"); - }; + ws.onopen = () => { + console.info("WebSocket connection opened"); + }; - ws.onclose = (event) => { - console.info("WebSocket connection closed, reason:", event.reason); - ws = null; - }; + ws.onclose = event => { + console.info("WebSocket connection closed, reason:", event.reason); + ws = null; + }; - ws.onerror = (event) => { - console.warn("WebSocket encountered error, closing socket.", event); - ws.close(); - ws = null; - }; + ws.onerror = event => { + console.warn("WebSocket encountered error, closing socket.", event); + ws.close(); + ws = null; + }; - ws.onmessage = (event) => { - const message = JSON.parse(event.data); - console.log("received websocket data", message); - if (message.type in callbacks) { - callbacks[message.type](message.data); - } - }; + ws.onmessage = event => { + const message = JSON.parse(event.data); + console.log("received websocket data", message); + if (message.type in callbacks) { + callbacks[message.type](message.data); + } + }; } export function registerCallback(type, callback) { - callbacks[type] = callback; + callbacks[type] = callback; } diff --git a/dependencies.lock b/dependencies.lock new file mode 100644 index 0000000..dca625b --- /dev/null +++ b/dependencies.lock @@ -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 diff --git a/docs/external/doxygen-awesome-css b/docs/external/doxygen-awesome-css new file mode 160000 index 0000000..1f36200 --- /dev/null +++ b/docs/external/doxygen-awesome-css @@ -0,0 +1 @@ +Subproject commit 1f3620084ff75734ed192101acf40e9dff01d848 diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..9f23426 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5740a76 --- /dev/null +++ b/flake.nix @@ -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 + ]; + }; + }; +} diff --git a/lib/ArtNet b/lib/ArtNet deleted file mode 160000 index 5d9c42b..0000000 --- a/lib/ArtNet +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5d9c42b531404ccfbcb14106d6312b03a166868a diff --git a/lib/AsyncWebServer_ESP32_W5500 b/lib/AsyncWebServer_ESP32_W5500 deleted file mode 160000 index 38de6ac..0000000 --- a/lib/AsyncWebServer_ESP32_W5500 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 38de6ac248c7f270ca3b77ba38512ba39919aed8 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..07d74e6 --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,8 @@ +idf_component_register( + SRCS + "dmx-interface.c" + INCLUDE_DIRS + "." + REQUIRES + web_server + logger) diff --git a/main/dmx-interface.c b/main/dmx-interface.c new file mode 100644 index 0000000..fb94c1b --- /dev/null +++ b/main/dmx-interface.c @@ -0,0 +1,41 @@ +#define LOG_TAG "MAIN" ///< "MAIN" log tag for this file + +#include + +#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)); + } +} diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..f58a105 --- /dev/null +++ b/main/idf_component.yml @@ -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 diff --git a/partitions.csv b/partitions.csv new file mode 100755 index 0000000..9c2c9d7 --- /dev/null +++ b/partitions.csv @@ -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, diff --git a/platformio.ini b/platformio.ini deleted file mode 100644 index 7233133..0000000 --- a/platformio.ini +++ /dev/null @@ -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 diff --git a/pre_extra_script.py b/pre_extra_script.py deleted file mode 100644 index 282b492..0000000 --- a/pre_extra_script.py +++ /dev/null @@ -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") diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 0000000..8f61fa8 --- /dev/null +++ b/sdkconfig.defaults @@ -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 diff --git a/src/main.cpp b/src/main.cpp deleted file mode 100644 index 5c18ba1..0000000 --- a/src/main.cpp +++ /dev/null @@ -1,515 +0,0 @@ -#ifdef ESP32 -#include -#include -// #include -// #include "USBCDC.h" -#include "driver/temp_sensor.h" -#elif defined(ESP8266) -#include -#include -#endif - -#include -// #include "w5500/esp32_w5500.h" -// #include - -#include -#include - -// #include "ESPDMX.h" -#include -#include - -#include -#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(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(config.getUInt("direction-1", DEFAULT_DIRECTION1)); - direction2 = static_cast(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(config.getUInt("connection", DEFAULT_CONNECTION)); - IpMethod ipMethod = static_cast(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(); -} diff --git a/src/routes/config.cpp b/src/routes/config.cpp deleted file mode 100644 index 25b3fec..0000000 --- a/src/routes/config.cpp +++ /dev/null @@ -1,177 +0,0 @@ -#include "config.h" -#include -#include -#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); - } - - throw ::std::invalid_argument("Invalid IP method value" + ipMethod); -} - -Connection parseConnection(uint8_t connection) -{ - if (connection > 0 || connection < CONNECTION_SIZE) - { - return static_cast(connection); - } - - throw ::std::invalid_argument("Invalid connection value: " + connection); -} - -Direction parseDirection(uint8_t direction) -{ - if (direction > 0 || direction < DIRECTION_SIZE) - { - return static_cast(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); - } - - 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()); - config.putUInt("ip-method", ipMethod); - - if (ipMethod == Static) - { - IPAddress ipAddress; - ipAddress.fromString(doc["ip"].as()); - config.putUInt("ip", ipAddress); - - IPAddress subnet; - subnet.fromString(doc["subnet"].as()); - config.putUInt("subnet", subnet); - - IPAddress gateway; - gateway.fromString(doc["gateway"].as()); - config.putUInt("gateway", gateway); - } - - Connection connection = parseConnection(doc["connection"].as()); - config.putUInt("connection", connection); - - if (connection == WiFiSta || connection == WiFiAP) - { - config.putString("ssid", doc["ssid"].as()); - config.putString("password", doc["password"].as()); - } - - Direction direction1 = parseDirection(doc["direction-1"].as()); - config.putUInt("direction-1", direction1); - - Direction direction2 = parseDirection(doc["direction-2"].as()); - 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()); - config.putUInt("button-action", buttonAction); - - config.end(); - - request->send(200); - } - catch (::std::invalid_argument &e) - { - config.end(); - request->send(400, "text/plain", e.what()); - } -} diff --git a/src/routes/config.h b/src/routes/config.h deleted file mode 100644 index 957ba25..0000000 --- a/src/routes/config.h +++ /dev/null @@ -1,59 +0,0 @@ -#include -#include - -#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 \ No newline at end of file diff --git a/src/routes/networks.cpp b/src/routes/networks.cpp deleted file mode 100644 index 9cda700..0000000 --- a/src/routes/networks.cpp +++ /dev/null @@ -1,29 +0,0 @@ -#include "networks.h" - -void onGetNetworks(AsyncWebServerRequest *request) -{ - JsonDocument doc; - JsonArray array = doc.to(); - - 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); -} diff --git a/src/routes/networks.h b/src/routes/networks.h deleted file mode 100644 index 33a742c..0000000 --- a/src/routes/networks.h +++ /dev/null @@ -1,9 +0,0 @@ -#include -#include - -#ifndef NETWORKS_H -#define NETWORKS_H - -void onGetNetworks(AsyncWebServerRequest *request); - -#endif \ No newline at end of file diff --git a/src/routes/status.cpp b/src/routes/status.cpp deleted file mode 100644 index 44af29b..0000000 --- a/src/routes/status.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include "status.h" - -int getTemperature() -{ - float tempC = -1.0f; - temp_sensor_read_celsius(&tempC); - return static_cast(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; -} \ No newline at end of file diff --git a/src/routes/status.h b/src/routes/status.h deleted file mode 100644 index 9df7ac2..0000000 --- a/src/routes/status.h +++ /dev/null @@ -1,5 +0,0 @@ -#include -#include -#include - -JsonDocument buildStatusJson(); diff --git a/src/websocket.cpp b/src/websocket.cpp deleted file mode 100644 index 0bb8d1a..0000000 --- a/src/websocket.cpp +++ /dev/null @@ -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); -} diff --git a/src/websocket.h b/src/websocket.h deleted file mode 100644 index 05d1144..0000000 --- a/src/websocket.h +++ /dev/null @@ -1,11 +0,0 @@ -#include -#include "routes/status.h" - -#ifndef WEBSOCKET_H -#define WEBSOCKET_H - -void initWebSocket(AsyncWebServer *server); - -void webSocketLoop(); - -#endif diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..df0e41b --- /dev/null +++ b/tasks.py @@ -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 + ) diff --git a/tools/doxy-coverage.py b/tools/doxy-coverage.py new file mode 100755 index 0000000..ea42100 --- /dev/null +++ b/tools/doxy-coverage.py @@ -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 +# +# 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()