From 08231c91d4dd9dcfedf8a34713c9e0e5b261cdbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 18 Aug 2026 09:43:47 -0500 Subject: [PATCH] Add shared noise component and move noise-c primitives out of api --- CODEOWNERS | 1 + esphome/components/api/__init__.py | 12 +-- .../components/api/api_frame_helper_noise.cpp | 52 +----------- esphome/components/api/api_noise_context.h | 37 ++------- esphome/components/noise/__init__.py | 26 ++++++ esphome/components/noise/noise.cpp | 62 ++++++++++++++ esphome/components/noise/noise.h | 41 ++++++++++ esphome/components/noise/noise_handshake.cpp | 80 +++++++++++++++++++ esphome/components/noise/noise_handshake.h | 47 +++++++++++ esphome/core/defines.h | 1 + platformio.ini | 6 +- tests/benchmarks/components/api/__init__.py | 5 +- tests/benchmarks/components/noise/__init__.py | 7 ++ tests/components/noise/common.yaml | 1 + tests/components/noise/test.esp32-idf.yaml | 2 + tests/components/noise/test.esp8266-ard.yaml | 2 + tests/components/noise/test.host.yaml | 2 + tests/components/noise/test.rp2040-ard.yaml | 2 + 18 files changed, 297 insertions(+), 89 deletions(-) create mode 100644 esphome/components/noise/__init__.py create mode 100644 esphome/components/noise/noise.cpp create mode 100644 esphome/components/noise/noise.h create mode 100644 esphome/components/noise/noise_handshake.cpp create mode 100644 esphome/components/noise/noise_handshake.h create mode 100644 tests/benchmarks/components/noise/__init__.py create mode 100644 tests/components/noise/common.yaml create mode 100644 tests/components/noise/test.esp32-idf.yaml create mode 100644 tests/components/noise/test.esp8266-ard.yaml create mode 100644 tests/components/noise/test.host.yaml create mode 100644 tests/components/noise/test.rp2040-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9ddbca5c71..3047072ea2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -381,6 +381,7 @@ esphome/components/nextion/switch/* @senexcrenshaw esphome/components/nextion/text_sensor/* @senexcrenshaw esphome/components/nfc/* @jesserockz @kbx81 esphome/components/noblex/* @AGalfra +esphome/components/noise/* @esphome/core esphome/components/npi19/* @bakerkj esphome/components/nrf52/* @tomaszduda23 esphome/components/number/* @esphome/core diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 0dc4b905bf..a07b965f95 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -46,9 +46,15 @@ CODEOWNERS = ["@esphome/core"] def AUTO_LOAD(config: ConfigType) -> list[str]: - """Conditionally auto-load json only when capture_response is used.""" + """Conditionally auto-load noise (encryption) and json (capture_response).""" base = ["socket"] + # A falsy config is a tooling probe for the maximal set (None from + # dependency resolution, {} from the components-graph platform probe); + # a validated config always carries defaults, never empty + if not config or CONF_ENCRYPTION in config: + base = base + ["noise"] + # Check if any homeassistant.action/homeassistant.service has capture_response: true # This flag is set during config validation in _validate_response_config if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False): @@ -498,10 +504,6 @@ async def to_code(config: ConfigType) -> None: # and plaintext disabled. Only a factory reset can remove it. cg.add_define("USE_API_PLAINTEXT") cg.add_define("USE_API_NOISE") - cg.add_library("esphome/noise-c", "0.1.21") - # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops - cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") - cg.add_build_flag("-DHAVE_INLINE_ASM=1") else: cg.add_define("USE_API_PLAINTEXT") diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 09e3ca2b9e..ae8e37a676 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -2,6 +2,7 @@ #ifdef USE_API #ifdef USE_API_NOISE #include "api_connection.h" // For ClientInfo struct +#include "esphome/components/noise/noise.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" @@ -17,6 +18,8 @@ namespace esphome::api { +using noise::noise_err_to_logstr; + static const char *const TAG = "api.noise"; #ifdef USE_ESP8266 static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit"; @@ -51,45 +54,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168; #define LOG_PACKET_RECEIVED(buffer) ((void) 0) #endif -/// Convert a noise error code to a readable error -const LogString *noise_err_to_logstr(int err) { - if (err == NOISE_ERROR_NO_MEMORY) - return LOG_STR("NO_MEMORY"); - if (err == NOISE_ERROR_UNKNOWN_ID) - return LOG_STR("UNKNOWN_ID"); - if (err == NOISE_ERROR_UNKNOWN_NAME) - return LOG_STR("UNKNOWN_NAME"); - if (err == NOISE_ERROR_MAC_FAILURE) - return LOG_STR("MAC_FAILURE"); - if (err == NOISE_ERROR_NOT_APPLICABLE) - return LOG_STR("NOT_APPLICABLE"); - if (err == NOISE_ERROR_SYSTEM) - return LOG_STR("SYSTEM"); - if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED) - return LOG_STR("REMOTE_KEY_REQUIRED"); - if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED) - return LOG_STR("LOCAL_KEY_REQUIRED"); - if (err == NOISE_ERROR_PSK_REQUIRED) - return LOG_STR("PSK_REQUIRED"); - if (err == NOISE_ERROR_INVALID_LENGTH) - return LOG_STR("INVALID_LENGTH"); - if (err == NOISE_ERROR_INVALID_PARAM) - return LOG_STR("INVALID_PARAM"); - if (err == NOISE_ERROR_INVALID_STATE) - return LOG_STR("INVALID_STATE"); - if (err == NOISE_ERROR_INVALID_NONCE) - return LOG_STR("INVALID_NONCE"); - if (err == NOISE_ERROR_INVALID_PRIVATE_KEY) - return LOG_STR("INVALID_PRIVATE_KEY"); - if (err == NOISE_ERROR_INVALID_PUBLIC_KEY) - return LOG_STR("INVALID_PUBLIC_KEY"); - if (err == NOISE_ERROR_INVALID_FORMAT) - return LOG_STR("INVALID_FORMAT"); - if (err == NOISE_ERROR_INVALID_SIGNATURE) - return LOG_STR("INVALID_SIGNATURE"); - return LOG_STR("UNKNOWN"); -} - /// Initialize the frame helper, returns OK if successful. APIError APINoiseFrameHelper::init() { APIError err = init_common_(); @@ -675,16 +639,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() { } } -extern "C" { -// declare how noise generates random bytes (here with a good HWRNG based on the RF system) -void noise_rand_bytes(void *output, size_t len) { - if (!esphome::random_bytes(reinterpret_cast(output), len)) { - ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting"); - arch_restart(); - } -} -} - } // namespace esphome::api #endif // USE_API_NOISE #endif // USE_API diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h index 44484ffa2c..b3b00bf00c 100644 --- a/esphome/components/api/api_noise_context.h +++ b/esphome/components/api/api_noise_context.h @@ -1,37 +1,14 @@ #pragma once -#include -#include #include "esphome/core/defines.h" +#ifdef USE_API_NOISE +#include "esphome/components/noise/noise.h" + namespace esphome::api { -#ifdef USE_API_NOISE -using psk_t = std::array; - -class APINoiseContext { - public: - // The all-zeros PSK is reserved: it marks the device as unprovisioned and - // doubles as the well-known provisioning PSK that unprovisioned devices - // accept for Noise handshakes (passive-sniffing protection only, no - // authentication). It is never a valid real key. - static bool is_all_zeros(const psk_t &psk) { - uint8_t acc = 0; - for (uint8_t b : psk) { - acc |= b; - } - return acc == 0; - } - void set_psk(psk_t psk) { - this->psk_ = psk; - this->has_psk_ = !is_all_zeros(psk); - } - const psk_t &get_psk() const { return this->psk_; } - bool has_psk() const { return this->has_psk_; } - - protected: - psk_t psk_{}; - bool has_psk_{false}; -}; -#endif // USE_API_NOISE +// Kept as aliases for external components that use the api names +using psk_t = noise::psk_t; +using APINoiseContext = noise::NoiseContext; } // namespace esphome::api +#endif // USE_API_NOISE diff --git a/esphome/components/noise/__init__.py b/esphome/components/noise/__init__.py new file mode 100644 index 0000000000..f570bb1adc --- /dev/null +++ b/esphome/components/noise/__init__.py @@ -0,0 +1,26 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.core import CORE +from esphome.types import ConfigType + +CODEOWNERS = ["@esphome/core"] + +noise_ns = cg.esphome_ns.namespace("noise") + +CONFIG_SCHEMA = cv.Schema({}) + + +async def to_code(config: ConfigType) -> None: + cg.add_define("USE_NOISE") + cg.add_library("esphome/noise-c", "0.1.21") + # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops + cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1") + cg.add_build_flag("-DHAVE_INLINE_ASM=1") + # noise-c pulls noise_rand_bytes (our HWRNG binding, defined in noise.cpp) + # from the static archive. A consumer such as api may reference no other + # symbol from noise.cpp at normal log levels, so on the ESP-IDF link (which + # resolves archives in a group) the member is dropped and noise-c fails to + # link. Force the linker to keep it. The host toolchain links it without + # help, and its ld syntax differs (leading underscore), so skip it there. + if not CORE.is_host: + cg.add_build_flag("-Wl,-u,noise_rand_bytes") diff --git a/esphome/components/noise/noise.cpp b/esphome/components/noise/noise.cpp new file mode 100644 index 0000000000..022b559f3a --- /dev/null +++ b/esphome/components/noise/noise.cpp @@ -0,0 +1,62 @@ +#include "noise.h" +#ifdef USE_NOISE +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::noise { + +static const char *const TAG = "noise"; + +const LogString *noise_err_to_logstr(int err) { + if (err == NOISE_ERROR_NO_MEMORY) + return LOG_STR("NO_MEMORY"); + if (err == NOISE_ERROR_UNKNOWN_ID) + return LOG_STR("UNKNOWN_ID"); + if (err == NOISE_ERROR_UNKNOWN_NAME) + return LOG_STR("UNKNOWN_NAME"); + if (err == NOISE_ERROR_MAC_FAILURE) + return LOG_STR("MAC_FAILURE"); + if (err == NOISE_ERROR_NOT_APPLICABLE) + return LOG_STR("NOT_APPLICABLE"); + if (err == NOISE_ERROR_SYSTEM) + return LOG_STR("SYSTEM"); + if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED) + return LOG_STR("REMOTE_KEY_REQUIRED"); + if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED) + return LOG_STR("LOCAL_KEY_REQUIRED"); + if (err == NOISE_ERROR_PSK_REQUIRED) + return LOG_STR("PSK_REQUIRED"); + if (err == NOISE_ERROR_INVALID_LENGTH) + return LOG_STR("INVALID_LENGTH"); + if (err == NOISE_ERROR_INVALID_PARAM) + return LOG_STR("INVALID_PARAM"); + if (err == NOISE_ERROR_INVALID_STATE) + return LOG_STR("INVALID_STATE"); + if (err == NOISE_ERROR_INVALID_NONCE) + return LOG_STR("INVALID_NONCE"); + if (err == NOISE_ERROR_INVALID_PRIVATE_KEY) + return LOG_STR("INVALID_PRIVATE_KEY"); + if (err == NOISE_ERROR_INVALID_PUBLIC_KEY) + return LOG_STR("INVALID_PUBLIC_KEY"); + if (err == NOISE_ERROR_INVALID_FORMAT) + return LOG_STR("INVALID_FORMAT"); + if (err == NOISE_ERROR_INVALID_SIGNATURE) + return LOG_STR("INVALID_SIGNATURE"); + return LOG_STR("UNKNOWN"); +} + +extern "C" { +// declare how noise generates random bytes (here with a good HWRNG based on the RF system) +void noise_rand_bytes(void *output, size_t len) { + if (!esphome::random_bytes(reinterpret_cast(output), len)) { + ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting"); + arch_restart(); + } +} +} + +} // namespace esphome::noise +#endif // USE_NOISE diff --git a/esphome/components/noise/noise.h b/esphome/components/noise/noise.h new file mode 100644 index 0000000000..b2c05b9842 --- /dev/null +++ b/esphome/components/noise/noise.h @@ -0,0 +1,41 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_NOISE +#include +#include +#include "esphome/core/log.h" + +namespace esphome::noise { + +using psk_t = std::array; + +class NoiseContext { + public: + // The all-zeros PSK is reserved: it marks the device as unprovisioned and + // doubles as the well-known provisioning PSK that unprovisioned devices + // accept for Noise handshakes (passive-sniffing protection only, no + // authentication). It is never a valid real key. + static bool is_all_zeros(const psk_t &psk) { + uint8_t acc = 0; + for (uint8_t b : psk) { + acc |= b; + } + return acc == 0; + } + void set_psk(psk_t psk) { + this->psk_ = psk; + this->has_psk_ = !is_all_zeros(psk); + } + const psk_t &get_psk() const { return this->psk_; } + bool has_psk() const { return this->has_psk_; } + + protected: + psk_t psk_{}; + bool has_psk_{false}; +}; + +/// Convert a noise error code to a readable error +const LogString *noise_err_to_logstr(int err); + +} // namespace esphome::noise +#endif // USE_NOISE diff --git a/esphome/components/noise/noise_handshake.cpp b/esphome/components/noise/noise_handshake.cpp new file mode 100644 index 0000000000..92dbf54050 --- /dev/null +++ b/esphome/components/noise/noise_handshake.cpp @@ -0,0 +1,80 @@ +#include "noise_handshake.h" +#ifdef USE_NOISE + +namespace esphome::noise { + +NoiseResponderHandshake::~NoiseResponderHandshake() { + if (this->handshake_ != nullptr) { + noise_handshakestate_free(this->handshake_); + this->handshake_ = nullptr; + } +} + +int NoiseResponderHandshake::init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len) { + // Noise_NNpsk0_25519_ChaChaPoly_SHA256, built on the stack: + // noise_handshakestate_new_by_id copies it, so a member would waste + // 104 bytes per connection, and a static const would sit in RAM on + // ESP8266 (.rodata is DRAM there). + const NoiseProtocolId nid = { + .prefix_id = NOISE_PREFIX_STANDARD, + .pattern_id = NOISE_PATTERN_NN, + .modifier_ids = {NOISE_MODIFIER_PSK0}, + .dh_id = NOISE_DH_CURVE25519, + .cipher_id = NOISE_CIPHER_CHACHAPOLY, + .hash_id = NOISE_HASH_SHA256, + .hybrid_id = NOISE_DH_NONE, + }; + + int err = noise_handshakestate_new_by_id(&this->handshake_, &nid, NOISE_ROLE_RESPONDER); + if (err != 0) + return err; + err = noise_handshakestate_set_pre_shared_key(this->handshake_, psk.data(), psk.size()); + if (err != 0) + return err; + err = noise_handshakestate_set_prologue(this->handshake_, prologue, prologue_len); + if (err != 0) + return err; + return noise_handshakestate_start(this->handshake_); +} + +NoiseResponderHandshake::Action NoiseResponderHandshake::action() const { + switch (noise_handshakestate_get_action(this->handshake_)) { + case NOISE_ACTION_READ_MESSAGE: + return Action::READ; + case NOISE_ACTION_WRITE_MESSAGE: + return Action::WRITE; + case NOISE_ACTION_SPLIT: + return Action::SPLIT; + default: + return Action::FAILED; + } +} + +int NoiseResponderHandshake::read_message(const uint8_t *data, size_t len) { + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_input(mbuf, const_cast(data), len); + return noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr); +} + +int NoiseResponderHandshake::write_message(uint8_t *out, size_t capacity, size_t &out_len) { + NoiseBuffer mbuf; + noise_buffer_init(mbuf); + noise_buffer_set_output(mbuf, out, capacity); + int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr); + if (err == 0) + out_len = mbuf.size; + return err; +} + +int NoiseResponderHandshake::split(NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher) { + int err = noise_handshakestate_split(this->handshake_, &send_cipher, &recv_cipher); + if (err != 0) + return err; + noise_handshakestate_free(this->handshake_); + this->handshake_ = nullptr; + return 0; +} + +} // namespace esphome::noise +#endif // USE_NOISE diff --git a/esphome/components/noise/noise_handshake.h b/esphome/components/noise/noise_handshake.h new file mode 100644 index 0000000000..3941e9434a --- /dev/null +++ b/esphome/components/noise/noise_handshake.h @@ -0,0 +1,47 @@ +#pragma once +#include "esphome/core/defines.h" +#ifdef USE_NOISE +#include +#include + +#include + +#include "noise.h" + +namespace esphome::noise { + +/** Sans-IO responder side of a Noise_NNpsk0_25519_ChaChaPoly_SHA256 handshake. + * + * Owns only the noise-c handshake state; the caller moves the raw handshake + * messages (no framing) over its own transport, driven by action(): + * read_message() while READ, write_message() while WRITE, then split() to + * take ownership of the transport ciphers. All methods return a noise-c + * error code, 0 on success. + * + * Methods are deliberately small separate functions so callers on tight + * stacks (RP2040 core0 scratch bank) never pay for more than one branch; + * the curve25519 step alone needs ~2KB of stack. + */ +class NoiseResponderHandshake { + public: + enum class Action : uint8_t { READ, WRITE, SPLIT, FAILED }; + + ~NoiseResponderHandshake(); + + /// Create and start the handshake with the given PSK and prologue. + int init(const psk_t &psk, const uint8_t *prologue, size_t prologue_len); + Action action() const; + /// Process one received handshake message. + int read_message(const uint8_t *data, size_t len); + /// Produce the next handshake message into out (out_len receives its size). + int write_message(uint8_t *out, size_t capacity, size_t &out_len); + /// Hand out the transport ciphers and free the handshake state. The caller + /// owns both cipher states and must free them with noise_cipherstate_free(). + int split(NoiseCipherState *&send_cipher, NoiseCipherState *&recv_cipher); + + protected: + NoiseHandshakeState *handshake_{nullptr}; +}; + +} // namespace esphome::noise +#endif // USE_NOISE diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 20aca3776f..5f34437145 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -222,6 +222,7 @@ #define API_MAX_SEND_QUEUE 8 #define MAX_API_CONNECTIONS 6 #define USE_MD5 +#define USE_NOISE #define USE_SHA256 #ifndef USE_RP2 // no MQTT backend or esp_wireguard library on RP2 #define USE_MQTT diff --git a/platformio.ini b/platformio.ini index 4c372cc0bb..b2a36e687c 100644 --- a/platformio.ini +++ b/platformio.ini @@ -45,7 +45,7 @@ lib_deps_base = lib_deps = ${common.lib_deps_base} https://github.com/dudanov/MideaUART.git#eeea6c3e9b4474f067054592b435be1c4e466815 ; midea - esphome/noise-c@0.1.21 ; api + esphome/noise-c@0.1.21 ; noise (api, ota) improv/Improv@1.2.6 ; improv_serial / esp32_improv kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library @@ -244,7 +244,7 @@ lib_deps = ${common:idf-component-libs.lib_deps} ESP32Async/ESPAsyncWebServer@3.9.6 ; web_server_base droscy/esp_wireguard@0.4.5 ; wireguard - esphome/noise-c@0.1.21 ; api + esphome/noise-c@0.1.21 ; noise (api, ota) ESP32Async/AsyncTCP@3.4.5 ; async_tcp DNSServer ; captive_portal heman/AsyncMqttClient-esphome@2.0.0 ; mqtt @@ -641,7 +641,7 @@ build_unflags = extends = common platform = platformio/native lib_deps = - esphome/noise-c@0.1.21 ; used by api + esphome/noise-c@0.1.21 ; used by noise (api, ota) lvgl/lvgl@9.5.0 ; lvgl build_flags = ${common.build_flags} diff --git a/tests/benchmarks/components/api/__init__.py b/tests/benchmarks/components/api/__init__.py index 0565bc5330..12c078911c 100644 --- a/tests/benchmarks/components/api/__init__.py +++ b/tests/benchmarks/components/api/__init__.py @@ -3,8 +3,9 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # api must run its to_code to define USE_API, USE_API_PLAINTEXT, - # and add the noise-c library dependency. + # api must run its to_code to define USE_API and USE_API_NOISE. The + # AUTO_LOADed noise component runs its own to_code via the override in + # tests/benchmarks/components/noise/__init__.py. manifest.enable_codegen() original_to_code = manifest.to_code diff --git a/tests/benchmarks/components/noise/__init__.py b/tests/benchmarks/components/noise/__init__.py new file mode 100644 index 0000000000..f430e9dd36 --- /dev/null +++ b/tests/benchmarks/components/noise/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # to_code must run: it defines USE_NOISE and adds the noise-c library + # the api benchmark sources need. + manifest.enable_codegen() diff --git a/tests/components/noise/common.yaml b/tests/components/noise/common.yaml new file mode 100644 index 0000000000..35253a35e6 --- /dev/null +++ b/tests/components/noise/common.yaml @@ -0,0 +1 @@ +noise: diff --git a/tests/components/noise/test.esp32-idf.yaml b/tests/components/noise/test.esp32-idf.yaml new file mode 100644 index 0000000000..550ffd1f88 --- /dev/null +++ b/tests/components/noise/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml diff --git a/tests/components/noise/test.esp8266-ard.yaml b/tests/components/noise/test.esp8266-ard.yaml new file mode 100644 index 0000000000..550ffd1f88 --- /dev/null +++ b/tests/components/noise/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml diff --git a/tests/components/noise/test.host.yaml b/tests/components/noise/test.host.yaml new file mode 100644 index 0000000000..550ffd1f88 --- /dev/null +++ b/tests/components/noise/test.host.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml diff --git a/tests/components/noise/test.rp2040-ard.yaml b/tests/components/noise/test.rp2040-ard.yaml new file mode 100644 index 0000000000..550ffd1f88 --- /dev/null +++ b/tests/components/noise/test.rp2040-ard.yaml @@ -0,0 +1,2 @@ +packages: + noise: !include common.yaml