Add firmware slices 1–2: skeleton and Wi-Fi provisioning
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
+4
-8
@@ -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
|
||||
|
||||
@@ -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 <port> 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).
|
||||
@@ -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)
|
||||
@@ -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 <string>
|
||||
#include <string_view>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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 <string>
|
||||
|
||||
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
|
||||
@@ -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 <expected>
|
||||
|
||||
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<void, StoreError>
|
||||
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<WifiCredentials, StoreError>
|
||||
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<void, StoreError>
|
||||
clearWifiCredentials() = 0;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -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
|
||||
@@ -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 <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
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<typename Fn>
|
||||
[[nodiscard]] auto usePlaintext(Fn&& fn) const
|
||||
-> decltype(fn(std::declval<std::string_view>()))
|
||||
{
|
||||
return std::forward<Fn>(fn)(bytes_);
|
||||
}
|
||||
|
||||
private:
|
||||
void zeroize() noexcept;
|
||||
|
||||
std::string bytes_;
|
||||
};
|
||||
|
||||
} // namespace core
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <expected>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
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<WifiCredentials, ParseError>
|
||||
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
|
||||
@@ -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 <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
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
|
||||
@@ -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 <cassert>
|
||||
|
||||
namespace core {
|
||||
|
||||
FirmwareVersion::FirmwareVersion(std::string_view version)
|
||||
: version_(version)
|
||||
{
|
||||
assert(!version.empty());
|
||||
}
|
||||
|
||||
std::string_view FirmwareVersion::value() const noexcept
|
||||
{
|
||||
return version_;
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <cstring>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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<WifiCredentials, ParseError>
|
||||
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
|
||||
@@ -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 <cassert>
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
@@ -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 <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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 <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
idf_component_register(
|
||||
SRCS "src/component_stub.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
idf_component_register(
|
||||
SRCS "src/component_stub.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
idf_component_register(
|
||||
SRCS "src/component_stub.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 <expected>
|
||||
#include <optional>
|
||||
|
||||
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<NetBootstrap, NetError>
|
||||
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<SoftApHost> softAp,
|
||||
std::optional<StaClient> sta,
|
||||
SetupWebServer webServer,
|
||||
NetState state);
|
||||
|
||||
std::optional<SoftApHost> softAp_;
|
||||
std::optional<StaClient> sta_;
|
||||
SetupWebServer webServer_;
|
||||
NetState state_;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 <expected>
|
||||
|
||||
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<void, NetError> start(core::ISecureStore& store,
|
||||
NetState netState);
|
||||
|
||||
private:
|
||||
httpd_handle* server_;
|
||||
core::ISecureStore* store_;
|
||||
NetState netState_;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
@@ -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 <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
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
|
||||
@@ -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 <expected>
|
||||
|
||||
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<void, NetError> start();
|
||||
|
||||
private:
|
||||
SoftApConfig config_;
|
||||
bool started_;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
@@ -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 <expected>
|
||||
|
||||
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<void, NetError>
|
||||
connect(const core::WifiCredentials& creds);
|
||||
|
||||
private:
|
||||
bool connected_;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
@@ -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<void, NetError> 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<void, NetError> 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<NetBootstrap, NetError>
|
||||
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<NetBootstrap, NetError>
|
||||
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, NetError>
|
||||
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<SoftApHost> softAp,
|
||||
std::optional<StaClient> 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
|
||||
@@ -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 <array>
|
||||
#include <string>
|
||||
|
||||
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<size_t>(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<const char*>(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<char, 512> body{};
|
||||
int received = 0;
|
||||
while (received < static_cast<int>(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<std::size_t>(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<void, NetError> 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
|
||||
@@ -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
|
||||
@@ -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 <cstring>
|
||||
|
||||
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<void, NetError> 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<int>(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<int>(ssid.size()),
|
||||
ssid.data());
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace net
|
||||
@@ -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 <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
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<void, NetError>
|
||||
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<int>(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
|
||||
@@ -0,0 +1,162 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DigiRadio Setup</title>
|
||||
<style>
|
||||
:root {
|
||||
--space-1: 0.5rem;
|
||||
--space-2: 1rem;
|
||||
--space-3: 1.5rem;
|
||||
--space-4: 2.5rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.25rem;
|
||||
--text-xl: 1.75rem;
|
||||
--accent: #c47a2c;
|
||||
--bg: #0f1114;
|
||||
--surface: #1a1d22;
|
||||
--text: #e8eaed;
|
||||
--muted: #9aa0a6;
|
||||
--radius: 0.5rem;
|
||||
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: var(--text-base);
|
||||
line-height: 1.5;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
main {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
max-width: 28rem;
|
||||
width: 100%;
|
||||
border-top: 3px solid var(--accent);
|
||||
}
|
||||
h1 { font-size: var(--text-xl); font-weight: 600; margin-bottom: var(--space-2); }
|
||||
p { color: var(--muted); margin-bottom: var(--space-3); }
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
border: 1px solid #333;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: var(--space-2);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: 0.6; cursor: wait; }
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
font-size: var(--text-lg);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.msg { font-size: 0.875rem; margin-top: var(--space-2); min-height: 1.25rem; }
|
||||
.msg.ok { color: #7cb342; }
|
||||
.msg.err { color: #e57373; }
|
||||
code {
|
||||
font-size: 0.9em;
|
||||
background: var(--bg);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>DigiRadio</h1>
|
||||
<p>Connect this radio to your Wi‑Fi network.</p>
|
||||
<div class="status" id="health">
|
||||
<span class="dot" aria-hidden="true"></span>
|
||||
<span>Checking health…</span>
|
||||
</div>
|
||||
<form id="wifi-form">
|
||||
<label for="ssid">Network name (SSID)</label>
|
||||
<input id="ssid" name="ssid" autocomplete="off" required maxlength="32">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="off" maxlength="63">
|
||||
<button type="submit" id="save-btn">Save & connect</button>
|
||||
</form>
|
||||
<p class="msg" id="msg" aria-live="polite"></p>
|
||||
</main>
|
||||
<script>
|
||||
fetch("/api/health")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
document.getElementById("health").innerHTML =
|
||||
'<span class="dot"></span><span>Status: <code>' +
|
||||
d.status + "</code> · FW <code>" + d.fw + "</code></span>";
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById("health").textContent = "Health check failed.";
|
||||
});
|
||||
|
||||
document.getElementById("wifi-form").addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById("save-btn");
|
||||
var msg = document.getElementById("msg");
|
||||
btn.disabled = true;
|
||||
msg.textContent = "Saving…";
|
||||
msg.className = "msg";
|
||||
fetch("/api/wifi", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ssid: document.getElementById("ssid").value,
|
||||
password: document.getElementById("password").value
|
||||
})
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (res) {
|
||||
if (res.ok && res.d.status === "saved") {
|
||||
msg.textContent = "Saved. Rebooting in " + res.d.reboot_in_sec + "s…";
|
||||
msg.className = "msg ok";
|
||||
} else {
|
||||
msg.textContent = "Error: " + (res.d.reason || "unknown");
|
||||
msg.className = "msg err";
|
||||
btn.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
msg.textContent = "Request failed.";
|
||||
msg.className = "msg err";
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -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)
|
||||
@@ -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<void, core::StoreError>
|
||||
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<core::WifiCredentials, core::StoreError>
|
||||
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<void, core::StoreError>
|
||||
clearWifiCredentials() override;
|
||||
};
|
||||
|
||||
} // namespace secure_store
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
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<void, core::StoreError>
|
||||
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<core::WifiCredentials, core::StoreError>
|
||||
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<char> 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<char> 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<void, core::StoreError> 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
|
||||
@@ -0,0 +1,6 @@
|
||||
idf_component_register(
|
||||
SRCS "src/component_stub.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
)
|
||||
|
||||
target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23)
|
||||
@@ -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
|
||||
@@ -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}
|
||||
% ...
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "main.cpp"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES core net secure_store
|
||||
)
|
||||
@@ -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://<device-ip>/");
|
||||
} else {
|
||||
ESP_LOGI(kTag,
|
||||
"running in setup mode — join DigiRadio-setup, "
|
||||
"open http://192.168.4.1/");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
@@ -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
|
||||
Reference in New Issue
Block a user