1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-21 03:03:50 +01:00

Merge branch 'integration' into memory_api

This commit is contained in:
J. Nick Koston
2025-09-29 10:02:05 -05:00
12 changed files with 129 additions and 25 deletions

View File

@@ -224,6 +224,7 @@ async def to_code(config):
if key := encryption_config.get(CONF_KEY): if key := encryption_config.get(CONF_KEY):
decoded = base64.b64decode(key) decoded = base64.b64decode(key)
cg.add(var.set_noise_psk(list(decoded))) cg.add(var.set_noise_psk(list(decoded)))
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else: else:
# No key provided, but encryption desired # No key provided, but encryption desired
# This will allow a plaintext client to provide a noise key, # This will allow a plaintext client to provide a noise key,

View File

@@ -17,6 +17,16 @@ namespace esphome::api {
// uncomment to log raw packets // uncomment to log raw packets
//#define HELPER_LOG_PACKETS //#define HELPER_LOG_PACKETS
// Maximum message size limits to prevent OOM on constrained devices
// Voice Assistant is our largest user at 1024 bytes per audio chunk
// Using 2048 + 256 bytes overhead = 2304 bytes total to support voice and future needs
// ESP8266 has very limited RAM and cannot support voice assistant
#ifdef USE_ESP8266
static constexpr uint16_t MAX_MESSAGE_SIZE = 512; // Keep small for memory constrained ESP8266
#else
static constexpr uint16_t MAX_MESSAGE_SIZE = 2304; // Support voice (1024) + headroom for larger messages
#endif
// Forward declaration // Forward declaration
struct ClientInfo; struct ClientInfo;

View File

@@ -184,6 +184,13 @@ APIError APINoiseFrameHelper::try_read_frame_(std::vector<uint8_t> *frame) {
return APIError::BAD_HANDSHAKE_PACKET_LEN; return APIError::BAD_HANDSHAKE_PACKET_LEN;
} }
// Check against maximum message size to prevent OOM
if (msg_size > MAX_MESSAGE_SIZE) {
state_ = State::FAILED;
HELPER_LOG("Bad packet: message size %u exceeds maximum %u", msg_size, MAX_MESSAGE_SIZE);
return APIError::BAD_DATA_PACKET;
}
// reserve space for body // reserve space for body
if (rx_buf_.size() != msg_size) { if (rx_buf_.size() != msg_size) {
rx_buf_.resize(msg_size); rx_buf_.resize(msg_size);

View File

@@ -122,10 +122,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_(std::vector<uint8_t> *frame) {
continue; continue;
} }
if (msg_size_varint->as_uint32() > std::numeric_limits<uint16_t>::max()) { if (msg_size_varint->as_uint32() > MAX_MESSAGE_SIZE) {
state_ = State::FAILED; state_ = State::FAILED;
HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(), HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum %u", msg_size_varint->as_uint32(),
std::numeric_limits<uint16_t>::max()); MAX_MESSAGE_SIZE);
return APIError::BAD_DATA_PACKET; return APIError::BAD_DATA_PACKET;
} }
rx_header_parsed_len_ = msg_size_varint->as_uint16(); rx_header_parsed_len_ = msg_size_varint->as_uint16();

View File

@@ -37,12 +37,14 @@ void APIServer::setup() {
this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true); this->noise_pref_ = global_preferences->make_preference<SavedNoisePsk>(hash, true);
#ifndef USE_API_NOISE_PSK_FROM_YAML
// Only load saved PSK if not set from YAML
SavedNoisePsk noise_pref_saved{}; SavedNoisePsk noise_pref_saved{};
if (this->noise_pref_.load(&noise_pref_saved)) { if (this->noise_pref_.load(&noise_pref_saved)) {
ESP_LOGD(TAG, "Loaded saved Noise PSK"); ESP_LOGD(TAG, "Loaded saved Noise PSK");
this->set_noise_psk(noise_pref_saved.psk); this->set_noise_psk(noise_pref_saved.psk);
} }
#endif
#endif #endif
// Schedule reboot if no clients connect within timeout // Schedule reboot if no clients connect within timeout
@@ -419,6 +421,12 @@ void APIServer::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeo
#ifdef USE_API_NOISE #ifdef USE_API_NOISE
bool APIServer::save_noise_psk(psk_t psk, bool make_active) { bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
#ifdef USE_API_NOISE_PSK_FROM_YAML
// When PSK is set from YAML, this function should never be called
// but if it is, reject the change
ESP_LOGW(TAG, "Key set in YAML");
return false;
#else
auto &old_psk = this->noise_ctx_->get_psk(); auto &old_psk = this->noise_ctx_->get_psk();
if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) {
ESP_LOGW(TAG, "New PSK matches old"); ESP_LOGW(TAG, "New PSK matches old");
@@ -447,6 +455,7 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
}); });
} }
return true; return true;
#endif
} }
#endif #endif

View File

@@ -514,7 +514,7 @@ async def to_code_characteristic(service_var, char_conf):
) )
if CONF_VALUE in char_conf: if CONF_VALUE in char_conf:
# Check if the value is templated (Lambda) # Check if the value is templated (Lambda)
value_data = char_conf[CONF_VALUE].get(CONF_DATA) value_data = char_conf[CONF_VALUE][CONF_DATA]
if isinstance(value_data, cv.Lambda): if isinstance(value_data, cv.Lambda):
# Templated value - need the full action infrastructure # Templated value - need the full action infrastructure
action_conf = { action_conf = {

View File

@@ -401,6 +401,12 @@ class DriverChip:
sequence.append((MADCTL, madctl)) sequence.append((MADCTL, madctl))
return madctl return madctl
def skip_command(self, command: str):
"""
Allow suppressing a standard command in the init sequence.
"""
return self.get_default(f"no_{command.lower()}", False)
def get_sequence(self, config) -> tuple[tuple[int, ...], int]: def get_sequence(self, config) -> tuple[tuple[int, ...], int]:
""" """
Create the init sequence for the display. Create the init sequence for the display.
@@ -432,7 +438,9 @@ class DriverChip:
sequence.append((INVOFF,)) sequence.append((INVOFF,))
if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)): if brightness := config.get(CONF_BRIGHTNESS, self.get_default(CONF_BRIGHTNESS)):
sequence.append((BRIGHTNESS, brightness)) sequence.append((BRIGHTNESS, brightness))
sequence.append((SLPOUT,)) # Add a SLPOUT command if required.
if not self.skip_command("SLPOUT"):
sequence.append((SLPOUT,))
sequence.append((DISPON,)) sequence.append((DISPON,))
# Flatten the sequence into a list of bytes, with the length of each command # Flatten the sequence into a list of bytes, with the length of each command

View File

@@ -27,7 +27,8 @@ DriverChip(
bus_mode=TYPE_QUAD, bus_mode=TYPE_QUAD,
brightness=0xD0, brightness=0xD0,
color_order=MODE_RGB, color_order=MODE_RGB,
initsequence=(SLPOUT,), # Requires early SLPOUT no_slpout=True, # SLPOUT is in the init sequence, early
initsequence=(SLPOUT,),
) )
DriverChip( DriverChip(
@@ -95,6 +96,7 @@ CO5300 = DriverChip(
brightness=0xD0, brightness=0xD0,
color_order=MODE_RGB, color_order=MODE_RGB,
bus_mode=TYPE_QUAD, bus_mode=TYPE_QUAD,
no_slpout=True,
initsequence=( initsequence=(
(SLPOUT,), # Requires early SLPOUT (SLPOUT,), # Requires early SLPOUT
(PAGESEL, 0x00), (PAGESEL, 0x00),

View File

@@ -288,11 +288,15 @@ void Sim800LComponent::parse_cmd_(std::string message) {
if (item == 3) { // stat if (item == 3) { // stat
uint8_t current_call_state = parse_number<uint8_t>(message.substr(start, end - start)).value_or(6); uint8_t current_call_state = parse_number<uint8_t>(message.substr(start, end - start)).value_or(6);
if (current_call_state != this->call_state_) { if (current_call_state != this->call_state_) {
ESP_LOGD(TAG, "Call state is now: %d", current_call_state); if (current_call_state == 4) {
if (current_call_state == 0) ESP_LOGV(TAG, "Premature call state '4'. Ignoring, waiting for RING");
this->call_connected_callback_.call(); } else {
this->call_state_ = current_call_state;
ESP_LOGD(TAG, "Call state is now: %d", current_call_state);
if (current_call_state == 0)
this->call_connected_callback_.call();
}
} }
this->call_state_ = current_call_state;
break; break;
} }
// item 4 = "" // item 4 = ""

View File

@@ -0,0 +1,10 @@
esphome:
name: noise-key-test
host:
api:
encryption:
key: "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs="
logger:

View File

@@ -0,0 +1,51 @@
"""Integration test for noise encryption key protection from YAML."""
from __future__ import annotations
import base64
from aioesphomeapi import InvalidEncryptionKeyAPIError
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_noise_encryption_key_protection(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that noise encryption key set in YAML cannot be changed via API."""
# The key that's set in the YAML fixture
noise_psk = "zX9/JHxMKwpP0jUGsF0iESCm1wRvNgR6NkKVOhn7kSs="
# Keep ESPHome process running throughout all tests
async with run_compiled(yaml_config):
# First connection - test key change attempt
async with api_client_connected(noise_psk=noise_psk) as client:
# Verify connection is established
device_info = await client.device_info()
assert device_info is not None
# Try to set a new encryption key via API
new_key = base64.b64encode(
b"x" * 32
) # Valid 32-byte key in base64 as bytes
# This should fail since key was set in YAML
success = await client.noise_encryption_set_key(new_key)
assert success is False
# Reconnect with the original key to verify it still works
async with api_client_connected(noise_psk=noise_psk) as client:
# Verify connection is still successful with original key
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "noise-key-test"
# Verify that connecting with a wrong key fails
wrong_key = base64.b64encode(b"y" * 32).decode() # Different key
with pytest.raises(InvalidEncryptionKeyAPIError):
async with api_client_connected(noise_psk=wrong_key) as client:
await client.device_info()

View File

@@ -15,7 +15,7 @@ async def test_oversized_payload_plaintext(
run_compiled: RunCompiledFunction, run_compiled: RunCompiledFunction,
api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory,
) -> None: ) -> None:
"""Test that oversized payloads (>100KiB) from client cause disconnection without crashing.""" """Test that oversized payloads (>2304 bytes) from client cause disconnection without crashing."""
process_exited = False process_exited = False
helper_log_found = False helper_log_found = False
@@ -39,8 +39,8 @@ async def test_oversized_payload_plaintext(
assert device_info is not None assert device_info is not None
assert device_info.name == "oversized-plaintext" assert device_info.name == "oversized-plaintext"
# Create an oversized payload (>100KiB) # Create an oversized payload (>2304 bytes which is our new limit)
oversized_data = b"X" * (100 * 1024 + 1) # 100KiB + 1 byte oversized_data = b"X" * 3000 # ~3KiB, exceeds the 2304 byte limit
# Access the internal connection to send raw data # Access the internal connection to send raw data
frame_helper = client._connection._frame_helper frame_helper = client._connection._frame_helper
@@ -132,22 +132,24 @@ async def test_oversized_payload_noise(
run_compiled: RunCompiledFunction, run_compiled: RunCompiledFunction,
api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory, api_client_connected_with_disconnect: APIClientConnectedWithDisconnectFactory,
) -> None: ) -> None:
"""Test that oversized payloads (>100KiB) from client cause disconnection without crashing with noise encryption.""" """Test that oversized payloads from client cause disconnection without crashing with noise encryption."""
noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU=" noise_key = "N4Yle5YirwZhPiHHsdZLdOA73ndj/84veVaLhTvxCuU="
process_exited = False process_exited = False
cipherstate_failed = False helper_log_found = False
def check_logs(line: str) -> None: def check_logs(line: str) -> None:
nonlocal process_exited, cipherstate_failed nonlocal process_exited, helper_log_found
# Check for signs that the process exited/crashed # Check for signs that the process exited/crashed
if "Segmentation fault" in line or "core dumped" in line: if "Segmentation fault" in line or "core dumped" in line:
process_exited = True process_exited = True
# Check for the expected warning about decryption failure # Check for HELPER_LOG message about message size exceeding maximum
# With our new protection, oversized messages are rejected at frame level
if ( if (
"[W][api.connection" in line "[VV]" in line
and "Reading failed CIPHERSTATE_DECRYPT_FAILED" in line and "Bad packet: message size" in line
and "exceeds maximum" in line
): ):
cipherstate_failed = True helper_log_found = True
async with run_compiled(yaml_config, line_callback=check_logs): async with run_compiled(yaml_config, line_callback=check_logs):
async with api_client_connected_with_disconnect(noise_psk=noise_key) as ( async with api_client_connected_with_disconnect(noise_psk=noise_key) as (
@@ -159,8 +161,8 @@ async def test_oversized_payload_noise(
assert device_info is not None assert device_info is not None
assert device_info.name == "oversized-noise" assert device_info.name == "oversized-noise"
# Create an oversized payload (>100KiB) # Create an oversized payload (>2304 bytes which is our new limit)
oversized_data = b"Y" * (100 * 1024 + 1) # 100KiB + 1 byte oversized_data = b"Y" * 3000 # ~3KiB, exceeds the 2304 byte limit
# Access the internal connection to send raw data # Access the internal connection to send raw data
frame_helper = client._connection._frame_helper frame_helper = client._connection._frame_helper
@@ -175,9 +177,9 @@ async def test_oversized_payload_noise(
# After disconnection, verify process didn't crash # After disconnection, verify process didn't crash
assert not process_exited, "ESPHome process should not crash" assert not process_exited, "ESPHome process should not crash"
# Verify we saw the expected warning message # Verify we saw the expected HELPER_LOG message
assert cipherstate_failed, ( assert helper_log_found, (
"Expected to see warning about CIPHERSTATE_DECRYPT_FAILED" "Expected to see HELPER_LOG about message size exceeding maximum"
) )
# Try to reconnect to verify the process is still running # Try to reconnect to verify the process is still running