1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-28 13:43:54 +00:00

Merge branch 'integration' into memory_api

This commit is contained in:
J. Nick Koston
2025-10-22 16:25:47 -10:00
11 changed files with 236 additions and 108 deletions

View File

@@ -28,6 +28,38 @@ void ImprovSerialComponent::setup() {
} }
} }
void ImprovSerialComponent::loop() {
if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) {
this->last_read_byte_ = 0;
this->rx_buffer_.clear();
ESP_LOGV(TAG, "Timeout");
}
auto byte = this->read_byte_();
while (byte.has_value()) {
if (this->parse_improv_serial_byte_(byte.value())) {
this->last_read_byte_ = millis();
} else {
this->last_read_byte_ = 0;
this->rx_buffer_.clear();
}
byte = this->read_byte_();
}
if (this->state_ == improv::STATE_PROVISIONING) {
if (wifi::global_wifi_component->is_connected()) {
wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(),
this->connecting_sta_.get_password());
this->connecting_sta_ = {};
this->cancel_timeout("wifi-connect-timeout");
this->set_state_(improv::STATE_PROVISIONED);
std::vector<uint8_t> url = this->build_rpc_settings_response_(improv::WIFI_SETTINGS);
this->send_response_(url);
}
}
}
void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); } void ImprovSerialComponent::dump_config() { ESP_LOGCONFIG(TAG, "Improv Serial:"); }
optional<uint8_t> ImprovSerialComponent::read_byte_() { optional<uint8_t> ImprovSerialComponent::read_byte_() {
@@ -78,8 +110,28 @@ optional<uint8_t> ImprovSerialComponent::read_byte_() {
return byte; return byte;
} }
void ImprovSerialComponent::write_data_(std::vector<uint8_t> &data) { void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) {
data.push_back('\n'); // First, set length field
this->tx_header_[TX_LENGTH_IDX] = this->tx_header_[TX_TYPE_IDX] == TYPE_RPC_RESPONSE ? size : 1;
const bool there_is_data = data != nullptr && size > 0;
// If there_is_data, checksum must not include our optional data byte
const uint8_t header_checksum_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE - 2;
// Only transmit the full buffer length if there is no data (only state/error byte is provided in this case)
const uint8_t header_tx_len = there_is_data ? TX_BUFFER_SIZE - 3 : TX_BUFFER_SIZE;
// Calculate checksum for message
uint8_t checksum = 0;
for (uint8_t i = 0; i < header_checksum_len; i++) {
checksum += this->tx_header_[i];
}
if (there_is_data) {
// Include data in checksum
for (size_t i = 0; i < size; i++) {
checksum += data[i];
}
}
this->tx_header_[TX_CHECKSUM_IDX] = checksum;
#ifdef USE_ESP32 #ifdef USE_ESP32
switch (logger::global_logger->get_uart()) { switch (logger::global_logger->get_uart()) {
case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART0:
@@ -87,63 +139,45 @@ void ImprovSerialComponent::write_data_(std::vector<uint8_t> &data) {
#if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ #if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \
!defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3)
case logger::UART_SELECTION_UART2: case logger::UART_SELECTION_UART2:
#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 #endif
uart_write_bytes(this->uart_num_, data.data(), data.size()); uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len);
if (there_is_data) {
uart_write_bytes(this->uart_num_, data, size);
uart_write_bytes(this->uart_num_, &this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
}
break; break;
#if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC) #if defined(USE_LOGGER_USB_CDC) && defined(CONFIG_ESP_CONSOLE_USB_CDC)
case logger::UART_SELECTION_USB_CDC: { case logger::UART_SELECTION_USB_CDC:
const char *msg = (char *) data.data(); esp_usb_console_write_buf((const char *) this->tx_header_, header_tx_len);
esp_usb_console_write_buf(msg, data.size()); if (there_is_data) {
esp_usb_console_write_buf((const char *) data, size);
esp_usb_console_write_buf((const char *) &this->tx_header_[TX_CHECKSUM_IDX],
2); // Footer: checksum and newline
}
break; break;
} #endif
#endif // USE_LOGGER_USB_CDC
#ifdef USE_LOGGER_USB_SERIAL_JTAG #ifdef USE_LOGGER_USB_SERIAL_JTAG
case logger::UART_SELECTION_USB_SERIAL_JTAG: case logger::UART_SELECTION_USB_SERIAL_JTAG:
usb_serial_jtag_write_bytes((char *) data.data(), data.size(), 20 / portTICK_PERIOD_MS); usb_serial_jtag_write_bytes((const char *) this->tx_header_, header_tx_len, 20 / portTICK_PERIOD_MS);
delay(10); if (there_is_data) {
usb_serial_jtag_ll_txfifo_flush(); // fixes for issue in IDF 4.4.7 usb_serial_jtag_write_bytes((const char *) data, size, 20 / portTICK_PERIOD_MS);
usb_serial_jtag_write_bytes((const char *) &this->tx_header_[TX_CHECKSUM_IDX], 2,
20 / portTICK_PERIOD_MS); // Footer: checksum and newline
}
break; break;
#endif // USE_LOGGER_USB_SERIAL_JTAG #endif
default: default:
break; break;
} }
#elif defined(USE_ARDUINO) #elif defined(USE_ARDUINO)
this->hw_serial_->write(data.data(), data.size()); this->hw_serial_->write(this->tx_header_, header_tx_len);
if (there_is_data) {
this->hw_serial_->write(data, size);
this->hw_serial_->write(&this->tx_header_[TX_CHECKSUM_IDX], 2); // Footer: checksum and newline
}
#endif #endif
} }
void ImprovSerialComponent::loop() {
if (this->last_read_byte_ && (millis() - this->last_read_byte_ > IMPROV_SERIAL_TIMEOUT)) {
this->last_read_byte_ = 0;
this->rx_buffer_.clear();
ESP_LOGV(TAG, "Improv Serial timeout");
}
auto byte = this->read_byte_();
while (byte.has_value()) {
if (this->parse_improv_serial_byte_(byte.value())) {
this->last_read_byte_ = millis();
} else {
this->last_read_byte_ = 0;
this->rx_buffer_.clear();
}
byte = this->read_byte_();
}
if (this->state_ == improv::STATE_PROVISIONING) {
if (wifi::global_wifi_component->is_connected()) {
wifi::global_wifi_component->save_wifi_sta(this->connecting_sta_.get_ssid(),
this->connecting_sta_.get_password());
this->connecting_sta_ = {};
this->cancel_timeout("wifi-connect-timeout");
this->set_state_(improv::STATE_PROVISIONED);
std::vector<uint8_t> url = this->build_rpc_settings_response_(improv::WIFI_SETTINGS);
this->send_response_(url);
}
}
}
std::vector<uint8_t> ImprovSerialComponent::build_rpc_settings_response_(improv::Command command) { std::vector<uint8_t> ImprovSerialComponent::build_rpc_settings_response_(improv::Command command) {
std::vector<std::string> urls; std::vector<std::string> urls;
#ifdef USE_IMPROV_SERIAL_NEXT_URL #ifdef USE_IMPROV_SERIAL_NEXT_URL
@@ -177,13 +211,13 @@ std::vector<uint8_t> ImprovSerialComponent::build_version_info_() {
bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) { bool ImprovSerialComponent::parse_improv_serial_byte_(uint8_t byte) {
size_t at = this->rx_buffer_.size(); size_t at = this->rx_buffer_.size();
this->rx_buffer_.push_back(byte); this->rx_buffer_.push_back(byte);
ESP_LOGV(TAG, "Improv Serial byte: 0x%02X", byte); ESP_LOGV(TAG, "Byte: 0x%02X", byte);
const uint8_t *raw = &this->rx_buffer_[0]; const uint8_t *raw = &this->rx_buffer_[0];
return improv::parse_improv_serial_byte( return improv::parse_improv_serial_byte(
at, byte, raw, [this](improv::ImprovCommand command) -> bool { return this->parse_improv_payload_(command); }, at, byte, raw, [this](improv::ImprovCommand command) -> bool { return this->parse_improv_payload_(command); },
[this](improv::Error error) -> void { [this](improv::Error error) -> void {
ESP_LOGW(TAG, "Error decoding Improv payload"); ESP_LOGW(TAG, "Error decoding payload");
this->set_error_(error); this->set_error_(error);
}); });
} }
@@ -199,7 +233,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command
wifi::global_wifi_component->set_sta(sta); wifi::global_wifi_component->set_sta(sta);
wifi::global_wifi_component->start_connecting(sta, false); wifi::global_wifi_component->start_connecting(sta, false);
this->set_state_(improv::STATE_PROVISIONING); this->set_state_(improv::STATE_PROVISIONING);
ESP_LOGD(TAG, "Received Improv wifi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(),
command.password.c_str()); command.password.c_str());
auto f = std::bind(&ImprovSerialComponent::on_wifi_connect_timeout_, this); auto f = std::bind(&ImprovSerialComponent::on_wifi_connect_timeout_, this);
@@ -240,7 +274,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command
return true; return true;
} }
default: { default: {
ESP_LOGW(TAG, "Unknown Improv payload"); ESP_LOGW(TAG, "Unknown payload");
this->set_error_(improv::ERROR_UNKNOWN_RPC); this->set_error_(improv::ERROR_UNKNOWN_RPC);
return false; return false;
} }
@@ -249,57 +283,26 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command
void ImprovSerialComponent::set_state_(improv::State state) { void ImprovSerialComponent::set_state_(improv::State state) {
this->state_ = state; this->state_ = state;
this->tx_header_[TX_TYPE_IDX] = TYPE_CURRENT_STATE;
std::vector<uint8_t> data = {'I', 'M', 'P', 'R', 'O', 'V'}; this->tx_header_[TX_DATA_IDX] = state;
data.resize(11); this->write_data_();
data[6] = IMPROV_SERIAL_VERSION;
data[7] = TYPE_CURRENT_STATE;
data[8] = 1;
data[9] = state;
uint8_t checksum = 0x00;
for (uint8_t d : data)
checksum += d;
data[10] = checksum;
this->write_data_(data);
} }
void ImprovSerialComponent::set_error_(improv::Error error) { void ImprovSerialComponent::set_error_(improv::Error error) {
std::vector<uint8_t> data = {'I', 'M', 'P', 'R', 'O', 'V'}; this->tx_header_[TX_TYPE_IDX] = TYPE_ERROR_STATE;
data.resize(11); this->tx_header_[TX_DATA_IDX] = error;
data[6] = IMPROV_SERIAL_VERSION; this->write_data_();
data[7] = TYPE_ERROR_STATE;
data[8] = 1;
data[9] = error;
uint8_t checksum = 0x00;
for (uint8_t d : data)
checksum += d;
data[10] = checksum;
this->write_data_(data);
} }
void ImprovSerialComponent::send_response_(std::vector<uint8_t> &response) { void ImprovSerialComponent::send_response_(std::vector<uint8_t> &response) {
std::vector<uint8_t> data = {'I', 'M', 'P', 'R', 'O', 'V'}; this->tx_header_[TX_TYPE_IDX] = TYPE_RPC_RESPONSE;
data.resize(9); this->write_data_(response.data(), response.size());
data[6] = IMPROV_SERIAL_VERSION;
data[7] = TYPE_RPC_RESPONSE;
data[8] = response.size();
data.insert(data.end(), response.begin(), response.end());
uint8_t checksum = 0x00;
for (uint8_t d : data)
checksum += d;
data.push_back(checksum);
this->write_data_(data);
} }
void ImprovSerialComponent::on_wifi_connect_timeout_() { void ImprovSerialComponent::on_wifi_connect_timeout_() {
this->set_error_(improv::ERROR_UNABLE_TO_CONNECT); this->set_error_(improv::ERROR_UNABLE_TO_CONNECT);
this->set_state_(improv::STATE_AUTHORIZED); this->set_state_(improv::STATE_AUTHORIZED);
ESP_LOGW(TAG, "Timed out trying to connect to given WiFi network"); ESP_LOGW(TAG, "Timed out while connecting to Wi-Fi network");
wifi::global_wifi_component->clear_sta(); wifi::global_wifi_component->clear_sta();
} }

View File

@@ -26,6 +26,16 @@
namespace esphome { namespace esphome {
namespace improv_serial { namespace improv_serial {
// TX buffer layout constants
static constexpr uint8_t TX_HEADER_SIZE = 6; // Bytes 0-5 = "IMPROV"
static constexpr uint8_t TX_VERSION_IDX = 6;
static constexpr uint8_t TX_TYPE_IDX = 7;
static constexpr uint8_t TX_LENGTH_IDX = 8;
static constexpr uint8_t TX_DATA_IDX = 9; // For state/error messages only
static constexpr uint8_t TX_CHECKSUM_IDX = 10;
static constexpr uint8_t TX_NEWLINE_IDX = 11;
static constexpr uint8_t TX_BUFFER_SIZE = 12;
enum ImprovSerialType : uint8_t { enum ImprovSerialType : uint8_t {
TYPE_CURRENT_STATE = 0x01, TYPE_CURRENT_STATE = 0x01,
TYPE_ERROR_STATE = 0x02, TYPE_ERROR_STATE = 0x02,
@@ -57,7 +67,22 @@ class ImprovSerialComponent : public Component, public improv_base::ImprovBase {
std::vector<uint8_t> build_version_info_(); std::vector<uint8_t> build_version_info_();
optional<uint8_t> read_byte_(); optional<uint8_t> read_byte_();
void write_data_(std::vector<uint8_t> &data); void write_data_(const uint8_t *data = nullptr, size_t size = 0);
uint8_t tx_header_[TX_BUFFER_SIZE] = {
'I', // 0: Header
'M', // 1: Header
'P', // 2: Header
'R', // 3: Header
'O', // 4: Header
'V', // 5: Header
IMPROV_SERIAL_VERSION, // 6: Version
0, // 7: ImprovSerialType
0, // 8: Length
0, // 9...X: Data (here, one byte reserved for state/error)
0, // X + 10: Checksum
'\n',
};
#ifdef USE_ESP32 #ifdef USE_ESP32
uart_port_t uart_num_; uart_port_t uart_num_;

View File

@@ -22,6 +22,10 @@ class LightEffect {
/// Apply this effect. Use the provided state for starting transitions, ... /// Apply this effect. Use the provided state for starting transitions, ...
virtual void apply() = 0; virtual void apply() = 0;
/**
* Returns the name of this effect.
* The returned pointer is valid for the lifetime of the program and must not be freed.
*/
const char *get_name() const { return this->name_; } const char *get_name() const { return this->name_; }
/// Internal method called by the LightState when this light effect is registered in it. /// Internal method called by the LightState when this light effect is registered in it.

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
@@ -277,3 +278,19 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
raise EsphomeError(f"Upload failed with result: {result}") raise EsphomeError(f"Upload failed with result: {result}")
return handled return handled
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
address = devices[0]
from .ble_logger import is_mac_address, logger_connect, logger_scan
if devices[0] == "BLE":
ble_device = asyncio.run(logger_scan(CORE.config["esphome"]["name"]))
if ble_device:
address = ble_device.address
else:
return True
if is_mac_address(address):
asyncio.run(logger_connect(address))
return True
return False

View File

@@ -0,0 +1,60 @@
import asyncio
import logging
import re
from typing import Final
from bleak import BleakClient, BleakScanner, BLEDevice
from bleak.exc import (
BleakCharacteristicNotFoundError,
BleakDBusError,
BleakDeviceNotFoundError,
)
_LOGGER = logging.getLogger(__name__)
NUS_SERVICE_UUID = "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"
NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
MAC_ADDRESS_PATTERN: Final = re.compile(
r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE
)
def is_mac_address(value: str) -> bool:
return MAC_ADDRESS_PATTERN.match(value)
async def logger_scan(name: str) -> BLEDevice | None:
_LOGGER.info("Scanning bluetooth for %s...", name)
device = await BleakScanner.find_device_by_name(name)
if not device:
_LOGGER.error("%s Bluetooth LE device was not found!", name)
return device
async def logger_connect(host: str) -> int | None:
disconnected_event = asyncio.Event()
def handle_disconnect(client):
disconnected_event.set()
def handle_rx(_, data: bytearray):
print(data.decode("utf-8"), end="")
_LOGGER.info("Connecting %s...", host)
try:
async with BleakClient(host, disconnected_callback=handle_disconnect) as client:
_LOGGER.info("Connected %s...", host)
try:
await client.start_notify(NUS_TX_CHAR_UUID, handle_rx)
except BleakDBusError as e:
_LOGGER.error("Bluetooth LE logger: %s", e)
disconnected_event.set()
await disconnected_event.wait()
except BleakDeviceNotFoundError:
_LOGGER.error("Device %s not found", host)
return 1
except BleakCharacteristicNotFoundError:
_LOGGER.error("Device %s has no NUS characteristic", host)
return 1

View File

@@ -234,6 +234,9 @@ def copy_files():
"url": "https://esphome.io/", "url": "https://esphome.io/",
"vendor": "esphome", "vendor": "esphome",
"build": { "build": {
"bsp": {
"name": "adafruit"
},
"softdevice": { "softdevice": {
"sd_fwid": "0x00B6" "sd_fwid": "0x00B6"
} }

View File

@@ -22,6 +22,7 @@ pillow==11.3.0
cairosvg==2.8.2 cairosvg==2.8.2
freetype-py==2.5.1 freetype-py==2.5.1
jinja2==3.1.6 jinja2==3.1.6
bleak==1.0.1
# esp-idf >= 5.0 requires this # esp-idf >= 5.0 requires this
pyparsing >= 3.0 pyparsing >= 3.0

View File

@@ -0,0 +1,8 @@
wifi:
ssid: MySSID
password: password1
logger:
hardware_uart: UART0
improv_serial:

View File

@@ -0,0 +1 @@
<<: !include common-uart0.yaml

View File

@@ -44,6 +44,7 @@ class InitialStateHelper:
helper = InitialStateHelper(entities) helper = InitialStateHelper(entities)
client.subscribe_states(helper.on_state_wrapper(user_callback)) client.subscribe_states(helper.on_state_wrapper(user_callback))
await helper.wait_for_initial_states() await helper.wait_for_initial_states()
# Access initial states via helper.initial_states[key]
""" """
def __init__(self, entities: list[EntityInfo]) -> None: def __init__(self, entities: list[EntityInfo]) -> None:
@@ -63,6 +64,8 @@ class InitialStateHelper:
self._entities_by_id = { self._entities_by_id = {
(entity.device_id, entity.key): entity for entity in entities (entity.device_id, entity.key): entity for entity in entities
} }
# Store initial states by key for test access
self.initial_states: dict[int, EntityState] = {}
# Log all entities # Log all entities
_LOGGER.debug( _LOGGER.debug(
@@ -127,6 +130,9 @@ class InitialStateHelper:
# If this entity is waiting for initial state # If this entity is waiting for initial state
if entity_id in self._wait_initial_states: if entity_id in self._wait_initial_states:
# Store the initial state for test access
self.initial_states[state.key] = state
# Remove from waiting set # Remove from waiting set
self._wait_initial_states.discard(entity_id) self._wait_initial_states.discard(entity_id)

View File

@@ -2,12 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import aioesphomeapi import aioesphomeapi
from aioesphomeapi import ClimateAction, ClimateMode, ClimatePreset, EntityState from aioesphomeapi import ClimateAction, ClimateInfo, ClimateMode, ClimatePreset
import pytest import pytest
from .state_utils import InitialStateHelper
from .types import APIClientConnectedFactory, RunCompiledFunction from .types import APIClientConnectedFactory, RunCompiledFunction
@@ -18,26 +17,27 @@ async def test_host_mode_climate_basic_state(
api_client_connected: APIClientConnectedFactory, api_client_connected: APIClientConnectedFactory,
) -> None: ) -> None:
"""Test basic climate state reporting.""" """Test basic climate state reporting."""
loop = asyncio.get_running_loop()
async with run_compiled(yaml_config), api_client_connected() as client: async with run_compiled(yaml_config), api_client_connected() as client:
states: dict[int, EntityState] = {} # Get entities and set up state synchronization
climate_future: asyncio.Future[EntityState] = loop.create_future() entities, services = await client.list_entities_services()
initial_state_helper = InitialStateHelper(entities)
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) >= 1, "Expected at least 1 climate entity"
def on_state(state: EntityState) -> None: # Subscribe with the wrapper (no-op callback since we just want initial states)
states[state.key] = state client.subscribe_states(initial_state_helper.on_state_wrapper(lambda _: None))
if (
isinstance(state, aioesphomeapi.ClimateState)
and not climate_future.done()
):
climate_future.set_result(state)
client.subscribe_states(on_state)
# Wait for all initial states to be broadcast
try: try:
climate_state = await asyncio.wait_for(climate_future, timeout=5.0) await initial_state_helper.wait_for_initial_states()
except TimeoutError: except TimeoutError:
pytest.fail("Climate state not received within 5 seconds") pytest.fail("Timeout waiting for initial states")
# Get the climate entity and its initial state
test_climate = climate_infos[0]
climate_state = initial_state_helper.initial_states.get(test_climate.key)
assert climate_state is not None, "Climate initial state not found"
assert isinstance(climate_state, aioesphomeapi.ClimateState) assert isinstance(climate_state, aioesphomeapi.ClimateState)
assert climate_state.mode == ClimateMode.OFF assert climate_state.mode == ClimateMode.OFF
assert climate_state.action == ClimateAction.OFF assert climate_state.action == ClimateAction.OFF