diff --git a/.gitignore b/.gitignore index 9c096f7..af425ae 100755 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /build/ *.elf *.bin +*.uf2 *.hex *.map *.img diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b413fa2..9ad2571 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.prettierignore b/.prettierignore index ad65d50..84199a1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,8 @@ # Ignore build artifacts and generated files build/ managed_components/ +docs/doxygen/ +docs/external/ .direnv/ *.lock diff --git a/CMakeLists.txt b/CMakeLists.txt index 0ec000b..d632be3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/Doxyfile b/Doxyfile index d6d5211..b875352 100644 --- a/Doxyfile +++ b/Doxyfile @@ -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 \ diff --git a/README.md b/README.md index 00955ed..5489749 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/components/logger/include/logger.h b/components/logger/include/logger.h index 8090718..ecb88ea 100644 --- a/components/logger/include/logger.h +++ b/components/logger/include/logger.h @@ -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 diff --git a/components/storage/CMakeLists.txt b/components/storage/CMakeLists.txt index 2cb9d94..03f4b47 100644 --- a/components/storage/CMakeLists.txt +++ b/components/storage/CMakeLists.txt @@ -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) diff --git a/components/storage/include/storage.h b/components/storage/include/storage.h index 3910ada..84b826e 100644 --- a/components/storage/include/storage.h +++ b/components/storage/include/storage.h @@ -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 */ diff --git a/components/storage/src/storage.c b/components/storage/src/storage.c index 5e40fc3..c7fae3c 100644 --- a/components/storage/src/storage.c +++ b/components/storage/src/storage.c @@ -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 = { diff --git a/components/web_server/CMakeLists.txt b/components/web_server/CMakeLists.txt index 080d042..e390e13 100644 --- a/components/web_server/CMakeLists.txt +++ b/components/web_server/CMakeLists.txt @@ -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) diff --git a/components/web_server/include/wifi.h b/components/web_server/include/wifi.h index 4876de7..6c348f5 100644 --- a/components/web_server/include/wifi.h +++ b/components/web_server/include/wifi.h @@ -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 diff --git a/components/web_server/src/web_server.c b/components/web_server/src/web_server.c index 9d25638..6e2f3dc 100644 --- a/components/web_server/src/web_server.c +++ b/components/web_server/src/web_server.c @@ -1,5 +1,10 @@ #define LOG_TAG "WEBSRV" +/** + * @def LOG_TAG + * @brief Tag used for web server logging. + */ + #include "web_server.h" #include @@ -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) { diff --git a/components/web_server/src/wifi.c b/components/web_server/src/wifi.c index 222592f..d6ea2f5 100644 --- a/components/web_server/src/wifi.c +++ b/components/web_server/src/wifi.c @@ -1,6 +1,4 @@ -#define LOG_TAG "WIFI" - -#include "wifi.h" +#define LOG_TAG "WIFI" ///< "WIFI" log tag for this file #include @@ -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; diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 1a454b6..5b7abd5 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -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) diff --git a/main/dmx-interface.c b/main/dmx-interface.c index ac0ec96..c7e6570 100644 --- a/main/dmx-interface.c +++ b/main/dmx-interface.c @@ -1,33 +1,39 @@ +#define LOG_TAG "MAIN" ///< "MAIN" log tag for this file + #include #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); diff --git a/tasks.py b/tasks.py index e97fe48..df0e41b 100644 --- a/tasks.py +++ b/tasks.py @@ -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 + ) 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()