Compare commits

...
Author SHA1 Message Date
J. Nick KostonandGitHub 6c41935e2d Merge branch 'dev' into api-noise-c-branch-test 2026-08-16 19:58:10 -07:00
J. Nick KostonandGitHub e1c279718f [ld2420] Drop the setup priority override so setup runs after the UART bus (#18428) 2026-08-16 21:57:12 -05:00
J. Nick KostonandGitHub cf764740cf [api] Create the camera image reader lazily (#18421) 2026-08-16 21:56:58 -05:00
J. Nick KostonandGitHub 58d549ed4c [api] Move NoiseProtocolId off the connection object (#18420) 2026-08-16 21:56:37 -05:00
Jesse Hills 6b22d8068d Merge branch 'beta' into dev 2026-08-17 11:36:10 +12:00
Jesse HillsandGitHub 443d8f1f28 Merge pull request #18424 from esphome/bump-2026.8.0b4
2026.8.0b4
2026-08-17 11:33:27 +12:00
J. Nick Koston ee3d0f6bce [api] Test noise-c chachapoly-stack-scratch branch 2026-08-16 17:28:11 -05:00
Jesse Hills 1ec21a2245 Bump version to 2026.8.0b4 2026-08-17 10:22:41 +12:00
J. Nick KostonandJesse Hills bb7d4c3630 [esp32] Split crash handler addr2line hint per core (#18418) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills f42fe9af29 [core] Skip redundant ESP8266 main loop wake posts from ISR context (#18416) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills 0bc2d71370 [bk72xx_ble] Fail early with a clear error on non BLE 5.x SoCs (#18406) 2026-08-17 10:22:39 +12:00
luar123andJesse Hills 594c12b3d9 [zigbee] bump esp-zigbee-sdk to 2.0.4 (#18415) 2026-08-17 10:22:39 +12:00
Jonathan SwobodaandJesse Hills bca72e9b6d [sensor] Pass NaN through the delta filter again (#18400) 2026-08-17 10:22:39 +12:00
J. Nick KostonandJesse Hills ce09504c92 [platformio] Skip ccache when the binary on PATH fails to run (#18407) 2026-08-17 10:22:39 +12:00
esphome[bot]andJesse Hills dda4566b9e Bump bundled esphome-device-builder to 1.11.0 (#18403) 2026-08-17 10:22:39 +12:00
Samuel SiebandJesse Hills 46a5665a66 [rotary_encoder] account for min and max value when resetting (#18197) 2026-08-17 10:22:39 +12:00
esphome[bot]andJesse Hills 9161f74bb1 Bump bundled esphome-device-builder to 1.10.0 (#18389) 2026-08-17 10:22:39 +12:00
13 changed files with 267 additions and 35 deletions
+5 -1
View File
@@ -497,7 +497,11 @@ 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.11")
cg.add_library(
"noise-c",
None,
"https://github.com/esphome-libs/noise-c#chachapoly-stack-scratch",
)
# 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")
+13 -13
View File
@@ -160,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr<socket::Socket> sock, APIServer *pa
#else
#error "No frame helper defined"
#endif
#ifdef USE_CAMERA
if (camera::Camera::instance() != nullptr) {
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
}
#endif
}
void APIConnection::start() {
@@ -1140,6 +1135,7 @@ void APIConnection::try_send_camera_image_() {
if (!this->image_reader_)
return;
const auto *cam = camera::Camera::instance();
// Send as many chunks as possible without blocking
while (this->image_reader_->available()) {
if (!this->helper_->can_write_without_blocking())
@@ -1149,11 +1145,11 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg;
msg.key = camera::Camera::instance()->get_object_id_hash();
msg.key = cam->get_object_id_hash();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
msg.device_id = camera::Camera::instance()->get_device_id();
msg.device_id = cam->get_device_id();
#endif
if (!this->send_message(msg)) {
@@ -1169,15 +1165,19 @@ void APIConnection::try_send_camera_image_() {
void APIConnection::set_camera_state(std::shared_ptr<camera::CameraImage> image) {
if (!this->flags_.state_subscription)
return;
if (!this->image_reader_)
if (this->image_reader_ && this->image_reader_->available())
return;
if (this->image_reader_->available())
if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
return;
if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
this->image_reader_->set_image(std::move(image));
// Try to send immediately to reduce latency
this->try_send_camera_image_();
if (!this->image_reader_) {
// Created on the first image this connection will send, so connections
// that never receive one never pay for a reader. Only a registered
// camera's listener can reach this, so instance() is non-null here.
this->image_reader_ = std::unique_ptr<camera::CameraImageReader>{camera::Camera::instance()->create_image_reader()};
}
this->image_reader_->set_image(std::move(image));
// Try to send immediately to reduce latency
this->try_send_camera_image_();
}
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *camera = static_cast<camera::Camera *>(entity);
@@ -591,18 +591,21 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
*/
APIError APINoiseFrameHelper::init_handshake_() {
int err;
memset(&nid_, 0, sizeof(nid_));
// const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
// err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
nid_.pattern_id = NOISE_PATTERN_NN;
nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
nid_.dh_id = NOISE_DH_CURVE25519;
nid_.prefix_id = NOISE_PREFIX_STANDARD;
nid_.hybrid_id = NOISE_DH_NONE;
nid_.hash_id = NOISE_HASH_SHA256;
nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
// 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,
};
err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
err = noise_handshakestate_new_by_id(&handshake_, &nid, NOISE_ROLE_RESPONDER);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
@@ -63,9 +63,6 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Buffer for noise handshake prologue (released after handshake)
APIBuffer prologue_;
// NoiseProtocolId (size depends on implementation)
NoiseProtocolId nid_;
// Group small types together
// Fixed-size header buffer for noise protocol:
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
+2 -1
View File
@@ -103,7 +103,8 @@ struct CameraImageSpec {
/** Abstract camera base class. Collaborates with API.
* 1) API server starts and registers as a listener (add_listener)
* to receive new images from the camera.
* 2) New API client connects and creates a new image reader (create_image_reader).
* 2) API connection creates an image reader (create_image_reader) when it receives
* the first image it will send.
* 3) API connection receives protobuf CameraImageRequest and calls request_image.
* 3.a) API connection receives protobuf CameraImageRequest and calls start_stream.
* 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners.
-2
View File
@@ -184,8 +184,6 @@ static int32_t get_firmware_int(const char *version_string) {
return result;
}
float LD2420Component::get_setup_priority() const { return setup_priority::BUS; }
void LD2420Component::dump_config() {
ESP_LOGCONFIG(TAG,
"LD2420:\n"
-1
View File
@@ -105,7 +105,6 @@ class LD2420Component final : public Component, public uart::UARTDevice {
void apply_config_action();
void factory_reset_action();
void revert_config_action();
float get_setup_priority() const override;
int send_cmd_from_array(CmdFrameT cmd_frame);
void report_gate_data();
void handle_cmd_error(uint16_t error);
+3 -3
View File
@@ -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.11 ; api
https://github.com/esphome-libs/noise-c.git#chachapoly-stack-scratch ; api
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.11 ; api
https://github.com/esphome-libs/noise-c.git#chachapoly-stack-scratch ; api
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.11 ; used by api
https://github.com/esphome-libs/noise-c.git#chachapoly-stack-scratch ; used by api
lvgl/lvgl@9.5.0 ; lvgl
build_flags =
${common.build_flags}
@@ -0,0 +1,19 @@
esphome:
name: camera-mock-test
host:
api:
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
mock_camera:
name: Mock Camera
# Larger than MAX_BATCH_PACKET_SIZE (1390) so the image is split across
# multiple CameraImageResponse chunks and the client must reassemble.
# Must match IMAGE_SIZE in test_camera_mock.py.
image_size: 4096
@@ -0,0 +1,28 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.core.entity_helpers import setup_entity
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/tests"]
AUTO_LOAD = ["camera"]
CONF_IMAGE_SIZE = "image_size"
mock_camera_ns = cg.esphome_ns.namespace("mock_camera")
MockCamera = mock_camera_ns.class_("MockCamera", cg.Component, cg.EntityBase)
CONFIG_SCHEMA = cv.ENTITY_BASE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(MockCamera),
cv.Optional(CONF_IMAGE_SIZE, default=1024): cv.positive_not_null_int,
}
).extend(cv.COMPONENT_SCHEMA)
async def to_code(config: ConfigType) -> None:
cg.add_define("USE_CAMERA")
var = cg.new_Pvariable(config[CONF_ID])
await setup_entity(var, config, "camera")
await cg.register_component(var, config)
cg.add(var.set_image_size(config[CONF_IMAGE_SIZE]))
@@ -0,0 +1,30 @@
#include "mock_camera.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::mock_camera {
static const char *const TAG = "mock_camera";
void MockCamera::loop() {
uint8_t requesters = this->single_requesters_ | this->stream_requesters_;
if (requesters == 0)
return;
uint32_t now = App.get_loop_component_start_time();
if (now - this->last_frame_ms_ < FRAME_INTERVAL_MS)
return;
this->last_frame_ms_ = now;
this->single_requesters_ = 0;
auto image = std::make_shared<MockCameraImage>(this->image_size_, this->frame_counter_, requesters);
ESP_LOGV(TAG, "Producing frame %u (%u bytes, requesters 0x%02X)", this->frame_counter_, this->image_size_,
requesters);
this->frame_counter_++;
for (auto *listener : this->listeners_) {
listener->on_camera_image(image);
}
}
void MockCamera::dump_config() { ESP_LOGCONFIG(TAG, "Mock Camera (%u byte frames)", this->image_size_); }
} // namespace esphome::mock_camera
@@ -0,0 +1,80 @@
#pragma once
#include "esphome/components/camera/camera.h"
#include "esphome/core/component.h"
#include <memory>
#include <vector>
namespace esphome::mock_camera {
/** Deterministic in-memory camera image.
* Byte i of frame N is (N + i) & 0xFF so tests can validate
* reassembled data from just the first byte.
*/
class MockCameraImage : public camera::CameraImage {
public:
MockCameraImage(size_t size, uint8_t frame_counter, uint8_t requesters)
: data_(new uint8_t[size]), size_(size), requesters_(requesters) {
for (size_t i = 0; i < size; i++) {
this->data_[i] = static_cast<uint8_t>(frame_counter + i);
}
}
uint8_t *get_data_buffer() override { return this->data_.get(); }
size_t get_data_length() override { return this->size_; }
bool was_requested_by(camera::CameraRequester requester) const override {
return (this->requesters_ & (1 << requester)) != 0;
}
protected:
std::unique_ptr<uint8_t[]> data_;
size_t size_;
uint8_t requesters_;
};
class MockCameraImageReader : public camera::CameraImageReader {
public:
void set_image(std::shared_ptr<camera::CameraImage> image) override {
this->image_ = std::move(image);
this->offset_ = 0;
}
size_t available() const override { return this->image_ ? this->image_->get_data_length() - this->offset_ : 0; }
uint8_t *peek_data_buffer() override { return this->image_->get_data_buffer() + this->offset_; }
void consume_data(size_t consumed) override { this->offset_ += consumed; }
void return_image() override {
this->image_.reset();
this->offset_ = 0;
}
protected:
std::shared_ptr<camera::CameraImage> image_;
size_t offset_{0};
};
/** Virtual camera producing deterministic frames on request or stream. */
class MockCamera : public camera::Camera {
public:
void loop() override;
void dump_config() override;
void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); }
camera::CameraImageReader *create_image_reader() override { return new MockCameraImageReader(); }
void request_image(camera::CameraRequester requester) override { this->single_requesters_ |= (1 << requester); }
void start_stream(camera::CameraRequester requester) override { this->stream_requesters_ |= (1 << requester); }
void stop_stream(camera::CameraRequester requester) override { this->stream_requesters_ &= ~(1 << requester); }
void set_image_size(uint32_t size) { this->image_size_ = size; }
protected:
static constexpr uint32_t FRAME_INTERVAL_MS = 50;
// Members ordered largest to smallest to minimize padding
std::vector<camera::CameraListener *> listeners_;
uint32_t image_size_{1024};
uint32_t last_frame_ms_{0};
uint8_t frame_counter_{0};
uint8_t single_requesters_{0};
uint8_t stream_requesters_{0};
};
} // namespace esphome::mock_camera
+73
View File
@@ -0,0 +1,73 @@
"""Integration test for the camera API flow using a mock camera platform."""
from __future__ import annotations
import asyncio
from aioesphomeapi import CameraInfo, CameraState, EntityState
import pytest
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
# Must match image_size in fixtures/camera_mock.yaml
IMAGE_SIZE = 4096
STREAM_FRAMES = 3
def _verify_frame(data: bytes) -> int:
"""Verify the deterministic frame pattern and return the frame counter."""
assert len(data) == IMAGE_SIZE, f"expected {IMAGE_SIZE} bytes, got {len(data)}"
counter = data[0]
assert data == bytes((counter + i) & 0xFF for i in range(IMAGE_SIZE)), (
"frame pattern mismatch"
)
return counter
@pytest.mark.asyncio
async def test_camera_mock(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Single-image and stream requests deliver reassembled deterministic frames."""
async with run_compiled(yaml_config), api_client_connected() as client:
entities, _ = await client.list_entities_services()
camera = require_entity(entities, "mock_camera", CameraInfo)
loop = asyncio.get_running_loop()
images: list[bytes] = []
single_image: asyncio.Future[None] = loop.create_future()
stream_done: asyncio.Future[None] = loop.create_future()
def on_state(state: EntityState) -> None:
if not (isinstance(state, CameraState) and state.key == camera.key):
return
images.append(bytes(state.data))
if not single_image.done():
single_image.set_result(None)
elif len(images) >= STREAM_FRAMES and not stream_done.done():
stream_done.set_result(None)
client.subscribe_states(on_state)
# Single image request: one complete frame arrives, reassembled
# from multiple chunks (4096 > 1390 byte packets)
client.request_single_image()
await asyncio.wait_for(single_image, timeout=10)
first_counter = _verify_frame(images[0])
# Stream request: multiple consecutive frames arrive
images.clear()
client.request_image_stream()
await asyncio.wait_for(stream_done, timeout=10)
# Frames are distinct, ordered, and fresh per the mock's counter.
# Not exactly consecutive: the API drops frames by design while the
# previous image is still being sent, so allow small gaps.
counters = [_verify_frame(img) for img in images[:STREAM_FRAMES]]
for prev, cur in zip(counters, counters[1:], strict=False):
assert cur != prev, f"duplicate frames: {counters}"
assert ((cur - prev) & 0xFF) < 16, f"frames out of order: {counters}"
assert counters[0] != first_counter, "stream should produce new frames"