From cd94e36a9d84766e383a137a7f27ce4d11dbbcc8 Mon Sep 17 00:00:00 2001 From: Michele Bigi Date: Tue, 7 Jul 2026 09:00:38 +0200 Subject: [PATCH] Add EEPROM EUI-48 device identity across firmware and API. Read the factory 24AA025E48 serial after ADAU I2C boot, derive SoftAP SSID, BT name, STA hostname, and expose serialNumber on GET /api/health with graceful fallbacks. Co-authored-by: Cursor --- Software/components/core/CMakeLists.txt | 2 + .../core/include/core/DeviceIdentity.hpp | 147 ++++++++++++++++++ .../components/core/include/core/Eui48.hpp | 90 +++++++++++ .../core/include/core/HealthStatus.hpp | 26 +++- .../include/core/IDeviceIdentitySource.hpp | 50 ++++++ .../core/include/core/IdentityError.hpp | 32 ++++ .../components/core/src/DeviceIdentity.cpp | 95 +++++++++++ Software/components/core/src/Eui48.cpp | 63 ++++++++ Software/components/core/src/HealthStatus.cpp | 24 ++- .../components/core/src/HealthStatusJson.cpp | 4 +- Software/components/core/test/CMakeLists.txt | 6 + .../core/test/device_identity_test.cpp | 100 ++++++++++++ .../core/test/health_status_test.cpp | 47 +----- .../include/adau1701/Adau1701Driver.hpp | 13 ++ .../drivers/adau1701/src/Adau1701Driver.cpp | 5 + .../bt1035/include/bt1035/Bt1035Driver.hpp | 15 ++ .../drivers/bt1035/src/Bt1035Driver.cpp | 14 ++ .../drivers/eeprom24aa/CMakeLists.txt | 7 + .../include/eeprom24aa/Eeprom24aa.hpp | 68 ++++++++ .../drivers/eeprom24aa/src/Eeprom24aa.cpp | 72 +++++++++ Software/components/net/CMakeLists.txt | 2 +- .../net/include/net/NetBootstrap.hpp | 5 +- .../net/include/net/SetupWebServer.hpp | 6 +- .../net/include/net/SoftApConfig.hpp | 13 ++ .../components/net/include/net/StaClient.hpp | 7 +- Software/components/net/src/NetBootstrap.cpp | 34 ++-- .../components/net/src/SetupWebServer.cpp | 17 +- Software/components/net/src/SoftApConfig.cpp | 7 +- Software/components/net/src/StaClient.cpp | 22 ++- Software/docs/manual/ch-api.tex | 4 +- Software/docs/manual/ch-classes.tex | 38 ++++- Software/main/CMakeLists.txt | 2 +- Software/main/hardware_bootstrap.cpp | 29 ++++ Software/main/hardware_bootstrap.hpp | 13 ++ Software/main/main.cpp | 3 +- 35 files changed, 999 insertions(+), 83 deletions(-) create mode 100644 Software/components/core/include/core/DeviceIdentity.hpp create mode 100644 Software/components/core/include/core/Eui48.hpp create mode 100644 Software/components/core/include/core/IDeviceIdentitySource.hpp create mode 100644 Software/components/core/include/core/IdentityError.hpp create mode 100644 Software/components/core/src/DeviceIdentity.cpp create mode 100644 Software/components/core/src/Eui48.cpp create mode 100644 Software/components/core/test/device_identity_test.cpp create mode 100644 Software/components/drivers/eeprom24aa/CMakeLists.txt create mode 100644 Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp create mode 100644 Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp diff --git a/Software/components/core/CMakeLists.txt b/Software/components/core/CMakeLists.txt index 8632dff..b7073fd 100644 --- a/Software/components/core/CMakeLists.txt +++ b/Software/components/core/CMakeLists.txt @@ -3,6 +3,8 @@ idf_component_register( "src/FirmwareVersion.cpp" "src/HealthStatus.cpp" "src/HealthStatusJson.cpp" + "src/Eui48.cpp" + "src/DeviceIdentity.cpp" "src/Secret.cpp" "src/WifiSsid.cpp" "src/WifiCredentials.cpp" diff --git a/Software/components/core/include/core/DeviceIdentity.hpp b/Software/components/core/include/core/DeviceIdentity.hpp new file mode 100644 index 0000000..98465c4 --- /dev/null +++ b/Software/components/core/include/core/DeviceIdentity.hpp @@ -0,0 +1,147 @@ +/** + * @file DeviceIdentity.hpp + * @brief Per-board identity derived from the factory EUI-48 or fallbacks. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +#include "core/Eui48.hpp" + +#include +#include +#include + +namespace core { + +/** + * @brief DeviceIdentity — SSID, Bluetooth name, hostname, and serial strings. + * + * @dname DeviceIdentity + * @return n/a (type) + * @pubstate Immutable after construction. unknown() uses documented fallbacks + * when the EEPROM read fails. + * + * @author Michele Bigi + * @date 2026-07-07 + */ +class DeviceIdentity { +public: + /** + * @brief unknown — identity when the EUI-48 read fails. + * + * @dname unknown + * @return DeviceIdentity with fallback SSID and serial "unknown". + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] static DeviceIdentity unknown() noexcept; + + /** + * @brief fromEui48 — derive network-visible names from factory EUI-48. + * + * @dname fromEui48 + * @param eui Factory-programmed identifier from the 24AA025E48. + * @return DeviceIdentity with DigiRadio- strings. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] static DeviceIdentity fromEui48(Eui48 eui); + + /** + * @brief isKnown — whether a factory EUI-48 was read successfully. + * + * @dname isKnown + * @return true when fromEui48 was used. + * @pubstate reads eui_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] bool isKnown() const noexcept; + + /** + * @brief serialNumber — canonical serial or "unknown". + * + * @dname serialNumber + * @return Uppercase hex serial when known. + * @pubstate reads serialNumber_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::string_view serialNumber() const noexcept; + + /** + * @brief softApSsid — setup SoftAP SSID for this unit. + * + * @dname softApSsid + * @return DigiRadio- or the DigiRadio-setup fallback. + * @pubstate reads softApSsid_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::string_view softApSsid() const noexcept; + + /** + * @brief bluetoothName — GAP friendly name for the BT1035 module. + * + * @dname bluetoothName + * @return DigiRadio- or plain DigiRadio when unknown. + * @pubstate reads bluetoothName_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::string_view bluetoothName() const noexcept; + + /** + * @brief hostname — STA hostname / mDNS label (no .local suffix). + * + * @dname hostname + * @return digiradio- or digiradio when unknown. + * @pubstate reads hostname_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::string_view hostname() const noexcept; + + /** + * @brief eui48 — optional factory identifier backing this identity. + * + * @dname eui48 + * @return EUI-48 when known; otherwise empty. + * @pubstate reads eui_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] const std::optional& eui48() const noexcept; + +private: + DeviceIdentity(std::optional eui, + std::string serialNumber, + std::string softApSsid, + std::string bluetoothName, + std::string hostname); + + std::optional eui_; + std::string serialNumber_; + std::string softApSsid_; + std::string bluetoothName_; + std::string hostname_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/Eui48.hpp b/Software/components/core/include/core/Eui48.hpp new file mode 100644 index 0000000..1d512c4 --- /dev/null +++ b/Software/components/core/include/core/Eui48.hpp @@ -0,0 +1,90 @@ +/** + * @file Eui48.hpp + * @brief Strong type for a factory-programmed 48-bit EUI from 24AA025E48. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +#include +#include +#include +#include + +namespace core { + +/** + * @brief Eui48 — immutable six-byte globally unique identifier. + * + * @dname Eui48 + * @return n/a (type) + * @pubstate Owns bytes_; constructed only from a full six-byte payload. + * + * @author Michele Bigi + * @date 2026-07-07 + */ +class Eui48 { +public: + /** + * @brief fromBytes — wrap a factory EUI-48 read from EEPROM. + * + * @dname fromBytes + * @param bytes Six-byte EUI-48 in datasheet order (0xFA..0xFF). + * @return Eui48 value. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] static Eui48 fromBytes( + const std::array& bytes) noexcept; + + /** + * @brief bytes — read the raw EUI-48 octets. + * + * @dname bytes + * @return Reference to the six stored bytes. + * @pubstate reads bytes_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] const std::array& bytes() const noexcept; + + /** + * @brief serialNumber — canonical uppercase hex serial (12 digits). + * + * @dname serialNumber + * @return Serial string without separators (e.g. AABBCCDDEEFF). + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::string serialNumber() const; + + /** + * @brief shortSuffix — last three bytes as six uppercase hex digits. + * + * @dname shortSuffix + * @return Suffix for SSID, Bluetooth name, and hostname. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::string shortSuffix() const; + +private: + explicit Eui48(std::array bytes) noexcept; + + std::array bytes_; +}; + +} // namespace core diff --git a/Software/components/core/include/core/HealthStatus.hpp b/Software/components/core/include/core/HealthStatus.hpp index 246e34f..fd4ccd6 100644 --- a/Software/components/core/include/core/HealthStatus.hpp +++ b/Software/components/core/include/core/HealthStatus.hpp @@ -21,6 +21,8 @@ #include "core/CompanionChipStatus.hpp" #include +#include +#include namespace core { @@ -71,8 +73,9 @@ public: * @brief ok — build health response including companion-chip flags. * * @dname ok - * @param firmware Active firmware version to report. - * @param chips Si4684 / ADAU1701 / BT1035 boot snapshot. + * @param firmware Active firmware version to report. + * @param chips Si4684 / ADAU1701 / BT1035 boot snapshot. + * @param serialNumber Unit serial from EEPROM, or "unknown". * @return HealthStatus with HealthState::Ok and chips populated. * @pubstate none * @@ -80,7 +83,8 @@ public: * @date 2026-07-06 */ [[nodiscard]] static HealthStatus ok(FirmwareVersion firmware, - CompanionChipStatus chips); + CompanionChipStatus chips, + std::string_view serialNumber); /** * @brief state — read the health indicator. @@ -119,15 +123,29 @@ public: [[nodiscard]] const std::optional& chips() const noexcept; + /** + * @brief serialNumber — board serial from EEPROM or "unknown". + * + * @dname serialNumber + * @return Canonical serial string for /api/health. + * @pubstate reads serialNumber_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::string_view serialNumber() const noexcept; + private: explicit HealthStatus(HealthState state, FirmwareVersion firmware); explicit HealthStatus(HealthState state, FirmwareVersion firmware, - CompanionChipStatus chips); + CompanionChipStatus chips, + std::string serialNumber); HealthState state_; FirmwareVersion firmware_; std::optional chips_; + std::string serialNumber_; }; } // namespace core diff --git a/Software/components/core/include/core/IDeviceIdentitySource.hpp b/Software/components/core/include/core/IDeviceIdentitySource.hpp new file mode 100644 index 0000000..abf7015 --- /dev/null +++ b/Software/components/core/include/core/IDeviceIdentitySource.hpp @@ -0,0 +1,50 @@ +/** + * @file IDeviceIdentitySource.hpp + * @brief Port for reading per-board identity from non-volatile storage. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +#include "core/DeviceIdentity.hpp" +#include "core/IdentityError.hpp" + +#include + +namespace core { + +/** + * @brief IDeviceIdentitySource — reads DeviceIdentity from board storage. + * + * @dname IDeviceIdentitySource + * @return n/a (type) + * @pubstate Implementations live in the imperative shell (EEPROM driver). + * + * @author Michele Bigi + * @date 2026-07-07 + */ +class IDeviceIdentitySource { +public: + virtual ~IDeviceIdentitySource() = default; + + /** + * @brief readDeviceIdentity — load identity from the backing store. + * + * @dname readDeviceIdentity + * @return DeviceIdentity on success, or IdentityError. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] virtual std::expected + readDeviceIdentity() = 0; +}; + +} // namespace core diff --git a/Software/components/core/include/core/IdentityError.hpp b/Software/components/core/include/core/IdentityError.hpp new file mode 100644 index 0000000..7814ce2 --- /dev/null +++ b/Software/components/core/include/core/IdentityError.hpp @@ -0,0 +1,32 @@ +/** + * @file IdentityError.hpp + * @brief Failure causes when reading board identity from EEPROM. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +namespace core { + +/** + * @brief IdentityError — EEPROM / I2C identity read failures. + * + * @dname IdentityError + * @return n/a (type) + * @pubstate n/a + * + * @author Michele Bigi + * @date 2026-07-07 + */ +enum class IdentityError { + I2cFailed, ///< Bus or device transaction failed. + ReadFailed, ///< Payload length or EEPROM response invalid. +}; + +} // namespace core diff --git a/Software/components/core/src/DeviceIdentity.cpp b/Software/components/core/src/DeviceIdentity.cpp new file mode 100644 index 0000000..f629c82 --- /dev/null +++ b/Software/components/core/src/DeviceIdentity.cpp @@ -0,0 +1,95 @@ +/** + * @file DeviceIdentity.cpp + * @brief DeviceIdentity implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ + +#include "core/DeviceIdentity.hpp" + +namespace core { + +namespace { + +constexpr std::string_view kSerialUnknown = "unknown"; +constexpr std::string_view kSoftApFallback = "DigiRadio-setup"; +constexpr std::string_view kBluetoothFallback = "DigiRadio"; +constexpr std::string_view kHostnameFallback = "digiradio"; + +[[nodiscard]] std::string prefixed(std::string_view prefix, + std::string_view suffix) +{ + return std::string(prefix) + std::string(suffix); +} + +} // namespace + +DeviceIdentity DeviceIdentity::unknown() noexcept +{ + return DeviceIdentity(std::nullopt, + std::string(kSerialUnknown), + std::string(kSoftApFallback), + std::string(kBluetoothFallback), + std::string(kHostnameFallback)); +} + +DeviceIdentity DeviceIdentity::fromEui48(Eui48 eui) +{ + const std::string suffix = eui.shortSuffix(); + return DeviceIdentity(eui, + eui.serialNumber(), + prefixed("DigiRadio-", suffix), + prefixed("DigiRadio-", suffix), + prefixed("digiradio-", suffix)); +} + +bool DeviceIdentity::isKnown() const noexcept +{ + return eui_.has_value(); +} + +std::string_view DeviceIdentity::serialNumber() const noexcept +{ + return serialNumber_; +} + +std::string_view DeviceIdentity::softApSsid() const noexcept +{ + return softApSsid_; +} + +std::string_view DeviceIdentity::bluetoothName() const noexcept +{ + return bluetoothName_; +} + +std::string_view DeviceIdentity::hostname() const noexcept +{ + return hostname_; +} + +const std::optional& DeviceIdentity::eui48() const noexcept +{ + return eui_; +} + +DeviceIdentity::DeviceIdentity(std::optional eui, + std::string serialNumber, + std::string softApSsid, + std::string bluetoothName, + std::string hostname) + : eui_(std::move(eui)) + , serialNumber_(std::move(serialNumber)) + , softApSsid_(std::move(softApSsid)) + , bluetoothName_(std::move(bluetoothName)) + , hostname_(std::move(hostname)) +{ +} + +} // namespace core diff --git a/Software/components/core/src/Eui48.cpp b/Software/components/core/src/Eui48.cpp new file mode 100644 index 0000000..cfd9d32 --- /dev/null +++ b/Software/components/core/src/Eui48.cpp @@ -0,0 +1,63 @@ +/** + * @file Eui48.cpp + * @brief Eui48 implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ + +#include "core/Eui48.hpp" + +#include +#include + +namespace core { + +namespace { + +[[nodiscard]] std::string toUpperHex(std::string_view bytes) +{ + std::ostringstream out; + out << std::uppercase << std::hex << std::setfill('0'); + for (const unsigned char byte : bytes) { + out << std::setw(2) << static_cast(byte); + } + return out.str(); +} + +} // namespace + +Eui48 Eui48::fromBytes(const std::array& bytes) noexcept +{ + return Eui48(bytes); +} + +Eui48::Eui48(std::array bytes) noexcept + : bytes_(bytes) +{ +} + +const std::array& Eui48::bytes() const noexcept +{ + return bytes_; +} + +std::string Eui48::serialNumber() const +{ + return toUpperHex( + std::string_view(reinterpret_cast(bytes_.data()), + bytes_.size())); +} + +std::string Eui48::shortSuffix() const +{ + return toUpperHex( + std::string_view(reinterpret_cast(bytes_.data() + 3U), 3U)); +} + +} // namespace core diff --git a/Software/components/core/src/HealthStatus.cpp b/Software/components/core/src/HealthStatus.cpp index 46603f2..d77e78e 100644 --- a/Software/components/core/src/HealthStatus.cpp +++ b/Software/components/core/src/HealthStatus.cpp @@ -15,29 +15,42 @@ namespace core { +namespace { + +constexpr std::string_view kSerialUnknown = "unknown"; + +} // namespace + HealthStatus HealthStatus::ok(FirmwareVersion firmware) { return HealthStatus(HealthState::Ok, std::move(firmware)); } HealthStatus HealthStatus::ok(FirmwareVersion firmware, - CompanionChipStatus chips) + CompanionChipStatus chips, + std::string_view serialNumber) { - return HealthStatus(HealthState::Ok, std::move(firmware), chips); + return HealthStatus(HealthState::Ok, + std::move(firmware), + chips, + std::string(serialNumber)); } HealthStatus::HealthStatus(HealthState state, FirmwareVersion firmware) : state_(state) , firmware_(std::move(firmware)) , chips_(std::nullopt) + , serialNumber_(kSerialUnknown) { } HealthStatus::HealthStatus(HealthState state, FirmwareVersion firmware, - CompanionChipStatus chips) + CompanionChipStatus chips, + std::string serialNumber) : state_(state) , firmware_(std::move(firmware)) , chips_(chips) + , serialNumber_(std::move(serialNumber)) { } @@ -56,4 +69,9 @@ const std::optional& HealthStatus::chips() const noexcept return chips_; } +std::string_view HealthStatus::serialNumber() const noexcept +{ + return serialNumber_; +} + } // namespace core diff --git a/Software/components/core/src/HealthStatusJson.cpp b/Software/components/core/src/HealthStatusJson.cpp index c616757..f42030e 100644 --- a/Software/components/core/src/HealthStatusJson.cpp +++ b/Software/components/core/src/HealthStatusJson.cpp @@ -39,7 +39,9 @@ namespace { std::string serializeHealthStatusJson(const HealthStatus& status) { std::string json = std::string("{\"status\":\"") + healthStateToken(status.state()) - + "\",\"fw\":\"" + std::string(status.firmware().value()) + "\""; + + "\",\"fw\":\"" + std::string(status.firmware().value()) + + "\",\"serialNumber\":\"" + + std::string(status.serialNumber()) + "\""; if (const std::optional& chips = status.chips(); chips) { json += std::string(",\"chips\":{") + "\"si4684\":" + (chips->si4684Ready ? "true" : "false") diff --git a/Software/components/core/test/CMakeLists.txt b/Software/components/core/test/CMakeLists.txt index 7605fb5..7776961 100644 --- a/Software/components/core/test/CMakeLists.txt +++ b/Software/components/core/test/CMakeLists.txt @@ -15,6 +15,8 @@ add_library(digiradio_core STATIC "${CORE_SRC_DIR}/FirmwareVersion.cpp" "${CORE_SRC_DIR}/HealthStatus.cpp" "${CORE_SRC_DIR}/HealthStatusJson.cpp" + "${CORE_SRC_DIR}/Eui48.cpp" + "${CORE_SRC_DIR}/DeviceIdentity.cpp" "${CORE_SRC_DIR}/Secret.cpp" "${CORE_SRC_DIR}/WifiSsid.cpp" "${CORE_SRC_DIR}/WifiCredentials.cpp" @@ -49,6 +51,10 @@ target_include_directories(digiradio_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/../include" ) +add_executable(device_identity_test device_identity_test.cpp) +target_link_libraries(device_identity_test PRIVATE digiradio_core) +add_test(NAME device_identity_test COMMAND device_identity_test) + 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) diff --git a/Software/components/core/test/device_identity_test.cpp b/Software/components/core/test/device_identity_test.cpp new file mode 100644 index 0000000..1dd165c --- /dev/null +++ b/Software/components/core/test/device_identity_test.cpp @@ -0,0 +1,100 @@ +/** + * @file device_identity_test.cpp + * @brief Host tests for Eui48 and DeviceIdentity derivation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ + +#include "core/DeviceIdentity.hpp" +#include "core/Eui48.hpp" + +#include +#include +#include +#include + +namespace { + +[[nodiscard]] bool expectEqual(std::string_view actual, + std::string_view expected) +{ + if (actual == expected) { + return true; + } + std::cerr << "expected: " << expected << "\nactual: " << actual << '\n'; + return false; +} + +[[nodiscard]] int runKnownEuiTest() +{ + const std::array bytes = { + 0x00U, 0x04U, 0xA3U, 0x12U, 0x34U, 0x56U, + }; + const core::Eui48 eui = core::Eui48::fromBytes(bytes); + if (!expectEqual(eui.serialNumber(), "0004A3123456")) { + return EXIT_FAILURE; + } + if (!expectEqual(eui.shortSuffix(), "123456")) { + return EXIT_FAILURE; + } + + const core::DeviceIdentity identity = core::DeviceIdentity::fromEui48(eui); + if (!identity.isKnown()) { + std::cerr << "expected known identity\n"; + return EXIT_FAILURE; + } + if (!expectEqual(identity.serialNumber(), "0004A3123456")) { + return EXIT_FAILURE; + } + if (!expectEqual(identity.softApSsid(), "DigiRadio-123456")) { + return EXIT_FAILURE; + } + if (!expectEqual(identity.bluetoothName(), "DigiRadio-123456")) { + return EXIT_FAILURE; + } + if (!expectEqual(identity.hostname(), "digiradio-123456")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +[[nodiscard]] int runUnknownIdentityTest() +{ + const core::DeviceIdentity identity = core::DeviceIdentity::unknown(); + if (identity.isKnown()) { + std::cerr << "expected unknown identity\n"; + return EXIT_FAILURE; + } + if (!expectEqual(identity.serialNumber(), "unknown")) { + return EXIT_FAILURE; + } + if (!expectEqual(identity.softApSsid(), "DigiRadio-setup")) { + return EXIT_FAILURE; + } + if (!expectEqual(identity.bluetoothName(), "DigiRadio")) { + return EXIT_FAILURE; + } + if (!expectEqual(identity.hostname(), "digiradio")) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} + +} // namespace + +int main() +{ + if (runKnownEuiTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + if (runUnknownIdentityTest() != EXIT_SUCCESS) { + return EXIT_FAILURE; + } + return EXIT_SUCCESS; +} diff --git a/Software/components/core/test/health_status_test.cpp b/Software/components/core/test/health_status_test.cpp index 5080a85..fb6f5d5 100644 --- a/Software/components/core/test/health_status_test.cpp +++ b/Software/components/core/test/health_status_test.cpp @@ -7,11 +7,6 @@ * 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 */ @@ -27,18 +22,6 @@ 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) { @@ -49,23 +32,13 @@ namespace { 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"})")) { + if (!expectEqual(json, + R"({"status":"ok","fw":"0.1.0","serialNumber":"unknown"})")) { return EXIT_FAILURE; } return EXIT_SUCCESS; @@ -73,17 +46,6 @@ namespace { } // 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() { if (runHealthStatusJsonTest() != EXIT_SUCCESS) { @@ -96,11 +58,12 @@ int main() .si4684Ready = true, .adau1701Ready = true, .bt1035Ready = true, - }); + }, + "0004A3123456"); const std::string chipsJson = core::serializeHealthStatusJson(withChips); if (!expectEqual( chipsJson, - R"({"status":"ok","fw":"0.6.0","chips":{"si4684":true,"adau1701":true,"bt1035":true}})")) { + R"({"status":"ok","fw":"0.6.0","serialNumber":"0004A3123456","chips":{"si4684":true,"adau1701":true,"bt1035":true}})")) { return EXIT_FAILURE; } return EXIT_SUCCESS; diff --git a/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp b/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp index 9209cc5..8c2517e 100644 --- a/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp +++ b/Software/components/drivers/adau1701/include/adau1701/Adau1701Driver.hpp @@ -112,6 +112,19 @@ public: */ [[nodiscard]] bool isBooted() const noexcept; + /** + * @brief i2cBusHandle — borrow the shared I2C master bus after boot. + * + * @dname i2cBusHandle + * @return Opaque bus handle for the 24AA025E48 on the same bus, or null + * before boot(). + * @pubstate reads i2cBus_. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] void* i2cBusHandle() const noexcept; + /** * @brief applyProfile — safeload mixer, EQ, and master from snapshot. * diff --git a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp index 2ff7b35..508218f 100644 --- a/Software/components/drivers/adau1701/src/Adau1701Driver.cpp +++ b/Software/components/drivers/adau1701/src/Adau1701Driver.cpp @@ -122,6 +122,11 @@ bool Adau1701Driver::isBooted() const noexcept return booted_; } +void* Adau1701Driver::i2cBusHandle() const noexcept +{ + return i2cBus_; +} + std::expected Adau1701Driver::ensureBooted() const { if (!booted_) { diff --git a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp index 1e63426..e2dbfb4 100644 --- a/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp +++ b/Software/components/drivers/bt1035/include/bt1035/Bt1035Driver.hpp @@ -17,6 +17,7 @@ #include "core/Bt1035At.hpp" #include +#include namespace bt1035 { @@ -168,6 +169,20 @@ public: */ [[nodiscard]] std::expected disconnectA2dp(); + /** + * @brief setDeviceName — set the module GAP friendly name (AT+NAME). + * + * @dname setDeviceName + * @param name Bluetooth name (Feasycom FSC-BT1035 AT+NAME command). + * @return Ok on success, or Bt1035Error. + * @pubstate sends AT+NAME after boot; does not alter AT+AUXCFG=1 init. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected setDeviceName( + std::string_view name); + private: [[nodiscard]] std::expected ensureBooted() const; [[nodiscard]] std::expected runInitSequence(); diff --git a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp index eca32d8..0bc16bb 100644 --- a/Software/components/drivers/bt1035/src/Bt1035Driver.cpp +++ b/Software/components/drivers/bt1035/src/Bt1035Driver.cpp @@ -21,6 +21,7 @@ #include #include +#include namespace bt1035 { @@ -161,6 +162,19 @@ std::expected Bt1035Driver::disconnectA2dp() return sendCommand(core::Bt1035AtCommand::A2dpDisconnect); } +std::expected Bt1035Driver::setDeviceName( + std::string_view name) +{ + if (auto ready = ensureBooted(); !ready) { + return ready; + } + if (name.empty() || name.size() > 32U) { + return std::unexpected(Bt1035Error::UnexpectedResponse); + } + std::string line = std::string("AT+NAME=") + std::string(name) + "\r\n"; + return transmitAndExpectOk(line); +} + std::expected Bt1035Driver::runInitSequence() { for (const core::Bt1035AtCommand command : core::bootInitSequence()) { diff --git a/Software/components/drivers/eeprom24aa/CMakeLists.txt b/Software/components/drivers/eeprom24aa/CMakeLists.txt new file mode 100644 index 0000000..9357eb2 --- /dev/null +++ b/Software/components/drivers/eeprom24aa/CMakeLists.txt @@ -0,0 +1,7 @@ +idf_component_register( + SRCS "src/Eeprom24aa.cpp" + INCLUDE_DIRS "include" + REQUIRES core driver esp_driver_i2c +) + +target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp new file mode 100644 index 0000000..a6bf946 --- /dev/null +++ b/Software/components/drivers/eeprom24aa/include/eeprom24aa/Eeprom24aa.hpp @@ -0,0 +1,68 @@ +/** + * @file Eeprom24aa.hpp + * @brief 24AA025E48 EEPROM driver — factory EUI-48 on the shared I2C bus. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ +#pragma once + +#include "core/IDeviceIdentitySource.hpp" + +#include "driver/i2c_master.h" + +#include + +namespace eeprom24aa { + +/** + * @brief Eeprom24aa — reads the factory EUI-48 from Microchip 24AA025E48. + * + * @dname Eeprom24aa + * @return n/a (type) + * @pubstate Borrows an existing I2C master bus (shared with ADAU1701). The + * EUI-48 lives at word address 0xFA..0xFF per the 24AA025E48 + * datasheet (DS20001191). + * + * @author Michele Bigi + * @date 2026-07-07 + */ +class Eeprom24aa : public core::IDeviceIdentitySource { +public: + /** + * @brief Eeprom24aa — bind to a running I2C master bus and 7-bit addr. + * + * @dname Eeprom24aa + * @param bus Shared I2C bus handle from Adau1701Driver after boot. + * @param addr7 7-bit EEPROM address (0x52 on DigiRadio). + * @pubstate stores bus_ and addr7_; does not own the bus. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + Eeprom24aa(i2c_master_bus_handle_t bus, std::uint8_t addr7) noexcept; + + /** + * @brief readDeviceIdentity — read EUI-48 and derive DeviceIdentity. + * + * @dname readDeviceIdentity + * @return DeviceIdentity on success, or IdentityError. + * @pubstate performs one I2C read of six bytes at word address 0xFA. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] std::expected + readDeviceIdentity() override; + +private: + i2c_master_bus_handle_t bus_; + std::uint8_t addr7_; +}; + +} // namespace eeprom24aa diff --git a/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp b/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp new file mode 100644 index 0000000..019c455 --- /dev/null +++ b/Software/components/drivers/eeprom24aa/src/Eeprom24aa.cpp @@ -0,0 +1,72 @@ +/** + * @file Eeprom24aa.cpp + * @brief Eeprom24aa implementation. + * + * DigiRadio firmware — https://github.com/manvalan/DigiRadio + * + * Copyright 2026 Michele Bigi + * SPDX-License-Identifier: Apache-2.0 + * + * @author Michele Bigi + * @date 2026-07-07 + */ + +#include "eeprom24aa/Eeprom24aa.hpp" + +#include "driver/i2c_master.h" +#include "esp_log.h" + +#include + +namespace eeprom24aa { + +namespace { + +constexpr char kTag[] = "Eeprom24aa"; +/** EUI-48 word address per Microchip 24AA025E48 datasheet (DS20001191). */ +constexpr std::uint8_t kEui48WordAddress = 0xFAU; +constexpr int kI2cTimeoutMs = 100; + +} // namespace + +Eeprom24aa::Eeprom24aa(i2c_master_bus_handle_t bus, + std::uint8_t addr7) noexcept + : bus_(bus) + , addr7_(addr7) +{ +} + +std::expected +Eeprom24aa::readDeviceIdentity() +{ + if (bus_ == nullptr) { + return std::unexpected(core::IdentityError::I2cFailed); + } + + i2c_device_config_t devCfg = {}; + devCfg.dev_addr_length = I2C_ADDR_BIT_LEN_7; + devCfg.device_address = addr7_; + devCfg.scl_speed_hz = 100000; + + i2c_master_dev_handle_t dev = nullptr; + if (i2c_master_bus_add_device(bus_, &devCfg, &dev) != ESP_OK) { + ESP_LOGW(kTag, "i2c_master_bus_add_device failed"); + return std::unexpected(core::IdentityError::I2cFailed); + } + + const std::uint8_t wordAddress = kEui48WordAddress; + std::array payload = {}; + const esp_err_t err = i2c_master_transmit_receive( + dev, &wordAddress, 1U, payload.data(), payload.size(), kI2cTimeoutMs); + + i2c_master_bus_rm_device(dev); + + if (err != ESP_OK) { + ESP_LOGW(kTag, "EUI-48 read failed (err=0x%x)", static_cast(err)); + return std::unexpected(core::IdentityError::ReadFailed); + } + + return core::DeviceIdentity::fromEui48(core::Eui48::fromBytes(payload)); +} + +} // namespace eeprom24aa diff --git a/Software/components/net/CMakeLists.txt b/Software/components/net/CMakeLists.txt index f13f141..bd28a6c 100644 --- a/Software/components/net/CMakeLists.txt +++ b/Software/components/net/CMakeLists.txt @@ -7,7 +7,7 @@ idf_component_register( "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 secure_store tuner audio bluetooth station integration bt1035 + REQUIRES core esp_wifi esp_netif esp_event nvs_flash esp_http_server mdns secure_store tuner audio bluetooth station integration bt1035 ) target_compile_features(${COMPONENT_LIB} PUBLIC cxx_std_23) diff --git a/Software/components/net/include/net/NetBootstrap.hpp b/Software/components/net/include/net/NetBootstrap.hpp index c1550a9..386d3f5 100644 --- a/Software/components/net/include/net/NetBootstrap.hpp +++ b/Software/components/net/include/net/NetBootstrap.hpp @@ -18,6 +18,7 @@ #pragma once #include "core/CompanionChipStatus.hpp" +#include "core/DeviceIdentity.hpp" #include "core/ISecureStore.hpp" #include "net/NetError.hpp" #include "net/NetState.hpp" @@ -74,6 +75,7 @@ public: * @param stations Station preset service for REST routes. * @param integration Application orchestration for preset recall. * @param companionChips Boot flags exposed on GET /api/health. + * @param deviceIdentity EEPROM-derived SSID, hostname, and serial. * @return NetBootstrap on success, or a NetError. * @pubstate none * @@ -85,7 +87,8 @@ public: audio::AudioService& audio, bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, - core::CompanionChipStatus companionChips); + core::CompanionChipStatus companionChips, + const core::DeviceIdentity& deviceIdentity); NetBootstrap(const NetBootstrap&) = delete; NetBootstrap& operator=(const NetBootstrap&) = delete; diff --git a/Software/components/net/include/net/SetupWebServer.hpp b/Software/components/net/include/net/SetupWebServer.hpp index 8a73951..c46de24 100644 --- a/Software/components/net/include/net/SetupWebServer.hpp +++ b/Software/components/net/include/net/SetupWebServer.hpp @@ -18,6 +18,7 @@ #pragma once #include "core/CompanionChipStatus.hpp" +#include "core/DeviceIdentity.hpp" #include "core/ISecureStore.hpp" #include "net/NetError.hpp" #include "net/NetState.hpp" @@ -69,6 +70,7 @@ struct HttpRouteContext { station::StationService* stations; ///< Preset list REST routes. integration::IntegrationService* integration; ///< Preset recall orchestration. core::CompanionChipStatus companionChips; ///< Boot flags for /api/health. + core::DeviceIdentity deviceIdentity; ///< EEPROM-derived unit identity. }; /** @@ -146,6 +148,7 @@ public: * @param stations Station preset service for list REST routes. * @param integration Application orchestration for preset recall. * @param companionChips Boot flags for GET /api/health. + * @param deviceIdentity Unit identity for /api/health serialNumber. * @return Ok on success, or NetError::HttpServerStartFailed. * @pubstate writes server_, store_, netState_, and service pointers on success. * @@ -158,7 +161,8 @@ public: bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, - core::CompanionChipStatus companionChips); + core::CompanionChipStatus companionChips, + const core::DeviceIdentity& deviceIdentity); private: httpd_handle* server_; diff --git a/Software/components/net/include/net/SoftApConfig.hpp b/Software/components/net/include/net/SoftApConfig.hpp index 25ae669..7774ecf 100644 --- a/Software/components/net/include/net/SoftApConfig.hpp +++ b/Software/components/net/include/net/SoftApConfig.hpp @@ -49,6 +49,19 @@ public: */ [[nodiscard]] static SoftApConfig setupDefault(); + /** + * @brief forSsid — construct SoftAP settings with a custom SSID. + * + * @dname forSsid + * @param ssid Network name (max 32 bytes per 802.11). + * @return SoftApConfig with the given SSID. + * @pubstate none + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] static SoftApConfig forSsid(std::string_view ssid); + /** * @brief ssid — read the broadcast SSID. * diff --git a/Software/components/net/include/net/StaClient.hpp b/Software/components/net/include/net/StaClient.hpp index 18d0c08..ec699d8 100644 --- a/Software/components/net/include/net/StaClient.hpp +++ b/Software/components/net/include/net/StaClient.hpp @@ -21,6 +21,7 @@ #include "net/NetError.hpp" #include +#include namespace net { @@ -91,7 +92,8 @@ public: * @brief connect — join the network described by creds. * * @dname connect - * @param creds Validated domain credentials from ISecureStore. + * @param creds Validated domain credentials from ISecureStore. + * @param hostname STA hostname / mDNS label (no .local suffix). * @return Ok on success, or NetError::StaConnectTimeout / * NetError::StaConnectFailed. * @pubstate writes connected_ on success; uses creds via Secret. @@ -100,7 +102,8 @@ public: * @date 2026-07-06 */ [[nodiscard]] std::expected - connect(const core::WifiCredentials& creds); + connect(const core::WifiCredentials& creds, + std::string_view hostname = {}); private: bool connected_; diff --git a/Software/components/net/src/NetBootstrap.cpp b/Software/components/net/src/NetBootstrap.cpp index bf2f838..8dbf399 100644 --- a/Software/components/net/src/NetBootstrap.cpp +++ b/Software/components/net/src/NetBootstrap.cpp @@ -101,11 +101,12 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, - core::CompanionChipStatus companionChips) + core::CompanionChipStatus companionChips, + const core::DeviceIdentity& deviceIdentity) { esp_netif_create_default_wifi_ap(); - SoftApHost softAp(SoftApConfig::setupDefault()); + SoftApHost softAp(SoftApConfig::forSsid(deviceIdentity.softApSsid())); if (auto apResult = softAp.start(); !apResult) { return std::unexpected(apResult.error()); } @@ -113,12 +114,15 @@ startSetupMode(core::ISecureStore& store, tuner::TunerService& tuner, SetupWebServer webServer; if (auto webResult = webServer.start(store, NetState::SoftApSetup, tuner, audio, - bluetooth, stations, integration, companionChips); + bluetooth, stations, integration, companionChips, + deviceIdentity); !webResult) { return std::unexpected(webResult.error()); } - ESP_LOGI(kTag, "setup mode ready — SSID DigiRadio-setup"); + ESP_LOGI(kTag, "setup mode ready — SSID %.*s", + static_cast(deviceIdentity.softApSsid().size()), + deviceIdentity.softApSsid().data()); return NetBootstrap(std::move(softAp), std::nullopt, std::move(webServer), NetState::SoftApSetup); } @@ -140,7 +144,8 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, - core::CompanionChipStatus companionChips) + core::CompanionChipStatus companionChips, + const core::DeviceIdentity& deviceIdentity) { auto credsResult = store.loadWifiCredentials(); if (!credsResult) { @@ -151,19 +156,24 @@ startStaMode(core::ISecureStore& store, tuner::TunerService& tuner, esp_netif_create_default_wifi_sta(); StaClient sta; - if (auto staResult = sta.connect(credsResult.value()); !staResult) { + if (auto staResult = + sta.connect(credsResult.value(), deviceIdentity.hostname()); + !staResult) { return std::unexpected(staResult.error()); } SetupWebServer webServer; if (auto webResult = webServer.start(store, NetState::StaConnected, tuner, audio, - bluetooth, stations, integration, companionChips); + bluetooth, stations, integration, companionChips, + deviceIdentity); !webResult) { return std::unexpected(webResult.error()); } - ESP_LOGI(kTag, "STA mode ready"); + ESP_LOGI(kTag, "STA mode ready — hostname %.*s.local", + static_cast(deviceIdentity.hostname().size()), + deviceIdentity.hostname().data()); return NetBootstrap(std::nullopt, std::move(sta), std::move(webServer), NetState::StaConnected); } @@ -176,7 +186,8 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, - core::CompanionChipStatus companionChips) + core::CompanionChipStatus companionChips, + const core::DeviceIdentity& deviceIdentity) { if (auto platform = initPlatform(); !platform) { return std::unexpected(platform.error()); @@ -188,7 +199,8 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, if (store.hasWifiCredentials()) { auto staResult = startStaMode(store, tuner, audio, bluetooth, stations, - integration, companionChips); + integration, companionChips, + deviceIdentity); if (staResult) { return staResult; } @@ -196,7 +208,7 @@ NetBootstrap::start(core::ISecureStore& store, tuner::TunerService& tuner, } return startSetupMode(store, tuner, audio, bluetooth, stations, integration, - companionChips); + companionChips, deviceIdentity); } NetBootstrap::NetBootstrap(std::optional softAp, diff --git a/Software/components/net/src/SetupWebServer.cpp b/Software/components/net/src/SetupWebServer.cpp index a27ec03..6661aa2 100644 --- a/Software/components/net/src/SetupWebServer.cpp +++ b/Software/components/net/src/SetupWebServer.cpp @@ -217,7 +217,9 @@ esp_err_t healthGetHandler(httpd_req_t* req) .bt1035Ready = false, }; const core::HealthStatus status = core::HealthStatus::ok( - core::FirmwareVersion(kFirmwareVersion), chips); + core::FirmwareVersion(kFirmwareVersion), chips, + ctx != nullptr ? ctx->deviceIdentity.serialNumber() + : std::string_view("unknown")); const std::string json = core::serializeHealthStatusJson(status); httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, json.c_str(), json.size()); @@ -916,7 +918,7 @@ SetupWebServer::SetupWebServer(SetupWebServer&& other) noexcept other.stations_ = nullptr; other.integration_ = nullptr; other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, - nullptr, {}}; + nullptr, {}, core::DeviceIdentity::unknown()}; } SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept @@ -943,7 +945,7 @@ SetupWebServer& SetupWebServer::operator=(SetupWebServer&& other) noexcept other.stations_ = nullptr; other.integration_ = nullptr; other.routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, - nullptr, {}}; + nullptr, {}, core::DeviceIdentity::unknown()}; } return *this; } @@ -954,7 +956,8 @@ SetupWebServer::~SetupWebServer() httpd_stop(server_); server_ = nullptr; } - routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, {}}; + routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, {}, + core::DeviceIdentity::unknown()}; } std::expected SetupWebServer::start( @@ -965,7 +968,8 @@ std::expected SetupWebServer::start( bluetooth::BluetoothService& bluetooth, station::StationService& stations, integration::IntegrationService& integration, - core::CompanionChipStatus companionChips) + core::CompanionChipStatus companionChips, + const core::DeviceIdentity& deviceIdentity) { if (server_ != nullptr) { return {}; @@ -985,6 +989,7 @@ std::expected SetupWebServer::start( routeContext_.stations = &stations; routeContext_.integration = &integration; routeContext_.companionChips = companionChips; + routeContext_.deviceIdentity = deviceIdentity; httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = 80; @@ -993,7 +998,7 @@ std::expected SetupWebServer::start( if (httpd_start(&server_, &config) != ESP_OK) { ESP_LOGE(kTag, "httpd_start failed"); routeContext_ = {nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, - {}}; + {}, core::DeviceIdentity::unknown()}; return std::unexpected(NetError::HttpServerStartFailed); } diff --git a/Software/components/net/src/SoftApConfig.cpp b/Software/components/net/src/SoftApConfig.cpp index 95c11ab..4cd18bc 100644 --- a/Software/components/net/src/SoftApConfig.cpp +++ b/Software/components/net/src/SoftApConfig.cpp @@ -28,7 +28,12 @@ constexpr std::uint8_t kSetupMaxConnections = 4; SoftApConfig SoftApConfig::setupDefault() { - return SoftApConfig(kSetupSsid, kSetupChannel, kSetupMaxConnections); + return forSsid(kSetupSsid); +} + +SoftApConfig SoftApConfig::forSsid(std::string_view ssid) +{ + return SoftApConfig(ssid, kSetupChannel, kSetupMaxConnections); } SoftApConfig::SoftApConfig(std::string_view ssid, diff --git a/Software/components/net/src/StaClient.cpp b/Software/components/net/src/StaClient.cpp index 301a7e4..946afb9 100644 --- a/Software/components/net/src/StaClient.cpp +++ b/Software/components/net/src/StaClient.cpp @@ -20,12 +20,15 @@ #include "esp_event.h" #include "esp_log.h" +#include "esp_netif.h" #include "esp_wifi.h" +#include "mdns.h" #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" #include #include +#include namespace net { @@ -104,12 +107,17 @@ StaClient& StaClient::operator=(StaClient&& other) noexcept } std::expected -StaClient::connect(const core::WifiCredentials& creds) +StaClient::connect(const core::WifiCredentials& creds, std::string_view hostname) { if (connected_) { return {}; } + std::string hostLabel; + if (!hostname.empty()) { + hostLabel.assign(hostname.begin(), hostname.end()); + } + s_wifiEventGroup = xEventGroupCreate(); if (s_wifiEventGroup == nullptr) { return std::unexpected(NetError::StaConnectFailed); @@ -132,6 +140,13 @@ StaClient::connect(const core::WifiCredentials& creds) return std::unexpected(NetError::WifiConfigFailed); } + if (!hostLabel.empty()) { + esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); + if (netif != nullptr) { + esp_netif_set_hostname(netif, hostLabel.c_str()); + } + } + wifi_config_t wifiCfg = {}; const std::string_view ssid = creds.ssid().value(); const std::size_t ssidCopy = @@ -167,6 +182,11 @@ StaClient::connect(const core::WifiCredentials& creds) if ((bits & kConnectedBit) != 0) { connected_ = true; + if (!hostLabel.empty()) { + if (mdns_init() == ESP_OK) { + mdns_hostname_set(hostLabel.c_str()); + } + } ESP_LOGI(kTag, "connected to %.*s", static_cast(ssid.size()), ssid.data()); return {}; diff --git a/Software/docs/manual/ch-api.tex b/Software/docs/manual/ch-api.tex index 426f9e5..4994989 100644 --- a/Software/docs/manual/ch-api.tex +++ b/Software/docs/manual/ch-api.tex @@ -36,7 +36,7 @@ Returns a health-check DTO serialised by \begin{drnote}[Response schema] \begin{drcode}[JSON] -{"status":"ok","fw":"0.8.3", +{"status":"ok","fw":"0.8.3","serialNumber":"0004A3123456", "chips":{"si4684":true,"adau1701":true,"bt1035":true}} \end{drcode} \begin{itemize} @@ -44,6 +44,8 @@ Returns a health-check DTO serialised by firmware is running normally. \item \texttt{fw} --- firmware release string (\texttt{core::FirmwareVersion}). + \item \texttt{serialNumber} --- canonical EEPROM serial, or + \texttt{unknown} when the 24AA025E48 read fails. \item \texttt{chips} --- companion-chip boot flags after \texttt{HardwareBootstrap::boot()} (Slice~8 integration). \end{itemize} diff --git a/Software/docs/manual/ch-classes.tex b/Software/docs/manual/ch-classes.tex index 47c2db4..89a07f8 100644 --- a/Software/docs/manual/ch-classes.tex +++ b/Software/docs/manual/ch-classes.tex @@ -36,9 +36,26 @@ trusted downstream by \texttt{ITuner}, \texttt{Si4684Driver}, and status DTOs. \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. +\texttt{HealthStatus::ok(FirmwareVersion, CompanionChipStatus, serialNumber)} +in the pure core; JSON serialisation lives in \texttt{serializeHealthStatusJson()}. +The \texttt{serialNumber} field carries the EEPROM-derived serial or +\texttt{unknown} when the EUI-48 read fails. No ESP-IDF headers. + +\section{Eui48}\label{cls:Eui48} +Strong type for the factory-programmed six-byte EUI-48 read from the +24AA025E48 at word address \texttt{0xFA} (Microchip DS20001191). Exposes +\texttt{serialNumber()} (12 uppercase hex digits) and \texttt{shortSuffix()} +(last three bytes) for SSID and hostname derivation. + +\section{DeviceIdentity}\label{cls:DeviceIdentity} +Per-board identity derived from \texttt{Eui48}: SoftAP SSID +\texttt{DigiRadio-}, Bluetooth name, STA hostname +\texttt{digiradio-}, and canonical serial. \texttt{unknown()} yields +documented fallbacks (\texttt{DigiRadio-setup}, serial \texttt{unknown}). + +\section{IDeviceIdentitySource}\label{cls:IDeviceIdentitySource} +Port for reading \texttt{DeviceIdentity} from board storage. Implemented in +the shell by \texttt{eeprom24aa::Eeprom24aa} on the shared I2C bus. % ------------------------------------------------------------------ % Network shell (Slice 1) @@ -46,8 +63,9 @@ headers. \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. +limit). \texttt{forSsid()} builds setup parameters from +\texttt{DeviceIdentity::softApSsid()}; \texttt{setupDefault()} keeps the +\texttt{DigiRadio-setup} fallback when the EEPROM read fails. \section{SoftApHost}\label{cls:SoftApHost} RAII wrapper around the ESP-IDF Wi-Fi stack that starts and stops the @@ -147,10 +165,18 @@ RAII I\textsuperscript{2}C driver for the ADAU1701 SigmaDSP (Chapter~\ref{ch:adau1701}). \texttt{boot()} asserts RESET\#, initialises the shared I2C bus, and replays the SigmaStudio export from \texttt{Firmware/ADAU1701-Firmware/} on every power-up (no EEPROM self-boot -on DigiRadio). Runtime mixer, EQ, and master volume updates use the +on DigiRadio). Exposes \texttt{i2cBusHandle()} after boot for the shared +24AA025E48 EEPROM. Runtime mixer, EQ, and master volume updates use the ADAU1701 safeload mechanism via \texttt{applyProfile()} and related methods (Section~\ref{sec:adau1701-driver}). +\section{Eeprom24aa}\label{cls:Eeprom24aa} +I\textsuperscript{2}C shell driver for the 24AA025E48 identity EEPROM +(Chapter~\ref{ch:hardware}, address \texttt{0x52}). Implements +\texttt{IDeviceIdentitySource}: reads six bytes at word address \texttt{0xFA} +per Microchip DS20001191 and builds \texttt{DeviceIdentity}. Called from +\texttt{HardwareBootstrap} after \texttt{Adau1701Driver::boot()}. + \section{Adau1701Dsp}\label{cls:Adau1701Dsp} \texttt{core::IDsp} adapter over \texttt{Adau1701Driver}. Maps domain-level audio control to safeload I\textsuperscript{2}C transactions without exposing diff --git a/Software/main/CMakeLists.txt b/Software/main/CMakeLists.txt index a29752d..778b002 100644 --- a/Software/main/CMakeLists.txt +++ b/Software/main/CMakeLists.txt @@ -3,5 +3,5 @@ idf_component_register( "main.cpp" "hardware_bootstrap.cpp" INCLUDE_DIRS "." - REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration + REQUIRES core net secure_store adau1701 si4684 tuner audio bt1035 bluetooth station integration eeprom24aa ) diff --git a/Software/main/hardware_bootstrap.cpp b/Software/main/hardware_bootstrap.cpp index 40ded40..918b020 100644 --- a/Software/main/hardware_bootstrap.cpp +++ b/Software/main/hardware_bootstrap.cpp @@ -18,6 +18,9 @@ #include "audio/AudioService.hpp" #include "board_pins.hpp" #include "bt1035/Bt1035Driver.hpp" +#include "core/DeviceIdentity.hpp" +#include "driver/i2c_master.h" +#include "eeprom24aa/Eeprom24aa.hpp" #include "secure_store/NvsAudioProfileStore.hpp" #include "si4684/Si4684Band.hpp" #include "si4684/Si4684Driver.hpp" @@ -69,6 +72,7 @@ bt1035::Bt1035Driver gBt1035( .sysCtlGpio = board::pins::Bt1035SysCtl, }); +core::DeviceIdentity gDeviceIdentity = core::DeviceIdentity::unknown(); bool gReady = false; } // namespace @@ -91,6 +95,21 @@ std::expected HardwareBootstrap::boot() } } + const auto* busHandle = + static_cast(gAdau1701.i2cBusHandle()); + eeprom24aa::Eeprom24aa eeprom(busHandle, + static_cast( + board::pins::Eeprom24aaAddr)); + if (auto identity = eeprom.readDeviceIdentity(); identity) { + gDeviceIdentity = std::move(*identity); + ESP_LOGI(kTag, "unit serial %.*s", + static_cast(gDeviceIdentity.serialNumber().size()), + gDeviceIdentity.serialNumber().data()); + } else { + gDeviceIdentity = core::DeviceIdentity::unknown(); + ESP_LOGW(kTag, "EUI-48 read failed — using fallback identity"); + } + if (auto audioResult = gAudioService.loadAndApply(); !audioResult) { ESP_LOGW(kTag, "ADAU1701 profile apply failed"); } @@ -100,6 +119,11 @@ std::expected HardwareBootstrap::boot() return std::unexpected(HardwareBootError::Bt1035BootFailed); } + if (auto nameResult = + gBt1035.setDeviceName(gDeviceIdentity.bluetoothName()); !nameResult) { + ESP_LOGW(kTag, "BT1035 device name set failed"); + } + gReady = true; ESP_LOGI(kTag, "companion chips ready"); return {}; @@ -129,4 +153,9 @@ bt1035::Bt1035Driver& HardwareBootstrap::bt1035Driver() return gBt1035; } +const core::DeviceIdentity& HardwareBootstrap::deviceIdentity() noexcept +{ + return gDeviceIdentity; +} + } // namespace hardware diff --git a/Software/main/hardware_bootstrap.hpp b/Software/main/hardware_bootstrap.hpp index 1373482..9b49c09 100644 --- a/Software/main/hardware_bootstrap.hpp +++ b/Software/main/hardware_bootstrap.hpp @@ -13,6 +13,7 @@ #pragma once #include "core/CompanionChipStatus.hpp" +#include "core/DeviceIdentity.hpp" #include "bt1035/Bt1035Driver.hpp" @@ -124,6 +125,18 @@ public: * @date 2026-07-06 */ [[nodiscard]] static bt1035::Bt1035Driver& bt1035Driver(); + + /** + * @brief deviceIdentity — per-board identity from the 24AA025E48 EUI-48. + * + * @dname deviceIdentity + * @return Reference to identity loaded during boot(). + * @pubstate reads gDeviceIdentity after boot(); unknown() on EEPROM failure. + * + * @author Michele Bigi + * @date 2026-07-07 + */ + [[nodiscard]] static const core::DeviceIdentity& deviceIdentity() noexcept; }; } // namespace hardware diff --git a/Software/main/main.cpp b/Software/main/main.cpp index 567cb65..2c9a3f5 100644 --- a/Software/main/main.cpp +++ b/Software/main/main.cpp @@ -85,7 +85,8 @@ extern "C" void app_main() bluetoothService, stationService, integration, - hardware::HardwareBootstrap::companionChipStatus()); + hardware::HardwareBootstrap::companionChipStatus(), + hardware::HardwareBootstrap::deviceIdentity()); if (!netResult) { ESP_LOGE(kTag, "network bootstrap failed"); return;