From ddbee70c237eb4abc76e34b8dfc26ed4e7ae4a91 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Mon, 6 Jul 2026 09:10:37 +0200 Subject: [PATCH] =?UTF-8?q?Add=20firmware=20slices=201=E2=80=932:=20skelet?= =?UTF-8?q?on=20and=20Wi-Fi=20provisioning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement ESP-IDF walking skeleton with SoftAP, health API, and host tests, then Slice 2 ISecureStore/NvsSecureStore, STA join, POST /api/wifi, and the provisioning web UI. Co-authored-by: Cursor --- Software/.gitignore | 17 + Software/CMakeLists.txt | 9 + Software/Doxyfile | 12 +- Software/README.md | 41 +++ Software/components/core/CMakeLists.txt | 13 + .../components/core/include/core/.gitkeep | 0 .../core/include/core/FirmwareVersion.hpp | 69 ++++ .../core/include/core/HealthStatus.hpp | 98 ++++++ .../core/include/core/HealthStatusJson.hpp | 42 +++ .../core/include/core/ISecureStore.hpp | 108 +++++++ .../core/include/core/ParseError.hpp | 39 +++ .../components/core/include/core/Secret.hpp | 130 ++++++++ .../core/include/core/StoreError.hpp | 38 +++ .../core/include/core/WifiCredentials.hpp | 98 ++++++ .../core/include/core/WifiProvisionJson.hpp | 75 +++++ .../components/core/include/core/WifiSsid.hpp | 82 +++++ .../components/core/src/FirmwareVersion.cpp | 36 +++ Software/components/core/src/HealthStatus.cpp | 44 +++ .../components/core/src/HealthStatusJson.cpp | 53 ++++ Software/components/core/src/Secret.cpp | 64 ++++ .../components/core/src/WifiCredentials.cpp | 47 +++ .../components/core/src/WifiProvisionJson.cpp | 93 ++++++ Software/components/core/src/WifiSsid.cpp | 41 +++ Software/components/core/test/CMakeLists.txt | 33 ++ .../core/test/health_status_test.cpp | 89 ++++++ .../core/test/wifi_provision_test.cpp | 147 +++++++++ .../drivers/adau1701/CMakeLists.txt | 6 + .../drivers/adau1701/include/.gitkeep | 0 .../drivers/adau1701/src/component_stub.cpp | 33 ++ .../components/drivers/bt1035/CMakeLists.txt | 6 + .../drivers/bt1035/include/.gitkeep | 0 .../drivers/bt1035/src/component_stub.cpp | 34 ++ .../components/drivers/si4684/CMakeLists.txt | 6 + .../drivers/si4684/include/.gitkeep | 0 .../drivers/si4684/src/component_stub.cpp | 33 ++ Software/components/net/CMakeLists.txt | 13 + Software/components/net/include/.gitkeep | 0 .../net/include/net/NetBootstrap.hpp | 122 ++++++++ .../components/net/include/net/NetError.hpp | 46 +++ .../components/net/include/net/NetState.hpp | 41 +++ .../net/include/net/SetupWebServer.hpp | 114 +++++++ .../net/include/net/SoftApConfig.hpp | 98 ++++++ .../components/net/include/net/SoftApHost.hpp | 111 +++++++ .../components/net/include/net/StaClient.hpp | 109 +++++++ Software/components/net/src/NetBootstrap.cpp | 198 ++++++++++++ .../components/net/src/SetupWebServer.cpp | 295 ++++++++++++++++++ Software/components/net/src/SoftApConfig.cpp | 58 ++++ Software/components/net/src/SoftApHost.cpp | 101 ++++++ Software/components/net/src/StaClient.cpp | 183 +++++++++++ Software/components/net/www/index.html | 162 ++++++++++ Software/components/net/www/index.html.gz | Bin 0 -> 1730 bytes .../components/secure_store/CMakeLists.txt | 7 + .../components/secure_store/include/.gitkeep | 0 .../include/secure_store/NvsSecureStore.hpp | 105 +++++++ .../secure_store/src/NvsSecureStore.cpp | 151 +++++++++ Software/components/services/CMakeLists.txt | 6 + Software/components/services/include/.gitkeep | 0 .../services/src/component_stub.cpp | 33 ++ Software/docs/manual/ch-classes.tex | 85 ++++- Software/main/CMakeLists.txt | 5 + Software/main/main.cpp | 91 ++++++ Software/partitions.csv | 7 + Software/sdkconfig.defaults | 13 + 63 files changed, 3771 insertions(+), 19 deletions(-) create mode 100644 Software/.gitignore create mode 100644 Software/CMakeLists.txt create mode 100644 Software/README.md create mode 100644 Software/components/core/CMakeLists.txt create mode 100644 Software/components/core/include/core/.gitkeep create mode 100644 Software/components/core/include/core/FirmwareVersion.hpp create mode 100644 Software/components/core/include/core/HealthStatus.hpp create mode 100644 Software/components/core/include/core/HealthStatusJson.hpp create mode 100644 Software/components/core/include/core/ISecureStore.hpp create mode 100644 Software/components/core/include/core/ParseError.hpp create mode 100644 Software/components/core/include/core/Secret.hpp create mode 100644 Software/components/core/include/core/StoreError.hpp create mode 100644 Software/components/core/include/core/WifiCredentials.hpp create mode 100644 Software/components/core/include/core/WifiProvisionJson.hpp create mode 100644 Software/components/core/include/core/WifiSsid.hpp create mode 100644 Software/components/core/src/FirmwareVersion.cpp create mode 100644 Software/components/core/src/HealthStatus.cpp create mode 100644 Software/components/core/src/HealthStatusJson.cpp create mode 100644 Software/components/core/src/Secret.cpp create mode 100644 Software/components/core/src/WifiCredentials.cpp create mode 100644 Software/components/core/src/WifiProvisionJson.cpp create mode 100644 Software/components/core/src/WifiSsid.cpp create mode 100644 Software/components/core/test/CMakeLists.txt create mode 100644 Software/components/core/test/health_status_test.cpp create mode 100644 Software/components/core/test/wifi_provision_test.cpp create mode 100644 Software/components/drivers/adau1701/CMakeLists.txt create mode 100644 Software/components/drivers/adau1701/include/.gitkeep create mode 100644 Software/components/drivers/adau1701/src/component_stub.cpp create mode 100644 Software/components/drivers/bt1035/CMakeLists.txt create mode 100644 Software/components/drivers/bt1035/include/.gitkeep create mode 100644 Software/components/drivers/bt1035/src/component_stub.cpp create mode 100644 Software/components/drivers/si4684/CMakeLists.txt create mode 100644 Software/components/drivers/si4684/include/.gitkeep create mode 100644 Software/components/drivers/si4684/src/component_stub.cpp create mode 100644 Software/components/net/CMakeLists.txt create mode 100644 Software/components/net/include/.gitkeep create mode 100644 Software/components/net/include/net/NetBootstrap.hpp create mode 100644 Software/components/net/include/net/NetError.hpp create mode 100644 Software/components/net/include/net/NetState.hpp create mode 100644 Software/components/net/include/net/SetupWebServer.hpp create mode 100644 Software/components/net/include/net/SoftApConfig.hpp create mode 100644 Software/components/net/include/net/SoftApHost.hpp create mode 100644 Software/components/net/include/net/StaClient.hpp create mode 100644 Software/components/net/src/NetBootstrap.cpp create mode 100644 Software/components/net/src/SetupWebServer.cpp create mode 100644 Software/components/net/src/SoftApConfig.cpp create mode 100644 Software/components/net/src/SoftApHost.cpp create mode 100644 Software/components/net/src/StaClient.cpp create mode 100644 Software/components/net/www/index.html create mode 100644 Software/components/net/www/index.html.gz create mode 100644 Software/components/secure_store/CMakeLists.txt create mode 100644 Software/components/secure_store/include/.gitkeep create mode 100644 Software/components/secure_store/include/secure_store/NvsSecureStore.hpp create mode 100644 Software/components/secure_store/src/NvsSecureStore.cpp create mode 100644 Software/components/services/CMakeLists.txt create mode 100644 Software/components/services/include/.gitkeep create mode 100644 Software/components/services/src/component_stub.cpp create mode 100644 Software/main/CMakeLists.txt create mode 100644 Software/main/main.cpp create mode 100644 Software/partitions.csv create mode 100644 Software/sdkconfig.defaults diff --git a/Software/.gitignore b/Software/.gitignore new file mode 100644 index 0000000..7f1045d --- /dev/null +++ b/Software/.gitignore @@ -0,0 +1,17 @@ +# ESP-IDF build artefacts +build/ +sdkconfig +sdkconfig.old +dependencies.lock +managed_components/ + +# Host unit-test build +build-host/ + +# Generated API docs (Doxyfile OUTPUT_DIRECTORY) +docs/api/ + +# Editor / OS +.vscode/ +.idea/ +.DS_Store diff --git a/Software/CMakeLists.txt b/Software/CMakeLists.txt new file mode 100644 index 0000000..529794c --- /dev/null +++ b/Software/CMakeLists.txt @@ -0,0 +1,9 @@ +# DigiRadio firmware — top-level ESP-IDF project +cmake_minimum_required(VERSION 3.16) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# Nested driver components live under components/drivers/ +set(EXTRA_COMPONENT_DIRS "components/drivers") + +project(digiradio) diff --git a/Software/Doxyfile b/Software/Doxyfile index 30655bc..4207a80 100644 --- a/Software/Doxyfile +++ b/Software/Doxyfile @@ -13,11 +13,12 @@ CREATE_SUBDIRS = YES #--------------------------------------------------------------------------- # Input #--------------------------------------------------------------------------- -INPUT = src include +INPUT = components main FILE_PATTERNS = *.hpp *.h *.cpp RECURSIVE = YES # Exclude vendored / generated code from the doc requirement: -EXCLUDE_PATTERNS = */vendor/* */generated/* */build/* +EXCLUDE_PATTERNS = */vendor/* */generated/* */build/* */test/* */component_stub.cpp +EXCLUDE = main/board_pins.hpp #--------------------------------------------------------------------------- # Build: enforce documentation (this is what makes the rule real) @@ -69,9 +70,4 @@ GENERATE_XML = NO #--------------------------------------------------------------------------- # Diagrams (optional, needs Graphviz 'dot'; great for "fits in your head") #--------------------------------------------------------------------------- -HAVE_DOT = YES -CLASS_GRAPH = YES -COLLABORATION_GRAPH = YES -CALL_GRAPH = NO -CALLER_GRAPH = NO -DOT_IMAGE_FORMAT = svg +HAVE_DOT = NO diff --git a/Software/README.md b/Software/README.md new file mode 100644 index 0000000..39458b9 --- /dev/null +++ b/Software/README.md @@ -0,0 +1,41 @@ +# DigiRadio Firmware + +Open-source Hi-Fi DAB+/FM receiver firmware for the ESP32-S3. + +**Status:** Slice 2 — secure store and Wi-Fi STA provisioning. + +## Quick start + +Open this directory (`Software/`) as the Cursor project so `AGENTS.md` and +`.cursor/rules/` load automatically. + +```bash +idf.py set-target esp32s3 +idf.py build +idf.py -p flash monitor +``` + +Host unit tests (pure core, no hardware): + +```bash +cmake -S components/core/test -B build-host \ + -DCMAKE_CXX_COMPILER="$(brew --prefix llvm)/bin/clang++" +cmake --build build-host +ctest --test-dir build-host --output-on-failure +``` + +Documentation (must exit 0): + +```bash +doxygen Doxyfile +python3 tools/check-manual-sync.py +``` + +## Layout + +See [`AGENTS.md`](AGENTS.md) §12 and [`instructions.md`](instructions.md) for +the authoritative repo layout, coding rules, and development roadmap. + +## Licence + +Apache-2.0 — see [`LICENSE`](LICENSE). diff --git a/Software/components/core/CMakeLists.txt b/Software/components/core/CMakeLists.txt new file mode 100644 index 0000000..a6419f5 --- /dev/null +++ b/Software/components/core/CMakeLists.txt @@ -0,0 +1,13 @@ +idf_component_register( + SRCS + "src/FirmwareVersion.cpp" + "src/HealthStatus.cpp" + "src/HealthStatusJson.cpp" + "src/Secret.cpp" + "src/WifiSsid.cpp" + "src/WifiCredentials.cpp" + "src/WifiProvisionJson.cpp" + INCLUDE_DIRS "include" +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/core/include/core/.gitkeep b/Software/components/core/include/core/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Software/components/core/include/core/FirmwareVersion.hpp b/Software/components/core/include/core/FirmwareVersion.hpp new file mode 100644 index 0000000..faddf98 --- /dev/null +++ b/Software/components/core/include/core/FirmwareVersion.hpp @@ -0,0 +1,69 @@ +/** + * @file FirmwareVersion.hpp + * @brief Strong type for the firmware release identifier string. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include +#include + +namespace core { + +/** + * @brief FirmwareVersion — validated firmware release identifier. + * + * @dname FirmwareVersion + * @param version Non-empty semantic version string (e.g. "0.1.0"). + * @return n/a (type) + * @pubstate Owns version_ (immutable after construction). No public data + * members. + * + * Wraps the release string so API endpoints never pass a bare char*. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class FirmwareVersion { +public: + /** + * @brief FirmwareVersion — construct from a non-empty version string. + * + * @dname FirmwareVersion + * @param version Non-empty semantic version (e.g. "0.1.0"). + * @pubstate writes version_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + explicit FirmwareVersion(std::string_view version); + + /** + * @brief value — read the version string. + * + * @dname value + * @return The stored version string view. + * @pubstate reads version_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::string_view value() const noexcept; + +private: + std::string version_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/HealthStatus.hpp b/Software/components/core/include/core/HealthStatus.hpp new file mode 100644 index 0000000..453cbfe --- /dev/null +++ b/Software/components/core/include/core/HealthStatus.hpp @@ -0,0 +1,98 @@ +/** + * @file HealthStatus.hpp + * @brief Health-check DTO returned by GET /api/health. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/FirmwareVersion.hpp" + +namespace core { + +/** + * @brief HealthState — coarse health indicator for the API. + * + * @dname HealthState + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class HealthState { + Ok, +}; + +/** + * @brief HealthStatus — immutable health-check response payload. + * + * @dname HealthStatus + * @param firmware Release identifier included in the response. + * @return n/a (type) + * @pubstate Owns state_ and firmware_. Factory ok() builds the nominal + * response. No public data members. + * + * Pure domain type; serialisation lives in HealthStatusJson.hpp. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class HealthStatus { +public: + /** + * @brief ok — build the nominal health response. + * + * @dname ok + * @param firmware Active firmware version to report. + * @return HealthStatus with HealthState::Ok. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static HealthStatus ok(FirmwareVersion firmware); + + /** + * @brief state — read the health indicator. + * + * @dname state + * @return Current HealthState. + * @pubstate reads state_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] HealthState state() const noexcept; + + /** + * @brief firmware — read the reported firmware version. + * + * @dname firmware + * @return The firmware version carried in this DTO. + * @pubstate reads firmware_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] const FirmwareVersion& firmware() const noexcept; + +private: + explicit HealthStatus(HealthState state, FirmwareVersion firmware); + + HealthState state_; + FirmwareVersion firmware_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/HealthStatusJson.hpp b/Software/components/core/include/core/HealthStatusJson.hpp new file mode 100644 index 0000000..874ce78 --- /dev/null +++ b/Software/components/core/include/core/HealthStatusJson.hpp @@ -0,0 +1,42 @@ +/** + * @file HealthStatusJson.hpp + * @brief JSON serialisation for HealthStatus (pure core, no ESP-IDF). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/HealthStatus.hpp" + +#include + +namespace core { + +/** + * @brief serializeHealthStatusJson — render HealthStatus as JSON. + * + * @dname serializeHealthStatusJson + * @param status Health DTO to serialise. + * @return JSON object string, e.g. {"status":"ok","fw":"0.1.0"}. + * @pubstate none + * + * Deterministic, allocation-on-stack-then-heap for the returned string. + * Used by the HTTP shell; tested on the host without hardware. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string serializeHealthStatusJson(const HealthStatus& status); + +} // namespace core diff --git a/Software/components/core/include/core/ISecureStore.hpp b/Software/components/core/include/core/ISecureStore.hpp new file mode 100644 index 0000000..bf23f8d --- /dev/null +++ b/Software/components/core/include/core/ISecureStore.hpp @@ -0,0 +1,108 @@ +/** + * @file ISecureStore.hpp + * @brief Abstract secure persistence for credentials and lists. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/StoreError.hpp" +#include "core/WifiCredentials.hpp" + +#include + +namespace core { + +/** + * @brief ISecureStore — persistence boundary for secrets at rest. + * + * @dname ISecureStore + * @return n/a (type) + * @pubstate Implementations own NVS/flash handles in the shell. The pure + * core and host tests use fakes; never touch real keys here. + * + * Slice 2 stores Wi-Fi credentials. Station list and user credentials + * arrive in later slices on the same interface. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class ISecureStore { +public: + /** + * @brief ~ISecureStore — virtual destructor for interface. + * + * @dname ~ISecureStore + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ + virtual ~ISecureStore() = default; + + /** + * @brief hasWifiCredentials — check whether STA creds are stored. + * + * @dname hasWifiCredentials + * @return true when loadWifiCredentials would succeed. + * @pubstate reads backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual bool hasWifiCredentials() const = 0; + + /** + * @brief saveWifiCredentials — persist validated STA credentials. + * + * @dname saveWifiCredentials + * @param creds Domain credentials; password stays wrapped in Secret. + * @return Ok on success, or StoreError::IoFailed. + * @pubstate writes backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + saveWifiCredentials(const WifiCredentials& creds) = 0; + + /** + * @brief loadWifiCredentials — read stored STA credentials. + * + * @dname loadWifiCredentials + * @return WifiCredentials on success, or StoreError::NotFound / + * StoreError::InvalidData / StoreError::IoFailed. + * @pubstate reads backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + loadWifiCredentials() const = 0; + + /** + * @brief clearWifiCredentials — erase stored STA credentials. + * + * @dname clearWifiCredentials + * @return Ok on success, or StoreError::IoFailed. + * @pubstate clears backing storage via implementation. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] virtual std::expected + clearWifiCredentials() = 0; +}; + +} // namespace core diff --git a/Software/components/core/include/core/ParseError.hpp b/Software/components/core/include/core/ParseError.hpp new file mode 100644 index 0000000..fc5a8d7 --- /dev/null +++ b/Software/components/core/include/core/ParseError.hpp @@ -0,0 +1,39 @@ +/** + * @file ParseError.hpp + * @brief Typed errors for untrusted JSON parsing at the boundary. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +namespace core { + +/** + * @brief ParseError — failure causes when parsing network input. + * + * @dname ParseError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class ParseError { + InvalidJson, + MissingField, + InvalidSsid, + InvalidPassword, +}; + +} // namespace core diff --git a/Software/components/core/include/core/Secret.hpp b/Software/components/core/include/core/Secret.hpp new file mode 100644 index 0000000..da1b276 --- /dev/null +++ b/Software/components/core/include/core/Secret.hpp @@ -0,0 +1,130 @@ +/** + * @file Secret.hpp + * @brief Opaque wrapper for sensitive strings (passwords, keys). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include +#include +#include +#include + +namespace core { + +/** + * @brief Secret — holds sensitive bytes, zeroised on destruction. + * + * @dname Secret + * @return n/a (type) + * @pubstate Owns bytes_ (immutable after construction except via move). + * No operator<<; never log or serialise this type to plaintext. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class Secret { +public: + /** + * @brief Secret — construct from a plaintext value at the boundary. + * + * @dname Secret + * @param value Sensitive string moved into protected storage. + * @pubstate writes bytes_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + explicit Secret(std::string value); + + Secret(const Secret&) = delete; + Secret& operator=(const Secret&) = delete; + + /** + * @brief Secret — move-construct, transferring protected storage. + * + * @dname Secret + * @param other Source secret; zeroised after the move. + * @pubstate transfers bytes_ from other. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + Secret(Secret&& other) noexcept; + + /** + * @brief operator= — move-assign, transferring protected storage. + * + * @dname operator= + * @param other Source secret; zeroised after the move. + * @return Reference to this instance. + * @pubstate transfers bytes_ from other. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + Secret& operator=(Secret&& other) noexcept; + + /** + * @brief ~Secret — zeroise stored bytes. + * + * @dname ~Secret + * @pubstate clears bytes_ securely. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + ~Secret(); + + /** + * @brief length — byte length of the protected value. + * + * @dname length + * @return Number of stored bytes. + * @pubstate reads bytes_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::size_t length() const noexcept; + + /** + * @brief usePlaintext — invoke a callable with a borrowed view. + * + * @dname usePlaintext + * @param fn Callable invoked with the secret as string_view. + * @return Whatever fn returns. + * @pubstate reads bytes_; fn must not retain the view past its scope. + * + * Shell-only escape hatch for APIs (e.g. esp_wifi_set_config) that + * require a transient C string. Never log inside fn. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + template + [[nodiscard]] auto usePlaintext(Fn&& fn) const + -> decltype(fn(std::declval())) + { + return std::forward(fn)(bytes_); + } + +private: + void zeroize() noexcept; + + std::string bytes_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/StoreError.hpp b/Software/components/core/include/core/StoreError.hpp new file mode 100644 index 0000000..aa4ccb2 --- /dev/null +++ b/Software/components/core/include/core/StoreError.hpp @@ -0,0 +1,38 @@ +/** + * @file StoreError.hpp + * @brief Typed errors for secure-store operations. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +namespace core { + +/** + * @brief StoreError — failure causes for ISecureStore operations. + * + * @dname StoreError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class StoreError { + NotFound, + IoFailed, + InvalidData, +}; + +} // namespace core diff --git a/Software/components/core/include/core/WifiCredentials.hpp b/Software/components/core/include/core/WifiCredentials.hpp new file mode 100644 index 0000000..b35807a --- /dev/null +++ b/Software/components/core/include/core/WifiCredentials.hpp @@ -0,0 +1,98 @@ +/** + * @file WifiCredentials.hpp + * @brief Domain type for stored Wi-Fi STA credentials. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/Secret.hpp" +#include "core/WifiSsid.hpp" + +namespace core { + +/** + * @brief WifiCredentials — SSID plus protected PSK for STA join. + * + * @dname WifiCredentials + * @return n/a (type) + * @pubstate Owns ssid_ and password_. Password is never exposed as a + * loggable string; use Secret::usePlaintext in the shell only. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class WifiCredentials { +public: + /** Maximum WPA-PSK length accepted at the boundary. */ + static constexpr std::size_t kMaxPasswordLength = 63; + + /** + * @brief isPasswordValid — validate a PSK length at the boundary. + * + * @dname isPasswordValid + * @param raw Untrusted password from network input. + * @return true when length is 0 (open) or 8–63 (WPA). + * @pubstate none + * + * Open networks use an empty password; WPA-PSK requires 8–63 chars. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static bool isPasswordValid(std::string_view raw) noexcept; + + /** + * @brief WifiCredentials — construct from validated domain parts. + * + * @dname WifiCredentials + * @param ssid Validated network name. + * @param password Protected pre-shared key (may be empty for open). + * @pubstate writes ssid_ and password_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + WifiCredentials(WifiSsid ssid, Secret password); + + /** + * @brief ssid — read the network name. + * + * @dname ssid + * @return The stored SSID value object. + * @pubstate reads ssid_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] const WifiSsid& ssid() const noexcept; + + /** + * @brief password — borrow the protected PSK. + * + * @dname password + * @return Const reference to the Secret wrapper. + * @pubstate reads password_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] const Secret& password() const noexcept; + +private: + WifiSsid ssid_; + Secret password_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/WifiProvisionJson.hpp b/Software/components/core/include/core/WifiProvisionJson.hpp new file mode 100644 index 0000000..f7d54aa --- /dev/null +++ b/Software/components/core/include/core/WifiProvisionJson.hpp @@ -0,0 +1,75 @@ +/** + * @file WifiProvisionJson.hpp + * @brief Parse and serialise Wi-Fi provisioning JSON at the boundary. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ParseError.hpp" +#include "core/WifiCredentials.hpp" + +#include +#include +#include + +namespace core { + +/** + * @brief parseWifiProvisionJson — validate POST body into credentials. + * + * @dname parseWifiProvisionJson + * @param json Untrusted request body, e.g. + * {"ssid":"MyNet","password":"secret"}. + * @return WifiCredentials on success, or a ParseError. + * @pubstate none + * + * Minimal parser for the Slice 2 provisioning endpoint; rejects malformed + * input before any persistence or Wi-Fi driver calls. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected +parseWifiProvisionJson(std::string_view json); + +/** + * @brief serializeWifiProvisionSavedJson — success response body. + * + * @dname serializeWifiProvisionSavedJson + * @param rebootInSec Seconds until the device reboots into STA mode. + * @return JSON object string. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string +serializeWifiProvisionSavedJson(unsigned rebootInSec); + +/** + * @brief serializeWifiProvisionErrorJson — rejection response body. + * + * @dname serializeWifiProvisionErrorJson + * @param reason Short machine-readable cause (never a secret). + * @return JSON object string. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string +serializeWifiProvisionErrorJson(std::string_view reason); + +} // namespace core diff --git a/Software/components/core/include/core/WifiSsid.hpp b/Software/components/core/include/core/WifiSsid.hpp new file mode 100644 index 0000000..09dd5ab --- /dev/null +++ b/Software/components/core/include/core/WifiSsid.hpp @@ -0,0 +1,82 @@ +/** + * @file WifiSsid.hpp + * @brief Strong type for a Wi-Fi network name (802.11 SSID). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include +#include +#include + +namespace core { + +/** + * @brief WifiSsid — validated Wi-Fi SSID (1–32 bytes). + * + * @dname WifiSsid + * @return n/a (type) + * @pubstate Owns ssid_ (immutable after construction). + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class WifiSsid { +public: + /** Maximum SSID length per 802.11. */ + static constexpr std::size_t kMaxLength = 32; + + /** + * @brief tryFrom — parse and validate an SSID at the boundary. + * + * @dname tryFrom + * @param raw Untrusted SSID string from network input. + * @return WifiSsid on success, or empty optional if invalid. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static bool isValid(std::string_view raw) noexcept; + + /** + * @brief WifiSsid — construct from an already-validated SSID. + * + * @dname WifiSsid + * @param raw Non-empty SSID up to 32 bytes. + * @pubstate writes ssid_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + explicit WifiSsid(std::string_view raw); + + /** + * @brief value — read the SSID string. + * + * @dname value + * @return Stored SSID as a string view. + * @pubstate reads ssid_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::string_view value() const noexcept; + +private: + std::string ssid_; +}; + +} // namespace core diff --git a/Software/components/core/src/FirmwareVersion.cpp b/Software/components/core/src/FirmwareVersion.cpp new file mode 100644 index 0000000..532a81b --- /dev/null +++ b/Software/components/core/src/FirmwareVersion.cpp @@ -0,0 +1,36 @@ +/** + * @file FirmwareVersion.cpp + * @brief FirmwareVersion implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/FirmwareVersion.hpp" + +#include + +namespace core { + +FirmwareVersion::FirmwareVersion(std::string_view version) + : version_(version) +{ + assert(!version.empty()); +} + +std::string_view FirmwareVersion::value() const noexcept +{ + return version_; +} + +} // namespace core diff --git a/Software/components/core/src/HealthStatus.cpp b/Software/components/core/src/HealthStatus.cpp new file mode 100644 index 0000000..0bcccfe --- /dev/null +++ b/Software/components/core/src/HealthStatus.cpp @@ -0,0 +1,44 @@ +/** + * @file HealthStatus.cpp + * @brief HealthStatus implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/HealthStatus.hpp" + +namespace core { + +HealthStatus HealthStatus::ok(FirmwareVersion firmware) +{ + return HealthStatus(HealthState::Ok, std::move(firmware)); +} + +HealthStatus::HealthStatus(HealthState state, FirmwareVersion firmware) + : state_(state) + , firmware_(std::move(firmware)) +{ +} + +HealthState HealthStatus::state() const noexcept +{ + return state_; +} + +const FirmwareVersion& HealthStatus::firmware() const noexcept +{ + return firmware_; +} + +} // namespace core diff --git a/Software/components/core/src/HealthStatusJson.cpp b/Software/components/core/src/HealthStatusJson.cpp new file mode 100644 index 0000000..bf9179f --- /dev/null +++ b/Software/components/core/src/HealthStatusJson.cpp @@ -0,0 +1,53 @@ +/** + * @file HealthStatusJson.cpp + * @brief JSON serialisation for HealthStatus. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/HealthStatusJson.hpp" + +namespace core { + +namespace { + +/** + * @brief healthStateToken — map HealthState to the API status string. + * + * @dname healthStateToken + * @param state Health indicator to encode. + * @return JSON string token without quotes (e.g. ok). + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] const char* healthStateToken(HealthState state) noexcept +{ + switch (state) { + case HealthState::Ok: + return "ok"; + } + return "unknown"; +} + +} // namespace + +std::string serializeHealthStatusJson(const HealthStatus& status) +{ + return std::string("{\"status\":\"") + healthStateToken(status.state()) + + "\",\"fw\":\"" + std::string(status.firmware().value()) + "\"}"; +} + +} // namespace core diff --git a/Software/components/core/src/Secret.cpp b/Software/components/core/src/Secret.cpp new file mode 100644 index 0000000..c202453 --- /dev/null +++ b/Software/components/core/src/Secret.cpp @@ -0,0 +1,64 @@ +/** + * @file Secret.cpp + * @brief Secret implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/Secret.hpp" + +#include + +namespace core { + +Secret::Secret(std::string value) + : bytes_(std::move(value)) +{ +} + +Secret::Secret(Secret&& other) noexcept + : bytes_(std::move(other.bytes_)) +{ + other.zeroize(); +} + +Secret& Secret::operator=(Secret&& other) noexcept +{ + if (this != &other) { + zeroize(); + bytes_ = std::move(other.bytes_); + other.zeroize(); + } + return *this; +} + +Secret::~Secret() +{ + zeroize(); +} + +std::size_t Secret::length() const noexcept +{ + return bytes_.size(); +} + +void Secret::zeroize() noexcept +{ + if (!bytes_.empty()) { + std::memset(bytes_.data(), 0, bytes_.size()); + bytes_.clear(); + } +} + +} // namespace core diff --git a/Software/components/core/src/WifiCredentials.cpp b/Software/components/core/src/WifiCredentials.cpp new file mode 100644 index 0000000..0e79dc2 --- /dev/null +++ b/Software/components/core/src/WifiCredentials.cpp @@ -0,0 +1,47 @@ +/** + * @file WifiCredentials.cpp + * @brief WifiCredentials implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/WifiCredentials.hpp" + +namespace core { + +bool WifiCredentials::isPasswordValid(std::string_view raw) noexcept +{ + if (raw.empty()) { + return true; + } + return raw.size() >= 8 && raw.size() <= kMaxPasswordLength; +} + +WifiCredentials::WifiCredentials(WifiSsid ssid, Secret password) + : ssid_(std::move(ssid)) + , password_(std::move(password)) +{ +} + +const WifiSsid& WifiCredentials::ssid() const noexcept +{ + return ssid_; +} + +const Secret& WifiCredentials::password() const noexcept +{ + return password_; +} + +} // namespace core diff --git a/Software/components/core/src/WifiProvisionJson.cpp b/Software/components/core/src/WifiProvisionJson.cpp new file mode 100644 index 0000000..f3590e0 --- /dev/null +++ b/Software/components/core/src/WifiProvisionJson.cpp @@ -0,0 +1,93 @@ +/** + * @file WifiProvisionJson.cpp + * @brief Wi-Fi provisioning JSON parse/serialise implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/WifiProvisionJson.hpp" + +namespace core { + +namespace { + +/** + * @brief extractJsonString — read a quoted string value for a key. + * + * @dname extractJsonString + * @param json Full JSON object text. + * @param key Field name without quotes (e.g. ssid). + * @return Decoded string view into json, or empty on failure. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::string_view extractJsonString(std::string_view json, + std::string_view key) +{ + const std::string needle = std::string("\"") + std::string(key) + "\":\""; + const std::size_t start = json.find(needle); + if (start == std::string_view::npos) { + return {}; + } + const std::size_t valueStart = start + needle.size(); + const std::size_t valueEnd = json.find('"', valueStart); + if (valueEnd == std::string_view::npos) { + return {}; + } + return json.substr(valueStart, valueEnd - valueStart); +} + +} // namespace + +std::expected +parseWifiProvisionJson(std::string_view json) +{ + if (json.find('{') == std::string_view::npos) { + return std::unexpected(ParseError::InvalidJson); + } + + const std::string_view ssidRaw = extractJsonString(json, "ssid"); + if (ssidRaw.empty() && json.find("\"ssid\"") == std::string_view::npos) { + return std::unexpected(ParseError::MissingField); + } + if (!WifiSsid::isValid(ssidRaw)) { + return std::unexpected(ParseError::InvalidSsid); + } + + std::string_view passwordRaw; + if (json.find("\"password\"") != std::string_view::npos) { + passwordRaw = extractJsonString(json, "password"); + } + if (!WifiCredentials::isPasswordValid(passwordRaw)) { + return std::unexpected(ParseError::InvalidPassword); + } + + return WifiCredentials(WifiSsid(ssidRaw), Secret(std::string(passwordRaw))); +} + +std::string serializeWifiProvisionSavedJson(unsigned rebootInSec) +{ + return std::string("{\"status\":\"saved\",\"reboot_in_sec\":") + + std::to_string(rebootInSec) + "}"; +} + +std::string serializeWifiProvisionErrorJson(std::string_view reason) +{ + return std::string("{\"status\":\"error\",\"reason\":\"") + std::string(reason) + + "\"}"; +} + +} // namespace core diff --git a/Software/components/core/src/WifiSsid.cpp b/Software/components/core/src/WifiSsid.cpp new file mode 100644 index 0000000..a403cd8 --- /dev/null +++ b/Software/components/core/src/WifiSsid.cpp @@ -0,0 +1,41 @@ +/** + * @file WifiSsid.cpp + * @brief WifiSsid implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/WifiSsid.hpp" + +#include + +namespace core { + +bool WifiSsid::isValid(std::string_view raw) noexcept +{ + return !raw.empty() && raw.size() <= kMaxLength; +} + +WifiSsid::WifiSsid(std::string_view raw) + : ssid_(raw) +{ + assert(isValid(raw)); +} + +std::string_view WifiSsid::value() const noexcept +{ + return ssid_; +} + +} // namespace core diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt new file mode 100644 index 0000000..74b6a6c --- /dev/null +++ b/Software/components/core/test/CMakeLists.txt @@ -0,0 +1,33 @@ +# Host unit tests for components/core (plain CMake + ctest) + +cmake_minimum_required(VERSION 3.16) +project(digiradio_core_tests LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS ON) + +enable_testing() + +set(CORE_SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../src") + +add_library(digiradio_core STATIC + "${CORE_SRC_DIR}/FirmwareVersion.cpp" + "${CORE_SRC_DIR}/HealthStatus.cpp" + "${CORE_SRC_DIR}/HealthStatusJson.cpp" + "${CORE_SRC_DIR}/Secret.cpp" + "${CORE_SRC_DIR}/WifiSsid.cpp" + "${CORE_SRC_DIR}/WifiCredentials.cpp" + "${CORE_SRC_DIR}/WifiProvisionJson.cpp" +) +target_include_directories(digiradio_core PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/../include" +) + +add_executable(health_status_test health_status_test.cpp) +target_link_libraries(health_status_test PRIVATE digiradio_core) +add_test(NAME health_status_test COMMAND health_status_test) + +add_executable(wifi_provision_test wifi_provision_test.cpp) +target_link_libraries(wifi_provision_test PRIVATE digiradio_core) +add_test(NAME wifi_provision_test COMMAND wifi_provision_test) diff --git a/Software/components/core/test/health_status_test.cpp b/Software/components/core/test/health_status_test.cpp new file mode 100644 index 0000000..a9c2071 --- /dev/null +++ b/Software/components/core/test/health_status_test.cpp @@ -0,0 +1,89 @@ +/** + * @file health_status_test.cpp + * @brief Host tests for HealthStatus JSON serialisation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/FirmwareVersion.hpp" +#include "core/HealthStatus.hpp" +#include "core/HealthStatusJson.hpp" + +#include +#include +#include + +namespace { + +/** + * @brief expectEqual — assert two strings match. + * + * @dname expectEqual + * @param actual Observed value. + * @param expected Expected value. + * @return true when equal. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] bool expectEqual(const std::string& actual, + const std::string& expected) +{ + if (actual == expected) { + return true; + } + std::cerr << "expected: " << expected << "\nactual: " << actual << '\n'; + return false; +} + +/** + * @brief runHealthStatusJsonTest — verify nominal JSON output. + * + * @dname runHealthStatusJsonTest + * @param none + * @return EXIT_SUCCESS or EXIT_FAILURE. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] int runHealthStatusJsonTest() +{ + const core::HealthStatus status = + core::HealthStatus::ok(core::FirmwareVersion("0.1.0")); + const std::string json = core::serializeHealthStatusJson(status); + if (!expectEqual(json, R"({"status":"ok","fw":"0.1.0"})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +/** + * @brief main — host test entry point. + * + * @dname main + * @param none + * @return EXIT_SUCCESS when all tests pass. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +int main() +{ + return runHealthStatusJsonTest(); +} diff --git a/Software/components/core/test/wifi_provision_test.cpp b/Software/components/core/test/wifi_provision_test.cpp new file mode 100644 index 0000000..98d134b --- /dev/null +++ b/Software/components/core/test/wifi_provision_test.cpp @@ -0,0 +1,147 @@ +/** + * @file wifi_provision_test.cpp + * @brief Host tests for Wi-Fi provisioning JSON parse/serialise. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "core/ParseError.hpp" +#include "core/WifiCredentials.hpp" +#include "core/WifiProvisionJson.hpp" + +#include +#include +#include + +namespace { + +/** + * @brief expectEqual — assert two strings match. + * + * @dname expectEqual + * @param actual Observed value. + * @param expected Expected value. + * @return true when equal. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] bool expectEqual(const std::string& actual, + const std::string& expected) +{ + if (actual == expected) { + return true; + } + std::cerr << "expected: " << expected << "\nactual: " << actual << '\n'; + return false; +} + +/** + * @brief runWifiProvisionParseTest — verify nominal JSON parsing. + * + * @dname runWifiProvisionParseTest + * @return EXIT_SUCCESS or EXIT_FAILURE. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] int runWifiProvisionParseTest() +{ + const auto parsed = core::parseWifiProvisionJson( + R"({"ssid":"MyNet","password":"secret12"})"); + if (!parsed) { + std::cerr << "parse failed\n"; + return EXIT_FAILURE; + } + if (parsed->ssid().value() != "MyNet") { + std::cerr << "unexpected ssid\n"; + return EXIT_FAILURE; + } + bool pwdOk = false; + parsed->password().usePlaintext( + [&](std::string_view pwd) { pwdOk = (pwd == "secret12"); }); + if (!pwdOk) { + std::cerr << "unexpected password\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +/** + * @brief runWifiProvisionRejectTest — verify invalid SSID is rejected. + * + * @dname runWifiProvisionRejectTest + * @return EXIT_SUCCESS or EXIT_FAILURE. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] int runWifiProvisionRejectTest() +{ + const auto parsed = + core::parseWifiProvisionJson(R"({"ssid":"","password":"secret12"})"); + if (parsed || parsed.error() != core::ParseError::InvalidSsid) { + std::cerr << "expected InvalidSsid\n"; + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +/** + * @brief runWifiProvisionSerialiseTest — verify saved response JSON. + * + * @dname runWifiProvisionSerialiseTest + * @return EXIT_SUCCESS or EXIT_FAILURE. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] int runWifiProvisionSerialiseTest() +{ + if (!expectEqual(core::serializeWifiProvisionSavedJson(3), + R"({"status":"saved","reboot_in_sec":3})")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +/** + * @brief main — host test entry point. + * + * @dname main + * @return EXIT_SUCCESS when all tests pass. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +int main() +{ + if (runWifiProvisionParseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runWifiProvisionRejectTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runWifiProvisionSerialiseTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/drivers/adau1701/CMakeLists.txt b/Software/components/drivers/adau1701/CMakeLists.txt new file mode 100644 index 0000000..6bf8c99 --- /dev/null +++ b/Software/components/drivers/adau1701/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "src/component_stub.cpp" + INCLUDE_DIRS "include" +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/drivers/adau1701/include/.gitkeep b/Software/components/drivers/adau1701/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Software/components/drivers/adau1701/src/component_stub.cpp b/Software/components/drivers/adau1701/src/component_stub.cpp new file mode 100644 index 0000000..fd373ef --- /dev/null +++ b/Software/components/drivers/adau1701/src/component_stub.cpp @@ -0,0 +1,33 @@ +/** + * @file component_stub.cpp + * @brief ADAU1701 driver component placeholder (Slice 5). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +namespace adau1701::detail { + +/** + * @brief adau1701ComponentLinked — ensures the driver component links. + * + * @dname adau1701ComponentLinked + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +void adau1701ComponentLinked() noexcept {} + +} // namespace adau1701::detail diff --git a/Software/components/drivers/bt1035/CMakeLists.txt b/Software/components/drivers/bt1035/CMakeLists.txt new file mode 100644 index 0000000..6bf8c99 --- /dev/null +++ b/Software/components/drivers/bt1035/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "src/component_stub.cpp" + INCLUDE_DIRS "include" +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/drivers/bt1035/include/.gitkeep b/Software/components/drivers/bt1035/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Software/components/drivers/bt1035/src/component_stub.cpp b/Software/components/drivers/bt1035/src/component_stub.cpp new file mode 100644 index 0000000..f69f2ef --- /dev/null +++ b/Software/components/drivers/bt1035/src/component_stub.cpp @@ -0,0 +1,34 @@ +/** + * @file component_stub.cpp + * @brief FSC-BT1035 driver component placeholder (Slice 6). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +namespace bt1035::detail { + +/** + * @brief bt1035ComponentLinked — ensures the driver component links. + * + * @dname bt1035ComponentLinked + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +void bt1035ComponentLinked() noexcept {} + +} // namespace bt1035::detail diff --git a/Software/components/drivers/si4684/CMakeLists.txt b/Software/components/drivers/si4684/CMakeLists.txt new file mode 100644 index 0000000..6bf8c99 --- /dev/null +++ b/Software/components/drivers/si4684/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "src/component_stub.cpp" + INCLUDE_DIRS "include" +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/drivers/si4684/include/.gitkeep b/Software/components/drivers/si4684/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Software/components/drivers/si4684/src/component_stub.cpp b/Software/components/drivers/si4684/src/component_stub.cpp new file mode 100644 index 0000000..11d71f5 --- /dev/null +++ b/Software/components/drivers/si4684/src/component_stub.cpp @@ -0,0 +1,33 @@ +/** + * @file component_stub.cpp + * @brief Si4684 driver component placeholder (Slice 4). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +namespace si4684::detail { + +/** + * @brief si4684ComponentLinked — ensures the driver component links. + * + * @dname si4684ComponentLinked + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +void si4684ComponentLinked() noexcept {} + +} // namespace si4684::detail diff --git a/Software/components/net/CMakeLists.txt b/Software/components/net/CMakeLists.txt new file mode 100644 index 0000000..445d250 --- /dev/null +++ b/Software/components/net/CMakeLists.txt @@ -0,0 +1,13 @@ +idf_component_register( + SRCS + "src/SoftApConfig.cpp" + "src/SoftApHost.cpp" + "src/StaClient.cpp" + "src/SetupWebServer.cpp" + "src/NetBootstrap.cpp" + INCLUDE_DIRS "include" + EMBED_FILES "www/index.html.gz" + REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/net/include/.gitkeep b/Software/components/net/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Software/components/net/include/net/NetBootstrap.hpp b/Software/components/net/include/net/NetBootstrap.hpp new file mode 100644 index 0000000..f732139 --- /dev/null +++ b/Software/components/net/include/net/NetBootstrap.hpp @@ -0,0 +1,122 @@ +/** + * @file NetBootstrap.hpp + * @brief Owns network resources for setup or STA mode for app lifetime. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ISecureStore.hpp" +#include "net/NetError.hpp" +#include "net/NetState.hpp" +#include "net/SetupWebServer.hpp" +#include "net/SoftApHost.hpp" +#include "net/StaClient.hpp" + +#include +#include + +namespace net { + +/** + * @brief NetBootstrap — brings up SoftAP or STA plus the HTTP server. + * + * @dname NetBootstrap + * @return n/a (type) + * @pubstate Owns optional softAp_, optional sta_, and webServer_. Must + * outlive app_main; keep one instance alive for process lifetime. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class NetBootstrap { +public: + /** + * @brief start — init platform and join stored Wi-Fi or open SoftAP. + * + * @dname start + * @param store Secure store consulted for saved STA credentials. + * @return NetBootstrap on success, or a NetError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static std::expected + start(core::ISecureStore& store); + + NetBootstrap(const NetBootstrap&) = delete; + NetBootstrap& operator=(const NetBootstrap&) = delete; + + /** + * @brief NetBootstrap — move-construct from a started bootstrap. + * + * @dname NetBootstrap + * @param other Source instance; left empty after the move. + * @pubstate transfers softAp_, sta_, and webServer_ from other. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + NetBootstrap(NetBootstrap&& other) noexcept = default; + + /** + * @brief operator= — move-assign from a started bootstrap. + * + * @dname operator= + * @param other Source instance; left empty after the move. + * @return Reference to this instance. + * @pubstate transfers softAp_, sta_, and webServer_ from other. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + NetBootstrap& operator=(NetBootstrap&& other) noexcept = default; + + /** + * @brief ~NetBootstrap — tear down network subsystems. + * + * @dname ~NetBootstrap + * @pubstate destroys softAp_, sta_, and webServer_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + ~NetBootstrap() = default; + + /** + * @brief state — read the active network phase. + * + * @dname state + * @return Current NetState. + * @pubstate reads state_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] NetState state() const noexcept; + +private: + NetBootstrap(std::optional softAp, + std::optional sta, + SetupWebServer webServer, + NetState state); + + std::optional softAp_; + std::optional sta_; + SetupWebServer webServer_; + NetState state_; +}; + +} // namespace net diff --git a/Software/components/net/include/net/NetError.hpp b/Software/components/net/include/net/NetError.hpp new file mode 100644 index 0000000..488cf36 --- /dev/null +++ b/Software/components/net/include/net/NetError.hpp @@ -0,0 +1,46 @@ +/** + * @file NetError.hpp + * @brief Typed errors for network bootstrap operations. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +namespace net { + +/** + * @brief NetError — failure causes for network bring-up. + * + * @dname NetError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class NetError { + NvsInitFailed, + NetifInitFailed, + EventLoopFailed, + WifiInitFailed, + WifiConfigFailed, + WifiStartFailed, + HttpServerStartFailed, + StaConnectTimeout, + StaConnectFailed, + StoreSaveFailed, + CredentialsNotFound, +}; + +} // namespace net diff --git a/Software/components/net/include/net/NetState.hpp b/Software/components/net/include/net/NetState.hpp new file mode 100644 index 0000000..d9e388f --- /dev/null +++ b/Software/components/net/include/net/NetState.hpp @@ -0,0 +1,41 @@ +/** + * @file NetState.hpp + * @brief Explicit network provisioning state machine. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +namespace net { + +/** + * @brief NetState — coarse network provisioning phase. + * + * @dname NetState + * @return n/a (type) + * @pubstate n/a + * + * Slice 1 starts in SoftApSetup; Slice 2 adds STA join after provisioning. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +enum class NetState { + Uninitialized, + SoftApSetup, + StaConnecting, + StaConnected, +}; + +} // namespace net diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp new file mode 100644 index 0000000..99d2922 --- /dev/null +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -0,0 +1,114 @@ +/** + * @file SetupWebServer.hpp + * @brief HTTP server for setup UI, health, and Wi-Fi provisioning API. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ISecureStore.hpp" +#include "net/NetError.hpp" +#include "net/NetState.hpp" + +#include + +struct httpd_handle; + +namespace net { + +/** + * @brief SetupWebServer — setup UI, health, and Wi-Fi provisioning API. + * + * @dname SetupWebServer + * @return n/a (type) + * @pubstate Owns server_ and borrows store_ while running. Routes delegate + * JSON work to the pure core. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class SetupWebServer { +public: + /** + * @brief SetupWebServer — construct an unstarted server. + * + * @dname SetupWebServer + * @pubstate clears server_, store_, and netState_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + SetupWebServer(); + + /** + * @brief ~SetupWebServer — stop the HTTP server if running. + * + * @dname ~SetupWebServer + * @pubstate stops server_ when non-null. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + ~SetupWebServer(); + + SetupWebServer(const SetupWebServer&) = delete; + SetupWebServer& operator=(const SetupWebServer&) = delete; + + /** + * @brief SetupWebServer — move-construct, transferring the handle. + * + * @dname SetupWebServer + * @param other Source server; left stopped after the move. + * @pubstate takes ownership of other.server_ and copies store pointer. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + SetupWebServer(SetupWebServer&& other) noexcept; + + /** + * @brief operator= — move-assign, transferring the handle. + * + * @dname operator= + * @param other Source server; left stopped after the move. + * @return Reference to this instance. + * @pubstate takes ownership of other.server_ and copies store pointer. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + SetupWebServer& operator=(SetupWebServer&& other) noexcept; + + /** + * @brief start — register routes and listen on port 80. + * + * @dname start + * @param store Secure store for POST /api/wifi persistence. + * @param netState Active network phase exposed to handlers. + * @return Ok on success, or NetError::HttpServerStartFailed. + * @pubstate writes server_, store_, and netState_ on success. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected start(core::ISecureStore& store, + NetState netState); + +private: + httpd_handle* server_; + core::ISecureStore* store_; + NetState netState_; +}; + +} // namespace net diff --git a/Software/components/net/include/net/SoftApConfig.hpp b/Software/components/net/include/net/SoftApConfig.hpp new file mode 100644 index 0000000..25ae669 --- /dev/null +++ b/Software/components/net/include/net/SoftApConfig.hpp @@ -0,0 +1,98 @@ +/** + * @file SoftApConfig.hpp + * @brief Configuration value type for the setup SoftAP. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include +#include + +namespace net { + +/** + * @brief SoftApConfig — immutable SoftAP parameters for first-time setup. + * + * @dname SoftApConfig + * @param ssid Broadcast SSID (non-empty). + * @param channel Wi-Fi channel (1–13). + * @param maxConnections Maximum associated stations. + * @return n/a (type) + * @pubstate Owns no handles; pure configuration snapshot. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class SoftApConfig { +public: + /** + * @brief setupDefault — factory for the Slice 1 setup SoftAP. + * + * @dname setupDefault + * @return SoftApConfig with SSID DigiRadio-setup. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] static SoftApConfig setupDefault(); + + /** + * @brief ssid — read the broadcast SSID. + * + * @dname ssid + * @return SSID string view. + * @pubstate reads ssid_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::string_view ssid() const noexcept; + + /** + * @brief channel — read the Wi-Fi channel. + * + * @dname channel + * @return Channel number. + * @pubstate reads channel_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::uint8_t channel() const noexcept; + + /** + * @brief maxConnections — read the station limit. + * + * @dname maxConnections + * @return Maximum associated clients. + * @pubstate reads maxConnections_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::uint8_t maxConnections() const noexcept; + +private: + SoftApConfig(std::string_view ssid, + std::uint8_t channel, + std::uint8_t maxConnections); + + std::string_view ssid_; + std::uint8_t channel_; + std::uint8_t maxConnections_; +}; + +} // namespace net diff --git a/Software/components/net/include/net/SoftApHost.hpp b/Software/components/net/include/net/SoftApHost.hpp new file mode 100644 index 0000000..87b37cf --- /dev/null +++ b/Software/components/net/include/net/SoftApHost.hpp @@ -0,0 +1,111 @@ +/** + * @file SoftApHost.hpp + * @brief RAII wrapper that brings up an ESP32 SoftAP. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "net/NetError.hpp" +#include "net/SoftApConfig.hpp" + +#include + +namespace net { + +/** + * @brief SoftApHost — owns SoftAP lifecycle on the ESP32 Wi-Fi stack. + * + * @dname SoftApHost + * @param config SoftAP parameters applied on start(). + * @return n/a (type) + * @pubstate Owns started_ (whether Wi-Fi AP is running). Stops AP in the + * destructor when started. + * + * Imperative shell: wraps ESP-IDF Wi-Fi calls; no business logic. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class SoftApHost { +public: + /** + * @brief SoftApHost — construct with configuration to apply on start. + * + * @dname SoftApHost + * @param config SoftAP parameters. + * @pubstate writes config_, clears started_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + explicit SoftApHost(SoftApConfig config); + + /** + * @brief ~SoftApHost — stop the SoftAP if running. + * + * @dname ~SoftApHost + * @pubstate stops AP when started_ is true. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + ~SoftApHost(); + + SoftApHost(const SoftApHost&) = delete; + SoftApHost& operator=(const SoftApHost&) = delete; + + /** + * @brief SoftApHost — move-construct, transferring started state. + * + * @dname SoftApHost + * @param other Source host; left stopped after the move. + * @pubstate transfers config_ and started_ from other. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + SoftApHost(SoftApHost&& other) noexcept; + + /** + * @brief operator= — move-assign, transferring started state. + * + * @dname operator= + * @param other Source host; left stopped after the move. + * @return Reference to this instance. + * @pubstate transfers config_ and started_ from other. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + SoftApHost& operator=(SoftApHost&& other) noexcept; + + /** + * @brief start — bring up the configured SoftAP. + * + * @dname start + * @return Ok on success, or a NetError describing the failure. + * @pubstate writes started_ on success; uses config_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected start(); + +private: + SoftApConfig config_; + bool started_; +}; + +} // namespace net diff --git a/Software/components/net/include/net/StaClient.hpp b/Software/components/net/include/net/StaClient.hpp new file mode 100644 index 0000000..18d0c08 --- /dev/null +++ b/Software/components/net/include/net/StaClient.hpp @@ -0,0 +1,109 @@ +/** + * @file StaClient.hpp + * @brief RAII helper that joins a Wi-Fi network in STA mode. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/WifiCredentials.hpp" +#include "net/NetError.hpp" + +#include + +namespace net { + +/** + * @brief StaClient — connects the ESP32 to a stored Wi-Fi network. + * + * @dname StaClient + * @return n/a (type) + * @pubstate Owns connected_ (whether STA link + IP are up). Assumes + * esp_wifi_init() was already called by NetBootstrap. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class StaClient { +public: + /** + * @brief StaClient — construct an unconnected STA client. + * + * @dname StaClient + * @pubstate clears connected_. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + StaClient(); + + /** + * @brief ~StaClient — disconnect STA if connected. + * + * @dname ~StaClient + * @pubstate stops Wi-Fi when connected_ is true. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + ~StaClient(); + + StaClient(const StaClient&) = delete; + StaClient& operator=(const StaClient&) = delete; + + /** + * @brief StaClient — move-construct, transferring connection state. + * + * @dname StaClient + * @param other Source client; left disconnected after the move. + * @pubstate transfers connected_ from other. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + StaClient(StaClient&& other) noexcept; + + /** + * @brief operator= — move-assign, transferring connection state. + * + * @dname operator= + * @param other Source client; left disconnected after the move. + * @return Reference to this instance. + * @pubstate transfers connected_ from other. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + StaClient& operator=(StaClient&& other) noexcept; + + /** + * @brief connect — join the network described by creds. + * + * @dname connect + * @param creds Validated domain credentials from ISecureStore. + * @return Ok on success, or NetError::StaConnectTimeout / + * NetError::StaConnectFailed. + * @pubstate writes connected_ on success; uses creds via Secret. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected + connect(const core::WifiCredentials& creds); + +private: + bool connected_; +}; + +} // namespace net diff --git a/Software/components/net/src/NetBootstrap.cpp b/Software/components/net/src/NetBootstrap.cpp new file mode 100644 index 0000000..79dab95 --- /dev/null +++ b/Software/components/net/src/NetBootstrap.cpp @@ -0,0 +1,198 @@ +/** + * @file NetBootstrap.cpp + * @brief NetBootstrap implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "net/NetBootstrap.hpp" + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_netif.h" +#include "esp_wifi.h" +#include "nvs_flash.h" + +namespace net { + +namespace { +constexpr char kTag[] = "NetBootstrap"; + +/** + * @brief initPlatform — one-time NVS and TCP/IP stack bring-up. + * + * @dname initPlatform + * @return Ok on success, or a NetError describing the failure. + * @pubstate initialises NVS, esp_netif, and the default event loop. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected initPlatform() +{ + esp_err_t nvsErr = nvs_flash_init(); + if (nvsErr == ESP_ERR_NVS_NO_FREE_PAGES + || nvsErr == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + nvsErr = nvs_flash_init(); + } + if (nvsErr != ESP_OK) { + ESP_LOGE(kTag, "nvs_flash_init failed"); + return std::unexpected(NetError::NvsInitFailed); + } + + if (esp_netif_init() != ESP_OK) { + ESP_LOGE(kTag, "esp_netif_init failed"); + return std::unexpected(NetError::NetifInitFailed); + } + + if (esp_event_loop_create_default() != ESP_OK) { + ESP_LOGE(kTag, "esp_event_loop_create_default failed"); + return std::unexpected(NetError::EventLoopFailed); + } + + return {}; +} + +/** + * @brief initWifiStack — initialise the Wi-Fi driver once. + * + * @dname initWifiStack + * @return Ok on success, or NetError::WifiInitFailed. + * @pubstate calls esp_wifi_init. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected initWifiStack() +{ + wifi_init_config_t initCfg = WIFI_INIT_CONFIG_DEFAULT(); + if (esp_wifi_init(&initCfg) != ESP_OK) { + ESP_LOGE(kTag, "esp_wifi_init failed"); + return std::unexpected(NetError::WifiInitFailed); + } + return {}; +} + +/** + * @brief startSetupMode — SoftAP plus HTTP for first-time provisioning. + * + * @dname startSetupMode + * @param store Store passed through to the web server routes. + * @return NetBootstrap in SoftApSetup, or a NetError. + * @pubstate creates default Wi-Fi AP netif and starts SoftAP. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected +startSetupMode(core::ISecureStore& store) +{ + esp_netif_create_default_wifi_ap(); + + SoftApHost softAp(SoftApConfig::setupDefault()); + if (auto apResult = softAp.start(); !apResult) { + return std::unexpected(apResult.error()); + } + + SetupWebServer webServer; + if (auto webResult = webServer.start(store, NetState::SoftApSetup); + !webResult) { + return std::unexpected(webResult.error()); + } + + ESP_LOGI(kTag, "setup mode ready — SSID DigiRadio-setup"); + return NetBootstrap(std::move(softAp), std::nullopt, std::move(webServer), + NetState::SoftApSetup); +} + +/** + * @brief startStaMode — join stored Wi-Fi and serve HTTP on STA. + * + * @dname startStaMode + * @param store Store supplying validated STA credentials. + * @return NetBootstrap in StaConnected, or a NetError. + * @pubstate creates default Wi-Fi STA netif and connects. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] std::expected +startStaMode(core::ISecureStore& store) +{ + auto credsResult = store.loadWifiCredentials(); + if (!credsResult) { + ESP_LOGE(kTag, "stored credentials missing"); + return std::unexpected(NetError::CredentialsNotFound); + } + + esp_netif_create_default_wifi_sta(); + + StaClient sta; + if (auto staResult = sta.connect(credsResult.value()); !staResult) { + return std::unexpected(staResult.error()); + } + + SetupWebServer webServer; + if (auto webResult = webServer.start(store, NetState::StaConnected); + !webResult) { + return std::unexpected(webResult.error()); + } + + ESP_LOGI(kTag, "STA mode ready"); + return NetBootstrap(std::nullopt, std::move(sta), std::move(webServer), + NetState::StaConnected); +} + +} // namespace + +std::expected +NetBootstrap::start(core::ISecureStore& store) +{ + if (auto platform = initPlatform(); !platform) { + return std::unexpected(platform.error()); + } + + if (auto wifi = initWifiStack(); !wifi) { + return std::unexpected(wifi.error()); + } + + if (store.hasWifiCredentials()) { + auto staResult = startStaMode(store); + if (staResult) { + return staResult; + } + ESP_LOGW(kTag, "STA join failed — falling back to setup SoftAP"); + } + + return startSetupMode(store); +} + +NetBootstrap::NetBootstrap(std::optional softAp, + std::optional sta, + SetupWebServer webServer, + NetState state) + : softAp_(std::move(softAp)) + , sta_(std::move(sta)) + , webServer_(std::move(webServer)) + , state_(state) +{ +} + +NetState NetBootstrap::state() const noexcept +{ + return state_; +} + +} // namespace net diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp new file mode 100644 index 0000000..6354e75 --- /dev/null +++ b/Software/components/net/src/SetupWebServer.cpp @@ -0,0 +1,295 @@ +/** + * @file SetupWebServer.cpp + * @brief SetupWebServer implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "net/SetupWebServer.hpp" + +#include "core/FirmwareVersion.hpp" +#include "core/HealthStatus.hpp" +#include "core/HealthStatusJson.hpp" +#include "core/ParseError.hpp" +#include "core/WifiProvisionJson.hpp" + +#include "esp_http_server.h" +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include + +namespace net { + +namespace { +constexpr char kTag[] = "SetupWebServer"; +constexpr char kFirmwareVersion[] = "0.2.0"; +constexpr unsigned kRebootDelaySec = 3; + +SetupWebServer* gActiveServer = nullptr; +core::ISecureStore* gStore = nullptr; + +extern const uint8_t www_index_html_gz_start[] asm( + "_binary_www_index_html_gz_start"); +extern const uint8_t www_index_html_gz_end[] asm( + "_binary_www_index_html_gz_end"); + +/** + * @brief rebootTask — restart after provisioning so STA mode can run. + * + * @dname rebootTask + * @param arg Unused. + * @pubstate calls esp_restart. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +void rebootTask(void* arg) +{ + (void)arg; + vTaskDelay(pdMS_TO_TICKS(kRebootDelaySec * 1000)); + esp_restart(); +} + +/** + * @brief parseErrorToken — map ParseError to a safe API reason string. + * + * @dname parseErrorToken + * @param error Parse failure from the pure core. + * @return Short reason token without secrets. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +[[nodiscard]] const char* parseErrorToken(core::ParseError error) noexcept +{ + switch (error) { + case core::ParseError::InvalidJson: + return "invalid_json"; + case core::ParseError::MissingField: + return "missing_field"; + case core::ParseError::InvalidSsid: + return "invalid_ssid"; + case core::ParseError::InvalidPassword: + return "invalid_password"; + } + return "parse_error"; +} + +/** + * @brief healthGetHandler — serve GET /api/health as JSON. + * + * @dname healthGetHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate none; serialises HealthStatus via the pure core. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +esp_err_t healthGetHandler(httpd_req_t* req) +{ + const core::HealthStatus status = + core::HealthStatus::ok(core::FirmwareVersion(kFirmwareVersion)); + const std::string json = core::serializeHealthStatusJson(status); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); +} + +/** + * @brief indexGetHandler — serve gzipped setup page from flash. + * + * @dname indexGetHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate none; reads embedded www/index.html.gz blob. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +esp_err_t indexGetHandler(httpd_req_t* req) +{ + const size_t length = + static_cast(www_index_html_gz_end - www_index_html_gz_start); + httpd_resp_set_type(req, "text/html"); + httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); + return httpd_resp_send(req, + reinterpret_cast(www_index_html_gz_start), + length); +} + +/** + * @brief wifiPostHandler — accept POST /api/wifi provisioning JSON. + * + * @dname wifiPostHandler + * @param req HTTP request handle from esp_http_server. + * @return ESP_OK on success, or an esp_err_t error code. + * @pubstate uses gActiveServer->store_ for persistence. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +esp_err_t wifiPostHandler(httpd_req_t* req) +{ + if (gStore == nullptr) { + httpd_resp_set_status(req, "500 Internal Server Error"); + return httpd_resp_send(req, nullptr, 0); + } + + std::array body{}; + int received = 0; + while (received < static_cast(body.size()) - 1) { + const int chunk = httpd_req_recv(req, body.data() + received, + body.size() - 1 - received); + if (chunk <= 0) { + break; + } + received += chunk; + } + body[static_cast(received)] = '\0'; + + const auto parsed = + core::parseWifiProvisionJson(std::string_view(body.data())); + if (!parsed) { + const std::string json = core::serializeWifiProvisionErrorJson( + parseErrorToken(parsed.error())); + httpd_resp_set_status(req, "400 Bad Request"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + if (!gStore->saveWifiCredentials(parsed.value())) { + const std::string json = + core::serializeWifiProvisionErrorJson("store_failed"); + httpd_resp_set_status(req, "500 Internal Server Error"); + httpd_resp_set_type(req, "application/json"); + return httpd_resp_send(req, json.c_str(), json.size()); + } + + const std::string json = + core::serializeWifiProvisionSavedJson(kRebootDelaySec); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, json.c_str(), json.size()); + + xTaskCreate(rebootTask, "reboot", 2048, nullptr, 5, nullptr); + return ESP_OK; +} + +} // namespace + +SetupWebServer::SetupWebServer() + : server_(nullptr) + , store_(nullptr) + , netState_(NetState::Uninitialized) +{ +} + +SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept + : server_(other.server_) + , store_(other.store_) + , netState_(other.netState_) +{ + other.server_ = nullptr; + other.store_ = nullptr; + other.netState_ = NetState::Uninitialized; + gActiveServer = this; +} + +SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept +{ + if (this != &other) { + if (server_ != nullptr) { + httpd_stop(server_); + } + server_ = other.server_; + store_ = other.store_; + netState_ = other.netState_; + other.server_ = nullptr; + other.store_ = nullptr; + other.netState_ = NetState::Uninitialized; + gActiveServer = this; + } + return *this; +} + +SetupWebServer::~SetupWebServer() +{ + if (gActiveServer == this) { + gActiveServer = nullptr; + } + if (gStore == store_) { + gStore = nullptr; + } + if (server_ != nullptr) { + httpd_stop(server_); + server_ = nullptr; + } +} + +std::expected SetupWebServer::start(core::ISecureStore& store, + NetState netState) +{ + if (server_ != nullptr) { + return {}; + } + + store_ = &store; + netState_ = netState; + gActiveServer = this; + gStore = &store; + + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.server_port = 80; + config.lru_purge_enable = true; + + if (httpd_start(&server_, &config) != ESP_OK) { + ESP_LOGE(kTag, "httpd_start failed"); + gActiveServer = nullptr; + gStore = nullptr; + return std::unexpected(NetError::HttpServerStartFailed); + } + + const httpd_uri_t healthUri = { + .uri = "/api/health", + .method = HTTP_GET, + .handler = healthGetHandler, + .user_ctx = nullptr, + }; + httpd_register_uri_handler(server_, &healthUri); + + const httpd_uri_t indexUri = { + .uri = "/", + .method = HTTP_GET, + .handler = indexGetHandler, + .user_ctx = nullptr, + }; + httpd_register_uri_handler(server_, &indexUri); + + const httpd_uri_t wifiUri = { + .uri = "/api/wifi", + .method = HTTP_POST, + .handler = wifiPostHandler, + .user_ctx = nullptr, + }; + httpd_register_uri_handler(server_, &wifiUri); + + ESP_LOGI(kTag, "HTTP server listening on port 80"); + return {}; +} + +} // namespace net diff --git a/Software/components/net/src/SoftApConfig.cpp b/Software/components/net/src/SoftApConfig.cpp new file mode 100644 index 0000000..95c11ab --- /dev/null +++ b/Software/components/net/src/SoftApConfig.cpp @@ -0,0 +1,58 @@ +/** + * @file SoftApConfig.cpp + * @brief SoftApConfig implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "net/SoftApConfig.hpp" + +namespace net { + +namespace { +constexpr std::string_view kSetupSsid = "DigiRadio-setup"; +constexpr std::uint8_t kSetupChannel = 1; +constexpr std::uint8_t kSetupMaxConnections = 4; +} // namespace + +SoftApConfig SoftApConfig::setupDefault() +{ + return SoftApConfig(kSetupSsid, kSetupChannel, kSetupMaxConnections); +} + +SoftApConfig::SoftApConfig(std::string_view ssid, + std::uint8_t channel, + std::uint8_t maxConnections) + : ssid_(ssid) + , channel_(channel) + , maxConnections_(maxConnections) +{ +} + +std::string_view SoftApConfig::ssid() const noexcept +{ + return ssid_; +} + +std::uint8_t SoftApConfig::channel() const noexcept +{ + return channel_; +} + +std::uint8_t SoftApConfig::maxConnections() const noexcept +{ + return maxConnections_; +} + +} // namespace net diff --git a/Software/components/net/src/SoftApHost.cpp b/Software/components/net/src/SoftApHost.cpp new file mode 100644 index 0000000..9691169 --- /dev/null +++ b/Software/components/net/src/SoftApHost.cpp @@ -0,0 +1,101 @@ +/** + * @file SoftApHost.cpp + * @brief SoftApHost implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "net/SoftApHost.hpp" + +#include "esp_wifi.h" +#include "esp_log.h" + +#include + +namespace net { + +namespace { +constexpr char kTag[] = "SoftApHost"; +} // namespace + +SoftApHost::SoftApHost(SoftApConfig config) + : config_(config) + , started_(false) +{ +} + +SoftApHost::~SoftApHost() +{ + if (started_) { + esp_wifi_stop(); + started_ = false; + } +} + +SoftApHost::SoftApHost(SoftApHost&& other) noexcept + : config_(other.config_) + , started_(other.started_) +{ + other.started_ = false; +} + +SoftApHost& SoftApHost::operator=(SoftApHost&& other) noexcept +{ + if (this != &other) { + if (started_) { + esp_wifi_stop(); + } + config_ = other.config_; + started_ = other.started_; + other.started_ = false; + } + return *this; +} + +std::expected SoftApHost::start() +{ + if (started_) { + return {}; + } + + if (esp_wifi_set_mode(WIFI_MODE_AP) != ESP_OK) { + ESP_LOGE(kTag, "esp_wifi_set_mode failed"); + return std::unexpected(NetError::WifiConfigFailed); + } + + wifi_config_t wifiCfg = {}; + const std::string_view ssid = config_.ssid(); + std::memcpy(wifiCfg.ap.ssid, ssid.data(), ssid.size()); + wifiCfg.ap.ssid_len = static_cast(ssid.size()); + wifiCfg.ap.channel = config_.channel(); + wifiCfg.ap.max_connection = config_.maxConnections(); + wifiCfg.ap.authmode = WIFI_AUTH_OPEN; + + if (esp_wifi_set_config(WIFI_IF_AP, &wifiCfg) != ESP_OK) { + ESP_LOGE(kTag, "esp_wifi_set_config failed"); + return std::unexpected(NetError::WifiConfigFailed); + } + + if (esp_wifi_start() != ESP_OK) { + ESP_LOGE(kTag, "esp_wifi_start failed"); + return std::unexpected(NetError::WifiStartFailed); + } + + started_ = true; + ESP_LOGI(kTag, "SoftAP started: %.*s", static_cast(ssid.size()), + ssid.data()); + return {}; +} + +} // namespace net diff --git a/Software/components/net/src/StaClient.cpp b/Software/components/net/src/StaClient.cpp new file mode 100644 index 0000000..301a7e4 --- /dev/null +++ b/Software/components/net/src/StaClient.cpp @@ -0,0 +1,183 @@ +/** + * @file StaClient.cpp + * @brief StaClient implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "net/StaClient.hpp" + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" + +#include +#include + +namespace net { + +namespace { +constexpr char kTag[] = "StaClient"; +constexpr int kConnectedBit = BIT0; +constexpr int kFailedBit = BIT1; +constexpr TickType_t kConnectTimeout = pdMS_TO_TICKS(30000); + +EventGroupHandle_t s_wifiEventGroup = nullptr; + +/** + * @brief wifiEventHandler — signal connect success or failure. + * + * @dname wifiEventHandler + * @param arg Unused. + * @param eventBase Event base identifier. + * @param eventId Specific event id. + * @param eventData Event payload. + * @pubstate sets bits on s_wifiEventGroup. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +void wifiEventHandler(void* arg, + esp_event_base_t eventBase, + int32_t eventId, + void* eventData) +{ + (void)arg; + (void)eventData; + if (eventBase == WIFI_EVENT && eventId == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (eventBase == WIFI_EVENT + && eventId == WIFI_EVENT_STA_DISCONNECTED) { + if (s_wifiEventGroup != nullptr) { + xEventGroupSetBits(s_wifiEventGroup, kFailedBit); + } + } else if (eventBase == IP_EVENT && eventId == IP_EVENT_STA_GOT_IP) { + if (s_wifiEventGroup != nullptr) { + xEventGroupSetBits(s_wifiEventGroup, kConnectedBit); + } + } +} +} // namespace + +StaClient::StaClient() + : connected_(false) +{ +} + +StaClient::~StaClient() +{ + if (connected_) { + esp_wifi_stop(); + connected_ = false; + } +} + +StaClient::StaClient(StaClient&& other) noexcept + : connected_(other.connected_) +{ + other.connected_ = false; +} + +StaClient& StaClient::operator=(StaClient&& other) noexcept +{ + if (this != &other) { + if (connected_) { + esp_wifi_stop(); + } + connected_ = other.connected_; + other.connected_ = false; + } + return *this; +} + +std::expected +StaClient::connect(const core::WifiCredentials& creds) +{ + if (connected_) { + return {}; + } + + s_wifiEventGroup = xEventGroupCreate(); + if (s_wifiEventGroup == nullptr) { + return std::unexpected(NetError::StaConnectFailed); + } + + esp_event_handler_instance_t instanceAnyId = nullptr; + esp_event_handler_instance_t instanceGotIp = nullptr; + esp_event_handler_instance_register(WIFI_EVENT, + ESP_EVENT_ANY_ID, + &wifiEventHandler, + nullptr, + &instanceAnyId); + esp_event_handler_instance_register(IP_EVENT, + IP_EVENT_STA_GOT_IP, + &wifiEventHandler, + nullptr, + &instanceGotIp); + + if (esp_wifi_set_mode(WIFI_MODE_STA) != ESP_OK) { + return std::unexpected(NetError::WifiConfigFailed); + } + + wifi_config_t wifiCfg = {}; + const std::string_view ssid = creds.ssid().value(); + const std::size_t ssidCopy = + std::min(ssid.size(), sizeof(wifiCfg.sta.ssid) - 1); + std::memcpy(wifiCfg.sta.ssid, ssid.data(), ssidCopy); + + creds.password().usePlaintext([&](std::string_view pwd) { + const std::size_t pwdCopy = + std::min(pwd.size(), sizeof(wifiCfg.sta.password) - 1); + std::memcpy(wifiCfg.sta.password, pwd.data(), pwdCopy); + }); + + if (esp_wifi_set_config(WIFI_IF_STA, &wifiCfg) != ESP_OK) { + return std::unexpected(NetError::WifiConfigFailed); + } + + if (esp_wifi_start() != ESP_OK) { + return std::unexpected(NetError::WifiStartFailed); + } + + const EventBits_t bits = xEventGroupWaitBits(s_wifiEventGroup, + kConnectedBit | kFailedBit, + pdTRUE, + pdFALSE, + kConnectTimeout); + + esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, + instanceGotIp); + esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, + instanceAnyId); + vEventGroupDelete(s_wifiEventGroup); + s_wifiEventGroup = nullptr; + + if ((bits & kConnectedBit) != 0) { + connected_ = true; + ESP_LOGI(kTag, "connected to %.*s", + static_cast(ssid.size()), ssid.data()); + return {}; + } + + esp_wifi_stop(); + ESP_LOGW(kTag, "STA connect timed out or failed"); + if ((bits & kFailedBit) != 0) { + return std::unexpected(NetError::StaConnectFailed); + } + return std::unexpected(NetError::StaConnectTimeout); +} + +} // namespace net diff --git a/Software/components/net/www/index.html b/Software/components/net/www/index.html new file mode 100644 index 0000000..9c7e85c --- /dev/null +++ b/Software/components/net/www/index.html @@ -0,0 +1,162 @@ + + + + + + DigiRadio Setup + + + +
+

DigiRadio

+

Connect this radio to your Wi‑Fi network.

+
+ + Checking health… +
+
+ + + + + +
+

+
+ + + diff --git a/Software/components/net/www/index.html.gz b/Software/components/net/www/index.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..5cdd88a6e939dbc6a5a52c136e3f460be629d8bb GIT binary patch literal 1730 zcmV;z20i&7iwFq6R7+|A18Ht#Wq2-VbZu+^rC3>V+eQ%m*C{$s#U^6{+_ael`4LGj zJLT9eQz=)zTti?$Oa#mzg8?Z?$a}XX~q#j?fnt-tW!zD@y5kq1pOW4A(D6UpNwFzaiQp)GUp_wo2t@_ zV4+nFc8J^s`sTcOLmUTlGlFC1SB(1j(c9e-#@AOlt>S~!ka$nyMsZ~0+Q(k zCDg6RVWOyu^yh^1$XP{~nAarUPLe0IGx0iBG&B>|0h2^gK+TNO0b@oNg2FGaj1I(v z$7W0=^D_B|^xq%g9_HfCjM*QIV+?a4T`El^jUgm*!8lqSLquFRy~lGVI2Z1^tfThB z^hn5p^$3;kvvDc9B4{R;v0_D35^n?dk&|gbqTP_(H*bT*V*u71 zUY0AiaE%ew+NYSZP3lI~PHPkAaIJE)VZ3NgnzCyP-_wz`IZhg5KY!M1DlKtaO`79# zXFfa`H14UCH3oWmK0F^5432PV^TjJXdtQtCP)k*>sMV(-{yRL~o3UPfDbWtt-F%I* zyV+)E>nAwd2oOK<`b#}gw1s{zcN7-&CGzA?=`w=w^`;6uC^KqMok&s>-CiJ@ddjVZd1 zTn&-wrB9t(ObBEOzkL3y&SiJee17zV3{%r9<}p+Ms;p<@A8F`h%v@voCW|B8Y@s!q zy}D}K-IS}8$>z0In2O{}aCRn%5{?LQk5Whzo@aohzb8y$U*pRfP`<#u*cc9Wx)5O& z&^o&DEKzC9|fa0(l{Tfotd9myhm~C4OBbG03etr#;QVQVd7KjVPW>B4_@E?J#r@-_nJgdpN#Uy_QhTTmW z;Ge(Y#fO~Rzzf&%R<-+Fw^1~I6`VtKo~r1+EjT^rFSAV*CSbfw>GIVMxeM=zQz;KGpKy~aL#Y!2|EJE5Y8W?&Xj`TjUFP%qP3ULYYodf zN0@`ENcd6h3!MJOySK9&qf^rL(54b0bPq;u+PuLoHIP+*wK1$bCb6sWZooq&sK@*W zeww{~gE*7uCw`8!?rZqdV{3FEnR=?VttAN(+Np^Z)8&9@Ie>EUQo6)zd3anK@9N!x z2Y0615psrr&~K{x&Qpa~k+`vdoIFD2@C{>c;2r?NRG6Re^J7Dm&M*@p=16?jkQ zLMZ)2;WwP_<4mPY$sZa27*j{LbzH~Jegt8J4Xy{EK}^5y&9#(zA<+)B3J(Y-6h3_d zBjLAPtZKb-z&`Fb8*Xkiibv3)hKt)Z|H_ow8NqveL20bEhR~xuJ?!U0u|1}Lak}O& Y4?b+^l7y>J`oCoV0_*gA5;hY60Ais|ZU6uP literal 0 HcmV?d00001 diff --git a/Software/components/secure_store/CMakeLists.txt b/Software/components/secure_store/CMakeLists.txt new file mode 100644 index 0000000..bcb8ab6 --- /dev/null +++ b/Software/components/secure_store/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "src/NvsSecureStore.cpp" + INCLUDE_DIRS "include" + REQUIRES core nvs_flash +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/secure_store/include/.gitkeep b/Software/components/secure_store/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp b/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp new file mode 100644 index 0000000..4b3e761 --- /dev/null +++ b/Software/components/secure_store/include/secure_store/NvsSecureStore.hpp @@ -0,0 +1,105 @@ +/** + * @file NvsSecureStore.hpp + * @brief NVS-backed ISecureStore for Wi-Fi credentials (Slice 2). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Production builds should enable NVS encryption (nvs_keys partition in + * partitions.csv) per ESP-IDF security docs; this slice uses plain NVS + * for development bring-up. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +#pragma once + +#include "core/ISecureStore.hpp" + +namespace secure_store { + +/** + * @brief NvsSecureStore — persists credentials in an NVS namespace. + * + * @dname NvsSecureStore + * @return n/a (type) + * @pubstate Opens namespace digiradio on each operation. Passwords are + * stored as NVS strings and never logged. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +class NvsSecureStore final : public core::ISecureStore { +public: + /** + * @brief NvsSecureStore — default-construct the store accessor. + * + * @dname NvsSecureStore + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ + NvsSecureStore() = default; + + /** + * @brief hasWifiCredentials — check whether STA creds are stored. + * + * @dname hasWifiCredentials + * @return true when SSID key exists in NVS. + * @pubstate reads NVS namespace digiradio. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] bool hasWifiCredentials() const override; + + /** + * @brief saveWifiCredentials — persist validated STA credentials. + * + * @dname saveWifiCredentials + * @param creds Validated credentials to persist. + * @return Ok on success, or StoreError::IoFailed. + * @pubstate writes NVS keys wifi_ssid and wifi_pwd. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected + saveWifiCredentials(const core::WifiCredentials& creds) override; + + /** + * @brief loadWifiCredentials — read stored STA credentials. + * + * @dname loadWifiCredentials + * @return WifiCredentials on success, or a StoreError. + * @pubstate reads NVS namespace digiradio. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected + loadWifiCredentials() const override; + + /** + * @brief clearWifiCredentials — erase stored STA credentials. + * + * @dname clearWifiCredentials + * @return Ok on success, or StoreError::IoFailed. + * @pubstate erases wifi_ssid and wifi_pwd from NVS. + * + * @author Michele Bigi + * @date 2026-07-06 + */ + [[nodiscard]] std::expected + clearWifiCredentials() override; +}; + +} // namespace secure_store diff --git a/Software/components/secure_store/src/NvsSecureStore.cpp b/Software/components/secure_store/src/NvsSecureStore.cpp new file mode 100644 index 0000000..21ed219 --- /dev/null +++ b/Software/components/secure_store/src/NvsSecureStore.cpp @@ -0,0 +1,151 @@ +/** + * @file NvsSecureStore.cpp + * @brief NvsSecureStore implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "secure_store/NvsSecureStore.hpp" + +#include "nvs.h" +#include "nvs_flash.h" + +#include +#include + +namespace secure_store { + +namespace { +constexpr char kNamespace[] = "digiradio"; +constexpr char kSsidKey[] = "wifi_ssid"; +constexpr char kPasswordKey[] = "wifi_pwd"; +} // namespace + +bool NvsSecureStore::hasWifiCredentials() const +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) { + return false; + } + + std::size_t ssidLen = 0; + const esp_err_t ssidErr = + nvs_get_str(handle, kSsidKey, nullptr, &ssidLen); + nvs_close(handle); + + return ssidErr == ESP_OK && ssidLen > 1; +} + +std::expected +NvsSecureStore::saveWifiCredentials(const core::WifiCredentials& creds) +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + + const std::string ssid(creds.ssid().value()); + std::string password; + creds.password().usePlaintext( + [&](std::string_view pwd) { password.assign(pwd); }); + + esp_err_t err = nvs_set_str(handle, kSsidKey, ssid.c_str()); + if (err == ESP_OK) { + err = nvs_set_str(handle, kPasswordKey, password.c_str()); + } + if (err == ESP_OK) { + err = nvs_commit(handle); + } + nvs_close(handle); + + for (char& ch : password) { + ch = '\0'; + } + + if (err != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + return {}; +} + +std::expected +NvsSecureStore::loadWifiCredentials() const +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READONLY, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::NotFound); + } + + std::size_t ssidLen = 0; + if (nvs_get_str(handle, kSsidKey, nullptr, &ssidLen) != ESP_OK + || ssidLen == 0) { + nvs_close(handle); + return std::unexpected(core::StoreError::NotFound); + } + + std::vector ssidBuf(ssidLen); + std::size_t pwdLen = 0; + if (nvs_get_str(handle, kSsidKey, ssidBuf.data(), &ssidLen) != ESP_OK) { + nvs_close(handle); + return std::unexpected(core::StoreError::IoFailed); + } + + if (nvs_get_str(handle, kPasswordKey, nullptr, &pwdLen) != ESP_OK) { + pwdLen = 0; + } + + std::string password; + if (pwdLen > 0) { + std::vector pwdBuf(pwdLen); + if (nvs_get_str(handle, kPasswordKey, pwdBuf.data(), &pwdLen) != ESP_OK) { + nvs_close(handle); + return std::unexpected(core::StoreError::IoFailed); + } + password.assign(pwdBuf.data()); + } + + nvs_close(handle); + + const std::string_view ssidView(ssidBuf.data()); + if (!core::WifiSsid::isValid(ssidView)) { + return std::unexpected(core::StoreError::InvalidData); + } + + return core::WifiCredentials(core::WifiSsid(ssidView), + core::Secret(std::move(password))); +} + +std::expected NvsSecureStore::clearWifiCredentials() +{ + nvs_handle_t handle = 0; + if (nvs_open(kNamespace, NVS_READWRITE, &handle) != ESP_OK) { + return std::unexpected(core::StoreError::IoFailed); + } + + esp_err_t err = nvs_erase_key(handle, kSsidKey); + if (err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND) { + err = nvs_erase_key(handle, kPasswordKey); + } + if (err == ESP_OK || err == ESP_ERR_NVS_NOT_FOUND) { + err = nvs_commit(handle); + } + nvs_close(handle); + + if (err != ESP_OK && err != ESP_ERR_NVS_NOT_FOUND) { + return std::unexpected(core::StoreError::IoFailed); + } + return {}; +} + +} // namespace secure_store diff --git a/Software/components/services/CMakeLists.txt b/Software/components/services/CMakeLists.txt new file mode 100644 index 0000000..6bf8c99 --- /dev/null +++ b/Software/components/services/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "src/component_stub.cpp" + INCLUDE_DIRS "include" +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/services/include/.gitkeep b/Software/components/services/include/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Software/components/services/src/component_stub.cpp b/Software/components/services/src/component_stub.cpp new file mode 100644 index 0000000..6083844 --- /dev/null +++ b/Software/components/services/src/component_stub.cpp @@ -0,0 +1,33 @@ +/** + * @file component_stub.cpp + * @brief Application services placeholder (Slice 7). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +namespace services::detail { + +/** + * @brief servicesComponentLinked — ensures the services component links. + * + * @dname servicesComponentLinked + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-06 + */ +void servicesComponentLinked() noexcept {} + +} // namespace services::detail diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index a97204a..6994723 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -20,16 +20,79 @@ section appears below, grouped by layer: domain core, application services, and hardware drivers. % ------------------------------------------------------------------ -% Per-class sections are added here as classes are implemented, e.g.: -% -% \section{Si4684Driver}\label{cls:Si4684Driver} -% Responsibility, collaborators, invariants, usage. -% -% \section{Adau1701Driver}\label{cls:Adau1701Driver} -% \section{Bt1035Driver}\label{cls:Bt1035Driver} -% \section{TunerService}\label{cls:TunerService} -% ... +% Domain core (Slice 1) % ------------------------------------------------------------------ -\section*{(No public classes documented yet)} -Sections will appear here as the firmware is implemented. +\section{FirmwareVersion}\label{cls:FirmwareVersion} +Strong type wrapping the firmware release identifier (e.g.\ \texttt{0.1.0}). +Used by \texttt{HealthStatus} and the \texttt{/api/health} endpoint so +version strings are never passed as bare \texttt{char*} across module +boundaries. Invariant: non-empty at construction. + +\section{HealthStatus}\label{cls:HealthStatus} +Immutable health-check DTO returned by \texttt{GET /api/health}. Built via +\texttt{HealthStatus::ok(FirmwareVersion)} in the pure core; JSON +serialisation lives in \texttt{serializeHealthStatusJson()}. No ESP-IDF +headers. + +% ------------------------------------------------------------------ +% Network shell (Slice 1) +% ------------------------------------------------------------------ + +\section{SoftApConfig}\label{cls:SoftApConfig} +Immutable value type holding SoftAP parameters (SSID, channel, station +limit). Factory \texttt{setupDefault()} yields SSID \texttt{DigiRadio-setup} +for first-time provisioning. + +\section{SoftApHost}\label{cls:SoftApHost} +RAII wrapper around the ESP-IDF Wi-Fi stack that starts and stops the +configured SoftAP. Imperative shell; no business logic. + +\section{SetupWebServer}\label{cls:SetupWebServer} +Minimal HTTP server: gzipped setup UI, \texttt{GET /api/health}, and +\texttt{POST /api/wifi} for provisioning. JSON parsing and serialisation +delegate to the pure core; credentials persist via \texttt{ISecureStore}. + +\section{NetBootstrap}\label{cls:NetBootstrap} +Owns network resources for setup or STA mode. \texttt{start(store)} +initialises the platform, joins stored Wi-Fi when credentials exist, or +falls back to the \texttt{DigiRadio-setup} SoftAP. Must outlive +\texttt{app\_main} for the process lifetime. + +% ------------------------------------------------------------------ +% Domain core + secure store (Slice 2) +% ------------------------------------------------------------------ + +\section{Secret}\label{cls:Secret} +Opaque wrapper for sensitive strings. Buffer is zeroised on destruction; +no \texttt{operator<<} and no plaintext serialisation. Shell code uses +\texttt{usePlaintext()} transiently for driver APIs only. + +\section{WifiSsid}\label{cls:WifiSsid} +Validated Wi-Fi SSID (1--32 bytes). Parsed once from untrusted JSON at +the HTTP boundary before persistence or driver calls. + +\section{WifiCredentials}\label{cls:WifiCredentials} +Domain value pairing \texttt{WifiSsid} with a \texttt{Secret} password. +Open networks use an empty password; WPA-PSK requires 8--63 characters. + +\section{ISecureStore}\label{cls:ISecureStore} +Abstract persistence boundary for credentials at rest. Slice~2 implements +Wi-Fi credential save/load/clear; station list and user credentials arrive +in later slices. Host tests use fakes; the shell uses \texttt{NvsSecureStore}. + +\section{StaClient}\label{cls:StaClient} +RAII STA join helper with an explicit connect timeout. Assumes +\texttt{esp\_wifi\_init()} was already called by \texttt{NetBootstrap}. + +\section{NvsSecureStore}\label{cls:NvsSecureStore} +\texttt{ISecureStore} implementation backed by an NVS namespace. Passwords +are stored as NVS strings and never logged. Production should enable NVS +encryption using the reserved \texttt{nvs\_keys} partition. + +% ------------------------------------------------------------------ +% Future slices (drivers, services) +% ------------------------------------------------------------------ +% \section{Si4684Driver}\label{cls:Si4684Driver} +% \section{TunerService}\label{cls:TunerService} +% ... diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt new file mode 100644 index 0000000..caf7fbe --- /dev/null +++ b/Software/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "main.cpp" + INCLUDE_DIRS "." + REQUIRES core net secure_store +) diff --git a/Software/main/main.cpp b/Software/main/main.cpp new file mode 100644 index 0000000..5aec7bc --- /dev/null +++ b/Software/main/main.cpp @@ -0,0 +1,91 @@ +/** + * @file main.cpp + * @brief DigiRadio imperative shell entry point (app_main). + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * @author Michele Bigi + * @date 2026-07-06 + */ + +#include "net/NetBootstrap.hpp" +#include "secure_store/NvsSecureStore.hpp" + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +namespace { + +constexpr char kTag[] = "digiradio"; +constexpr TickType_t kHeartbeatPeriod = pdMS_TO_TICKS(5000); + +/** + * @brief heartbeatTask — periodic alive log for bring-up verification. + * + * @dname heartbeatTask + * @param arg Unused task parameter. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-06 + */ +void heartbeatTask(void* arg) +{ + (void)arg; + while (true) { + ESP_LOGI(kTag, "heartbeat"); + vTaskDelay(kHeartbeatPeriod); + } +} + +} // namespace + +/** + * @brief app_main — ESP-IDF application entry point. + * + * @dname app_main + * @pubstate starts NvsSecureStore, NetBootstrap, and the heartbeat task. + * + * Slice 2: joins a stored Wi-Fi network when credentials exist, otherwise + * opens the DigiRadio-setup SoftAP for provisioning via the web UI. + * + * @author Michele Bigi + * @date 2026-07-06 + */ +extern "C" void app_main() +{ + ESP_LOGI(kTag, "DigiRadio firmware boot — Slice 2"); + + static secure_store::NvsSecureStore store; + + auto netResult = net::NetBootstrap::start(store); + if (!netResult) { + ESP_LOGE(kTag, "network bootstrap failed"); + return; + } + + static net::NetBootstrap net = std::move(netResult.value()); + + if (xTaskCreate(heartbeatTask, "heartbeat", 2048, nullptr, 5, nullptr) + != pdPASS) { + ESP_LOGE(kTag, "heartbeat task create failed"); + return; + } + + if (net.state() == net::NetState::StaConnected) { + ESP_LOGI(kTag, "running in STA mode — open http:///"); + } else { + ESP_LOGI(kTag, + "running in setup mode — join DigiRadio-setup, " + "open http://192.168.4.1/"); + } +} diff --git a/Software/partitions.csv b/Software/partitions.csv new file mode 100644 index 0000000..a933b46 --- /dev/null +++ b/Software/partitions.csv @@ -0,0 +1,7 @@ +# DigiRadio partition table +# nvs_keys supports NVS encryption (enable in secure-store slice). +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +nvs_keys, data, nvs_keys, 0xf000, 0x1000, +phy_init, data, phy, 0x10000, 0x1000, +factory, app, factory, 0x20000, 0x180000, diff --git a/Software/sdkconfig.defaults b/Software/sdkconfig.defaults new file mode 100644 index 0000000..691716c --- /dev/null +++ b/Software/sdkconfig.defaults @@ -0,0 +1,13 @@ +# DigiRadio — default Kconfig +# NVS encryption: enable CONFIG_NVS_ENCRYPTION and flash encryption for +# production; nvs_keys partition is reserved in partitions.csv. + +CONFIG_IDF_TARGET="esp32s3" + +# C++23, exceptions and RTTI off (AGENTS.md §2) +CONFIG_COMPILER_CXX_EXCEPTIONS=n +CONFIG_COMPILER_CXX_RTTI=n +CONFIG_COMPILER_CXX_STD=23 + +# Logging +CONFIG_LOG_DEFAULT_LEVEL_INFO=y