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:
2026-07-06 09:10:37 +02:00
co-authored by Cursor
parent 9f04c4bb0c
commit ddbee70c23
63 changed files with 3771 additions and 19 deletions
@@ -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 863 (WPA).
* @pubstate none
*
* Open networks use an empty password; WPA-PSK requires 863 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 (132 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