Merge branch 'idf' into idf-dmx

This commit is contained in:
Hendrik Rauh 2026-03-20 22:47:00 +01:00 committed by GitHub
commit 573757ffe0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 443 additions and 103 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@
/build/
*.elf
*.bin
*.uf2
*.hex
*.map
*.img

View file

@ -11,7 +11,8 @@ exclude: |
.*\.o|
flake.lock|
dependencies.lock|
assets/case/
assets/case/|
docs/doxygen/
)
repos:
@ -33,7 +34,6 @@ repos:
args: [--maxkb=1000]
- id: check-ast
- id: check-case-conflict
- id: check-docstring-first
- id: check-json
- id: check-merge-conflict
- id: detect-private-key
@ -99,3 +99,22 @@ repos:
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=40, --generate-docs]
types_or: [c, c++, header]
verbose: false
require_serial: true

View file

@ -1,6 +1,8 @@
# Ignore build artifacts and generated files
build/
managed_components/
docs/doxygen/
docs/external/
.direnv/
*.lock

View file

@ -1,10 +1,7 @@
# For more information about build system see
# https://docs.espressif.com/projects/esp-idf/en/latest/api-guides/build-system.html
# The following five lines of boilerplate have to be in your project's
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
# Clear any stale EXTRA_COMPONENT_DIRS entries (e.g. leftover from previous runs or PlatformIO)
# 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)
@ -13,5 +10,6 @@ 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)
littlefs_create_partition_image(storage ${CMAKE_SOURCE_DIR}/data
FLASH_IN_PROJECT)
endif()

View file

@ -17,6 +17,7 @@ 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 \

View file

@ -15,7 +15,7 @@
| 1x | ♂️-DMX-socket |
| 1x | ♀️-DMX-socket |
> Additionally you need: `some wires`, `soldering equipment`, `3D-printer`, `small screws`, `shrink tubing`, `hot glue gun`
> Additionally you need: `some wires`, `soldering equipment`, `3D-printer`, `small screws` (see [case](#-case)), `heat shrink tubing`, `hot glue gun`
---
@ -123,6 +123,13 @@ All print files (STL, STEP, X_T) can be found in [assets/case](/assets/case/). A
![Prusa Slicer with case loaded](/assets/case/Screenshot.png)
| Part | Screw | Count |
| ----------- | ------- | ----- |
| Case lid | M2x5 | 4x |
| ESP32 | M2x5 | 2x |
| W5500 | M2,5x5 | 2x |
| XLR sockets | M3+Nuts | 4x |
---
## 💡 Status LED

View file

@ -1,4 +1,3 @@
/**
* @file logger.h
* @brief Project-wide logging macros based on ESP-IDF's logging library.
@ -27,8 +26,12 @@
#include "esp_log.h"
#ifdef __cplusplus
extern "C" {
#endif
#ifndef LOG_TAG
#define LOG_TAG "CHAOS"
#define LOG_TAG "CHAOS" ///< Default log tag
#endif
/** @brief Log a message at Error level. */
@ -41,3 +44,7 @@
#define LOGD(...) ESP_LOGD(LOG_TAG, __VA_ARGS__)
/** @brief Log a message at Verbose level. */
#define LOGV(...) ESP_LOGV(LOG_TAG, __VA_ARGS__)
#ifdef __cplusplus
}
#endif

View file

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

View file

@ -1,5 +1,4 @@
#ifndef STORAGE_H
#define STORAGE_H
#pragma once
#include "esp_err.h"
@ -24,5 +23,3 @@ const char *storage_get_mount_point(void);
#ifdef __cplusplus
}
#endif
#endif /* STORAGE_H */

View file

@ -1,11 +1,12 @@
#define LOG_TAG "STORE"
#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";
static const char *LITTLEFS_MOUNT_POINT =
"/data"; ///< Mount point for LittleFS filesystem
esp_err_t storage_init(void) {
esp_vfs_littlefs_conf_t conf = {

View file

@ -1,7 +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
)
SRCS
"src/web_server.c"
"src/wifi.c"
INCLUDE_DIRS
"include"
REQUIRES
esp_http_server
storage
PRIV_REQUIRES
freertos
esp_wifi
esp_event
esp_netif
nvs_flash
logger)

View file

@ -6,8 +6,26 @@
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

View file

@ -1,5 +1,10 @@
#define LOG_TAG "WEBSRV"
/**
* @def LOG_TAG
* @brief Tag used for web server logging.
*/
#include "web_server.h"
#include <ctype.h>
@ -14,12 +19,34 @@
#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;
/**
@ -127,6 +154,15 @@ static void webserver_task(void *arg) {
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");
@ -207,6 +243,13 @@ httpd_handle_t webserver_start(const webserver_config_t *config) {
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;
@ -224,6 +267,14 @@ void webserver_stop(httpd_handle_t server) {
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) {

View file

@ -1,6 +1,4 @@
#define LOG_TAG "WIFI"
#include "wifi.h"
#define LOG_TAG "WIFI" ///< "WIFI" log tag for this file
#include <string.h>
@ -9,9 +7,25 @@
#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) {
@ -74,6 +88,11 @@ esp_err_t wifi_start_ap(const char *ssid, const char *password, uint8_t 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;

View file

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

View file

@ -1,33 +1,39 @@
#define LOG_TAG "MAIN" ///< "MAIN" log tag for this file
#include <stdio.h>
#include "dmx.h"
#include "esp_err.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "logger.h"
#include "web_server.h"
#include "wifi.h"
static const char *TAG = "MAIN";
/**
* @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) {
ESP_LOGI(TAG, "DMX Interface starting...");
LOGI("DMX Interface starting...");
esp_err_t wifi_err = wifi_start_ap("DMX", "mbgmbgmbg", 1, 4);
if (wifi_err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start WiFi AP: %s", esp_err_to_name(wifi_err));
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) {
ESP_LOGE(TAG, "Failed to start web server!");
LOGE("Failed to start web server!");
return;
}
ESP_LOGI(TAG, "Web server started successfully");
ESP_LOGI(TAG, "Open http://192.168.4.1 in your browser");
LOGI("Web server started successfully");
LOGI("Open http://192.168.4.1 in your browser");
init_dmx(DMX_NUM_1, 21, 33);

View file

@ -1,8 +1,8 @@
from invoke import task
from invoke.exceptions import Exit
import os
import shutil
import subprocess
import sys
import webbrowser
@ -85,12 +85,8 @@ def reset(c):
@task
def format(c):
"""Format all source files using pre-commit hooks and optimize SVGs"""
print("\nOptimizing SVG files...")
c.run(
"find . -name '*.svg' -not -path './build/*' -not -path './managed_components/*' | xargs -r svgo --quiet --final-newline",
warn=True,
)
"""Format all source files using pre-commit hooks"""
is_windows = os.name == "nt"
if is_windows:
# Windows doesn't provide a POSIX pty
@ -99,60 +95,6 @@ def format(c):
c.run("pre-commit run --all-files", pty=True)
@task
def format_check(c):
"""Check if all files are formatted correctly"""
missing_tools = []
format_errors = []
print("Checking C file formatting...")
result = c.run(
"find main components -name '*.c' -o -name '*.h' | xargs clang-format --dry-run --Werror",
warn=True,
)
if result:
if result.return_code == 127: # Command not found
missing_tools.append("clang-format")
elif not result.ok:
format_errors.append("C files")
print("Checking Python file formatting...")
result = c.run("black --check tasks.py", warn=True)
if result:
if result.return_code == 127:
missing_tools.append("black")
elif not result.ok:
format_errors.append("Python files")
print("Checking Nix file formatting...")
result = c.run("nixfmt --check flake.nix", warn=True)
if result:
if result.return_code == 127:
missing_tools.append("nixfmt")
elif not result.ok:
format_errors.append("Nix files")
print("Checking other file formatting...")
result = c.run("prettier --check '**/*.{js,json,yaml,yml,md,html,css}'", warn=True)
if result:
if result.return_code == 127:
missing_tools.append("prettier")
elif not result.ok:
format_errors.append("JS/JSON/YAML/HTML/CSS files")
if missing_tools:
print(f"\n❌ ERROR: Missing formatting tools: {', '.join(missing_tools)}")
print("Please install them or reload the nix-shell.")
sys.exit(1)
if format_errors:
print(f"\n❌ ERROR: Formatting issues in: {', '.join(format_errors)}")
print("Run 'invoke format' to fix them.")
sys.exit(1)
print("\n✅ All files are correctly formatted!")
@task(help={"o": "Open documentation in the default browser after generation."})
def docs(c, o=False):
"""Generate Doxygen documentation."""
@ -164,3 +106,11 @@ def docs(c, o=False):
webbrowser.open(f"file://{os.path.abspath(path)}")
return
raise Exit(code=1)
@task
def docs_coverage(c):
"""List doxygen coverage of documentation."""
subprocess.run(
"python tools/doxy-coverage.py docs/doxygen/xml --no-error", shell=True
)

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

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