diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000..51e2232d24 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,37 @@ +ARG BUILD_BASE_VERSION=2025.04.0 + + +FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base + +RUN git config --system --add safe.directory "*" + +RUN apt update \ + && apt install -y \ + protobuf-compiler + +RUN pip install uv + +RUN useradd esphome -m + +USER esphome +ENV VIRTUAL_ENV=/home/esphome/.local/esphome-venv +RUN uv venv $VIRTUAL_ENV +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +# Override this set to true in the docker-base image +ENV UV_SYSTEM_PYTHON=false + +WORKDIR /tmp + +COPY requirements.txt ./ +RUN uv pip install -r requirements.txt +COPY requirements_dev.txt requirements_test.txt ./ +RUN uv pip install -r requirements_dev.txt -r requirements_test.txt + +RUN \ + platformio settings set enable_telemetry No \ + && platformio settings set check_platformio_interval 1000000 + +COPY script/platformio_install_deps.py platformio.ini ./ +RUN ./platformio_install_deps.py platformio.ini --libraries --platforms --tools + +WORKDIR /workspaces diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 86f35cc47b..5a7a02a266 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,18 +1,17 @@ { "name": "ESPHome Dev", - "image": "ghcr.io/esphome/esphome-lint:dev", + "context": "..", + "dockerFile": "Dockerfile", "postCreateCommand": [ "script/devcontainer-post-create" ], - "containerEnv": { - "DEVCONTAINER": "1", - "PIP_BREAK_SYSTEM_PACKAGES": "1", - "PIP_ROOT_USER_ACTION": "ignore" + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {} }, "runArgs": [ "--privileged", "-e", - "ESPHOME_DASHBOARD_USE_PING=1" + "GIT_EDITOR=code --wait" // uncomment and edit the path in order to pass though local USB serial to the conatiner // , "--device=/dev/ttyACM0" ], diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 511ec55f3e..8a14dba5eb 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -47,7 +47,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5.6.0 with: - python-version: "3.9" + python-version: "3.10" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.10.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4913e8ee19..c35488d96b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,8 @@ permissions: contents: read env: - DEFAULT_PYTHON: "3.9" - PYUPGRADE_TARGET: "--py39-plus" + DEFAULT_PYTHON: "3.10" + PYUPGRADE_TARGET: "--py310-plus" concurrency: # yamllint disable-line rule:line-length @@ -173,10 +173,10 @@ jobs: fail-fast: false matrix: python-version: - - "3.9" - "3.10" - "3.11" - "3.12" + - "3.13" os: - ubuntu-latest - macOS-latest @@ -185,18 +185,18 @@ jobs: # Minimize CI resource usage # by only running the Python version # version used for docker images on Windows and macOS + - python-version: "3.13" + os: windows-latest - python-version: "3.12" os: windows-latest - python-version: "3.10" os: windows-latest - - python-version: "3.9" - os: windows-latest + - python-version: "3.13" + os: macOS-latest - python-version: "3.12" os: macOS-latest - python-version: "3.10" os: macOS-latest - - python-version: "3.9" - os: macOS-latest runs-on: ${{ matrix.os }} needs: - common diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39e1ce73b1..a310b7f083 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5.6.0 with: - python-version: "3.9" + python-version: "3.10" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.10.0 @@ -259,44 +259,3 @@ jobs: version: "${{ needs.init.outputs.tag }}", } }) - - deploy-api-docs: - if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' - runs-on: ubuntu-latest - needs: [init] - environment: ${{ needs.init.outputs.deploy_env }} - steps: - - name: Checkout repo - uses: actions/checkout@v4.1.7 - - - name: Set up Node.js - uses: actions/setup-node@v4.4.0 - with: - node-version: "22" - - - name: Generate docs - uses: mattnotmitt/doxygen-action@v1.12.0 - - - name: Deploy to netlify ${{ needs.init.outputs.deploy_env }} - if: needs.init.outputs.deploy_env != 'production' - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - run: | - npx netlify-cli deploy \ - --dir api-docs \ - --no-build \ - --alias "${{ needs.init.outputs.deploy_env }}" \ - --message "Deploy API docs for ${{ needs.init.outputs.tag }}" - - - name: Deploy to netlify production - if: needs.init.outputs.deploy_env == 'production' - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} - run: | - npx netlify-cli deploy \ - --dir api-docs \ - --no-build \ - --prod \ - --message "Deploy API docs for ${{ needs.init.outputs.tag }}" diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index b262a9f9c1..a38825fc45 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -13,10 +13,10 @@ jobs: if: github.repository == 'esphome/esphome' steps: - name: Checkout - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 - name: Checkout Home Assistant - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 with: repository: home-assistant/core path: lib/home-assistant @@ -24,7 +24,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5.6.0 with: - python-version: 3.12 + python-version: 3.13 - name: Install Home Assistant run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c3d5b9c783..a76d5dd9b9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.9 + rev: v0.11.10 hooks: # Run the linter. - id: ruff @@ -28,10 +28,10 @@ repos: - --branch=release - --branch=beta - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.19.1 hooks: - id: pyupgrade - args: [--py39-plus] + args: [--py310-plus] - repo: https://github.com/adrienverge/yamllint.git rev: v1.37.1 hooks: diff --git a/CODEOWNERS b/CODEOWNERS index a6e08f225d..c7423e9eae 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -96,6 +96,7 @@ esphome/components/ch422g/* @clydebarrow @jesterret esphome/components/chsc6x/* @kkosik20 esphome/components/climate/* @esphome/core esphome/components/climate_ir/* @glmnet +esphome/components/cm1106/* @andrewjswan esphome/components/color_temperature/* @jesserockz esphome/components/combination/* @Cat-Ion @kahrendt esphome/components/const/* @esphome/core @@ -478,6 +479,8 @@ esphome/components/ufire_ise/* @pvizeli esphome/components/ultrasonic/* @OttoWinter esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon +esphome/components/usb_host/* @clydebarrow +esphome/components/usb_uart/* @clydebarrow esphome/components/valve/* @esphome/core esphome/components/vbus/* @ssieb esphome/components/veml3235/* @kbx81 diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp index f18f4104b6..aa80c41597 100644 --- a/esphome/components/api/api_frame_helper.cpp +++ b/esphome/components/api/api_frame_helper.cpp @@ -7,20 +7,13 @@ #include "proto.h" #include "api_pb2_size.h" #include +#include namespace esphome { namespace api { static const char *const TAG = "api.socket"; -/// Is the given return value (from write syscalls) a wouldblock error? -bool is_would_block(ssize_t ret) { - if (ret == -1) { - return errno == EWOULDBLOCK || errno == EAGAIN; - } - return ret == 0; -} - const char *api_error_to_str(APIError err) { // not using switch to ensure compiler doesn't try to build a big table out of it if (err == APIError::OK) { @@ -73,92 +66,154 @@ const char *api_error_to_str(APIError err) { return "UNKNOWN"; } -// Common implementation for writing raw data to socket -template -APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, - std::vector &tx_buf, const std::string &info, StateEnum &state, - StateEnum failed_state) { - // This method writes data to socket or buffers it +// Helper method to buffer data from IOVs +void APIFrameHelper::buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len) { + SendBuffer buffer; + buffer.data.reserve(total_write_len); + for (int i = 0; i < iovcnt; i++) { + const uint8_t *data = reinterpret_cast(iov[i].iov_base); + buffer.data.insert(buffer.data.end(), data, data + iov[i].iov_len); + } + this->tx_buf_.push_back(std::move(buffer)); +} + +// This method writes data to socket or buffers it +APIError APIFrameHelper::write_raw_(const struct iovec *iov, int iovcnt) { // Returns APIError::OK if successful (or would block, but data has been buffered) - // Returns APIError::SOCKET_WRITE_FAILED if socket write failed, and sets state to failed_state + // Returns APIError::SOCKET_WRITE_FAILED if socket write failed, and sets state to FAILED if (iovcnt == 0) return APIError::OK; // Nothing to do, success - size_t total_write_len = 0; + uint16_t total_write_len = 0; for (int i = 0; i < iovcnt; i++) { #ifdef HELPER_LOG_PACKETS ESP_LOGVV(TAG, "Sending raw: %s", format_hex_pretty(reinterpret_cast(iov[i].iov_base), iov[i].iov_len).c_str()); #endif - total_write_len += iov[i].iov_len; + total_write_len += static_cast(iov[i].iov_len); } - if (!tx_buf.empty()) { - // try to empty tx_buf first - while (!tx_buf.empty()) { - ssize_t sent = socket->write(tx_buf.data(), tx_buf.size()); - if (is_would_block(sent)) { - break; - } else if (sent == -1) { - ESP_LOGVV(TAG, "%s: Socket write failed with errno %d", info.c_str(), errno); - state = failed_state; - return APIError::SOCKET_WRITE_FAILED; // Socket write failed - } - // TODO: inefficient if multiple packets in txbuf - // replace with deque of buffers - tx_buf.erase(tx_buf.begin(), tx_buf.begin() + sent); + // Try to send any existing buffered data first if there is any + if (!this->tx_buf_.empty()) { + APIError send_result = try_send_tx_buf_(); + // If real error occurred (not just WOULD_BLOCK), return it + if (send_result != APIError::OK && send_result != APIError::WOULD_BLOCK) { + return send_result; + } + + // If there is still data in the buffer, we can't send, buffer + // the new data and return + if (!this->tx_buf_.empty()) { + this->buffer_data_from_iov_(iov, iovcnt, total_write_len); + return APIError::OK; // Success, data buffered } } - if (!tx_buf.empty()) { - // tx buf not empty, can't write now because then stream would be inconsistent - // Reserve space upfront to avoid multiple reallocations - tx_buf.reserve(tx_buf.size() + total_write_len); - for (int i = 0; i < iovcnt; i++) { - tx_buf.insert(tx_buf.end(), reinterpret_cast(iov[i].iov_base), - reinterpret_cast(iov[i].iov_base) + iov[i].iov_len); - } - return APIError::OK; // Success, data buffered - } + // Try to send directly if no buffered data + ssize_t sent = this->socket_->writev(iov, iovcnt); - ssize_t sent = socket->writev(iov, iovcnt); - if (is_would_block(sent)) { - // operation would block, add buffer to tx_buf - // Reserve space upfront to avoid multiple reallocations - tx_buf.reserve(tx_buf.size() + total_write_len); - for (int i = 0; i < iovcnt; i++) { - tx_buf.insert(tx_buf.end(), reinterpret_cast(iov[i].iov_base), - reinterpret_cast(iov[i].iov_base) + iov[i].iov_len); + if (sent == -1) { + if (errno == EWOULDBLOCK || errno == EAGAIN) { + // Socket would block, buffer the data + this->buffer_data_from_iov_(iov, iovcnt, total_write_len); + return APIError::OK; // Success, data buffered } - return APIError::OK; // Success, data buffered - } else if (sent == -1) { - // an error occurred - ESP_LOGVV(TAG, "%s: Socket write failed with errno %d", info.c_str(), errno); - state = failed_state; + // Socket error + ESP_LOGVV(TAG, "%s: Socket write failed with errno %d", this->info_.c_str(), errno); + this->state_ = State::FAILED; return APIError::SOCKET_WRITE_FAILED; // Socket write failed - } else if ((size_t) sent != total_write_len) { - // partially sent, add end to tx_buf - size_t remaining = total_write_len - sent; - // Reserve space upfront to avoid multiple reallocations - tx_buf.reserve(tx_buf.size() + remaining); + } else if (static_cast(sent) < total_write_len) { + // Partially sent, buffer the remaining data + SendBuffer buffer; + uint16_t to_consume = static_cast(sent); + uint16_t remaining = total_write_len - static_cast(sent); + + buffer.data.reserve(remaining); - size_t to_consume = sent; for (int i = 0; i < iovcnt; i++) { if (to_consume >= iov[i].iov_len) { - to_consume -= iov[i].iov_len; + // This segment was fully sent + to_consume -= static_cast(iov[i].iov_len); } else { - tx_buf.insert(tx_buf.end(), reinterpret_cast(iov[i].iov_base) + to_consume, - reinterpret_cast(iov[i].iov_base) + iov[i].iov_len); + // This segment was partially sent or not sent at all + const uint8_t *data = reinterpret_cast(iov[i].iov_base) + to_consume; + uint16_t len = static_cast(iov[i].iov_len) - to_consume; + buffer.data.insert(buffer.data.end(), data, data + len); to_consume = 0; } } - return APIError::OK; // Success, data buffered + + this->tx_buf_.push_back(std::move(buffer)); } - return APIError::OK; // Success, all data sent + + return APIError::OK; // Success, all data sent or buffered } -#define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, info_.c_str(), ##__VA_ARGS__) +// Common implementation for trying to send buffered data +// IMPORTANT: Caller MUST ensure tx_buf_ is not empty before calling this method +APIError APIFrameHelper::try_send_tx_buf_() { + // Try to send from tx_buf - we assume it's not empty as it's the caller's responsibility to check + bool tx_buf_empty = false; + while (!tx_buf_empty) { + // Get the first buffer in the queue + SendBuffer &front_buffer = this->tx_buf_.front(); + + // Try to send the remaining data in this buffer + ssize_t sent = this->socket_->write(front_buffer.current_data(), front_buffer.remaining()); + + if (sent == -1) { + if (errno != EWOULDBLOCK && errno != EAGAIN) { + // Real socket error (not just would block) + ESP_LOGVV(TAG, "%s: Socket write failed with errno %d", this->info_.c_str(), errno); + this->state_ = State::FAILED; + return APIError::SOCKET_WRITE_FAILED; // Socket write failed + } + // Socket would block, we'll try again later + return APIError::WOULD_BLOCK; + } else if (sent == 0) { + // Nothing sent but not an error + return APIError::WOULD_BLOCK; + } else if (static_cast(sent) < front_buffer.remaining()) { + // Partially sent, update offset + // Cast to ensure no overflow issues with uint16_t + front_buffer.offset += static_cast(sent); + return APIError::WOULD_BLOCK; // Stop processing more buffers if we couldn't send a complete buffer + } else { + // Buffer completely sent, remove it from the queue + this->tx_buf_.pop_front(); + // Update empty status for the loop condition + tx_buf_empty = this->tx_buf_.empty(); + // Continue loop to try sending the next buffer + } + } + + return APIError::OK; // All buffers sent successfully +} + +APIError APIFrameHelper::init_common_() { + if (state_ != State::INITIALIZE || this->socket_ == nullptr) { + ESP_LOGVV(TAG, "%s: Bad state for init %d", this->info_.c_str(), (int) state_); + return APIError::BAD_STATE; + } + int err = this->socket_->setblocking(false); + if (err != 0) { + state_ = State::FAILED; + ESP_LOGVV(TAG, "%s: Setting nonblocking failed with errno %d", this->info_.c_str(), errno); + return APIError::TCP_NONBLOCKING_FAILED; + } + + int enable = 1; + err = this->socket_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); + if (err != 0) { + state_ = State::FAILED; + ESP_LOGVV(TAG, "%s: Setting nodelay failed with errno %d", this->info_.c_str(), errno); + return APIError::TCP_NODELAY_FAILED; + } + return APIError::OK; +} + +#define HELPER_LOG(msg, ...) ESP_LOGVV(TAG, "%s: " msg, this->info_.c_str(), ##__VA_ARGS__) // uncomment to log raw packets //#define HELPER_LOG_PACKETS @@ -206,23 +261,9 @@ std::string noise_err_to_str(int err) { /// Initialize the frame helper, returns OK if successful. APIError APINoiseFrameHelper::init() { - if (state_ != State::INITIALIZE || socket_ == nullptr) { - HELPER_LOG("Bad state for init %d", (int) state_); - return APIError::BAD_STATE; - } - int err = socket_->setblocking(false); - if (err != 0) { - state_ = State::FAILED; - HELPER_LOG("Setting nonblocking failed with errno %d", errno); - return APIError::TCP_NONBLOCKING_FAILED; - } - - int enable = 1; - err = socket_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); - if (err != 0) { - state_ = State::FAILED; - HELPER_LOG("Setting nodelay failed with errno %d", errno); - return APIError::TCP_NODELAY_FAILED; + APIError err = init_common_(); + if (err != APIError::OK) { + return err; } // init prologue @@ -234,17 +275,16 @@ APIError APINoiseFrameHelper::init() { /// Run through handshake messages (if in that phase) APIError APINoiseFrameHelper::loop() { APIError err = state_action_(); - if (err == APIError::WOULD_BLOCK) - return APIError::OK; - if (err != APIError::OK) + if (err != APIError::OK && err != APIError::WOULD_BLOCK) { return err; - if (!tx_buf_.empty()) { + } + if (!this->tx_buf_.empty()) { err = try_send_tx_buf_(); - if (err != APIError::OK) { + if (err != APIError::OK && err != APIError::WOULD_BLOCK) { return err; } } - return APIError::OK; + return APIError::OK; // Convert WOULD_BLOCK to OK to avoid connection termination } /** Read a packet into the rx_buf_. If successful, stores frame data in the frame parameter @@ -270,8 +310,8 @@ APIError APINoiseFrameHelper::try_read_frame_(ParsedFrame *frame) { // read header if (rx_header_buf_len_ < 3) { // no header information yet - size_t to_read = 3 - rx_header_buf_len_; - ssize_t received = socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read); + uint8_t to_read = 3 - rx_header_buf_len_; + ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read); if (received == -1) { if (errno == EWOULDBLOCK || errno == EAGAIN) { return APIError::WOULD_BLOCK; @@ -284,8 +324,8 @@ APIError APINoiseFrameHelper::try_read_frame_(ParsedFrame *frame) { HELPER_LOG("Connection closed"); return APIError::CONNECTION_CLOSED; } - rx_header_buf_len_ += received; - if ((size_t) received != to_read) { + rx_header_buf_len_ += static_cast(received); + if (static_cast(received) != to_read) { // not a full read return APIError::WOULD_BLOCK; } @@ -317,8 +357,8 @@ APIError APINoiseFrameHelper::try_read_frame_(ParsedFrame *frame) { if (rx_buf_len_ < msg_size) { // more data to read - size_t to_read = msg_size - rx_buf_len_; - ssize_t received = socket_->read(&rx_buf_[rx_buf_len_], to_read); + uint16_t to_read = msg_size - rx_buf_len_; + ssize_t received = this->socket_->read(&rx_buf_[rx_buf_len_], to_read); if (received == -1) { if (errno == EWOULDBLOCK || errno == EAGAIN) { return APIError::WOULD_BLOCK; @@ -331,8 +371,8 @@ APIError APINoiseFrameHelper::try_read_frame_(ParsedFrame *frame) { HELPER_LOG("Connection closed"); return APIError::CONNECTION_CLOSED; } - rx_buf_len_ += received; - if ((size_t) received != to_read) { + rx_buf_len_ += static_cast(received); + if (static_cast(received) != to_read) { // not all read return APIError::WOULD_BLOCK; } @@ -381,6 +421,8 @@ APIError APINoiseFrameHelper::state_action_() { if (aerr != APIError::OK) return aerr; // ignore contents, may be used in future for flags + // Reserve space for: existing prologue + 2 size bytes + frame data + prologue_.reserve(prologue_.size() + 2 + frame.msg.size()); prologue_.push_back((uint8_t) (frame.msg.size() >> 8)); prologue_.push_back((uint8_t) frame.msg.size()); prologue_.insert(prologue_.end(), frame.msg.begin(), frame.msg.end()); @@ -389,16 +431,20 @@ APIError APINoiseFrameHelper::state_action_() { } if (state_ == State::SERVER_HELLO) { // send server hello + const std::string &name = App.get_name(); + const std::string &mac = get_mac_address(); + std::vector msg; + // Reserve space for: 1 byte proto + name + null + mac + null + msg.reserve(1 + name.size() + 1 + mac.size() + 1); + // chosen proto msg.push_back(0x01); // node name, terminated by null byte - const std::string &name = App.get_name(); const uint8_t *name_ptr = reinterpret_cast(name.c_str()); msg.insert(msg.end(), name_ptr, name_ptr + name.size() + 1); // node mac, terminated by null byte - const std::string &mac = get_mac_address(); const uint8_t *mac_ptr = reinterpret_cast(mac.c_str()); msg.insert(msg.end(), mac_ptr, mac_ptr + mac.size() + 1); @@ -505,7 +551,6 @@ void APINoiseFrameHelper::send_explicit_handshake_reject_(const std::string &rea write_frame_(data.data(), data.size()); state_ = orig_state; } - APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { int err; APIError aerr; @@ -533,7 +578,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { return APIError::CIPHERSTATE_DECRYPT_FAILED; } - size_t msg_size = mbuf.size; + uint16_t msg_size = mbuf.size; uint8_t *msg_data = frame.msg.data(); if (msg_size < 4) { state_ = State::FAILED; @@ -559,7 +604,6 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = type; return APIError::OK; } -bool APINoiseFrameHelper::can_write_without_blocking() { return state_ == State::DATA && tx_buf_.empty(); } APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { int err; APIError aerr; @@ -574,9 +618,9 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuf std::vector *raw_buffer = buffer.get_buffer(); // Message data starts after padding - size_t payload_len = raw_buffer->size() - frame_header_padding_; - size_t padding = 0; - size_t msg_len = 4 + payload_len + padding; + uint16_t payload_len = raw_buffer->size() - frame_header_padding_; + uint16_t padding = 0; + uint16_t msg_len = 4 + payload_len + padding; // We need to resize to include MAC space, but we already reserved it in create_buffer raw_buffer->resize(raw_buffer->size() + frame_footer_size_); @@ -609,7 +653,7 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuf return APIError::CIPHERSTATE_ENCRYPT_FAILED; } - size_t total_len = 3 + mbuf.size; + uint16_t total_len = 3 + mbuf.size; buf_start[1] = (uint8_t) (mbuf.size >> 8); buf_start[2] = (uint8_t) mbuf.size; @@ -620,29 +664,9 @@ APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuf iov.iov_len = total_len; // write raw to not have two packets sent if NAGLE disabled - return write_raw_(&iov, 1); + return this->write_raw_(&iov, 1); } -APIError APINoiseFrameHelper::try_send_tx_buf_() { - // try send from tx_buf - while (state_ != State::CLOSED && !tx_buf_.empty()) { - ssize_t sent = socket_->write(tx_buf_.data(), tx_buf_.size()); - if (sent == -1) { - if (errno == EWOULDBLOCK || errno == EAGAIN) - break; - state_ = State::FAILED; - HELPER_LOG("Socket write failed with errno %d", errno); - return APIError::SOCKET_WRITE_FAILED; - } else if (sent == 0) { - break; - } - // TODO: inefficient if multiple packets in txbuf - // replace with deque of buffers - tx_buf_.erase(tx_buf_.begin(), tx_buf_.begin() + sent); - } - - return APIError::OK; -} -APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, size_t len) { +APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) { uint8_t header[3]; header[0] = 0x01; // indicator header[1] = (uint8_t) (len >> 8); @@ -652,12 +676,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, size_t len) { iov[0].iov_base = header; iov[0].iov_len = 3; if (len == 0) { - return write_raw_(iov, 1); + return this->write_raw_(iov, 1); } iov[1].iov_base = const_cast(data); iov[1].iov_len = len; - return write_raw_(iov, 2); + return this->write_raw_(iov, 2); } /** Initiate the data structures for the handshake. @@ -752,22 +776,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() { } } -APIError APINoiseFrameHelper::close() { - state_ = State::CLOSED; - int err = socket_->close(); - if (err == -1) - return APIError::CLOSE_FAILED; - return APIError::OK; -} -APIError APINoiseFrameHelper::shutdown(int how) { - int err = socket_->shutdown(how); - if (err == -1) - return APIError::SHUTDOWN_FAILED; - if (how == SHUT_RDWR) { - state_ = State::CLOSED; - } - return APIError::OK; -} 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) { @@ -778,32 +786,15 @@ void noise_rand_bytes(void *output, size_t len) { } } -// Explicit template instantiation for Noise -template APIError APIFrameHelper::write_raw_( - const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector &tx_buf_, const std::string &info, - APINoiseFrameHelper::State &state, APINoiseFrameHelper::State failed_state); #endif // USE_API_NOISE #ifdef USE_API_PLAINTEXT /// Initialize the frame helper, returns OK if successful. APIError APIPlaintextFrameHelper::init() { - if (state_ != State::INITIALIZE || socket_ == nullptr) { - HELPER_LOG("Bad state for init %d", (int) state_); - return APIError::BAD_STATE; - } - int err = socket_->setblocking(false); - if (err != 0) { - state_ = State::FAILED; - HELPER_LOG("Setting nonblocking failed with errno %d", errno); - return APIError::TCP_NONBLOCKING_FAILED; - } - int enable = 1; - err = socket_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); - if (err != 0) { - state_ = State::FAILED; - HELPER_LOG("Setting nodelay failed with errno %d", errno); - return APIError::TCP_NODELAY_FAILED; + APIError err = init_common_(); + if (err != APIError::OK) { + return err; } state_ = State::DATA; @@ -814,14 +805,13 @@ APIError APIPlaintextFrameHelper::loop() { if (state_ != State::DATA) { return APIError::BAD_STATE; } - // try send pending TX data - if (!tx_buf_.empty()) { + if (!this->tx_buf_.empty()) { APIError err = try_send_tx_buf_(); - if (err != APIError::OK) { + if (err != APIError::OK && err != APIError::WOULD_BLOCK) { return err; } } - return APIError::OK; + return APIError::OK; // Convert WOULD_BLOCK to OK to avoid connection termination } /** Read a packet into the rx_buf_. If successful, stores frame data in the frame parameter @@ -846,7 +836,7 @@ APIError APIPlaintextFrameHelper::try_read_frame_(ParsedFrame *frame) { // there is no data on the wire (which is the common case). // This results in faster failure detection compared to // attempting to read multiple bytes at once. - ssize_t received = socket_->read(&data, 1); + ssize_t received = this->socket_->read(&data, 1); if (received == -1) { if (errno == EWOULDBLOCK || errno == EAGAIN) { return APIError::WOULD_BLOCK; @@ -910,14 +900,24 @@ APIError APIPlaintextFrameHelper::try_read_frame_(ParsedFrame *frame) { continue; } - rx_header_parsed_len_ = msg_size_varint->as_uint32(); + if (msg_size_varint->as_uint32() > 65535) { + state_ = State::FAILED; + HELPER_LOG("Bad packet: message size %" PRIu32 " exceeds maximum 65535", msg_size_varint->as_uint32()); + return APIError::BAD_DATA_PACKET; + } + rx_header_parsed_len_ = msg_size_varint->as_uint16(); auto msg_type_varint = ProtoVarInt::parse(&rx_header_buf_[consumed], rx_header_buf_pos_ - 1 - consumed, &consumed); if (!msg_type_varint.has_value()) { // not enough data there yet continue; } - rx_header_parsed_type_ = msg_type_varint->as_uint32(); + if (msg_type_varint->as_uint32() > 65535) { + state_ = State::FAILED; + HELPER_LOG("Bad packet: message type %" PRIu32 " exceeds maximum 65535", msg_type_varint->as_uint32()); + return APIError::BAD_DATA_PACKET; + } + rx_header_parsed_type_ = msg_type_varint->as_uint16(); rx_header_parsed_ = true; } // header reading done @@ -929,8 +929,8 @@ APIError APIPlaintextFrameHelper::try_read_frame_(ParsedFrame *frame) { if (rx_buf_len_ < rx_header_parsed_len_) { // more data to read - size_t to_read = rx_header_parsed_len_ - rx_buf_len_; - ssize_t received = socket_->read(&rx_buf_[rx_buf_len_], to_read); + uint16_t to_read = rx_header_parsed_len_ - rx_buf_len_; + ssize_t received = this->socket_->read(&rx_buf_[rx_buf_len_], to_read); if (received == -1) { if (errno == EWOULDBLOCK || errno == EAGAIN) { return APIError::WOULD_BLOCK; @@ -943,8 +943,8 @@ APIError APIPlaintextFrameHelper::try_read_frame_(ParsedFrame *frame) { HELPER_LOG("Connection closed"); return APIError::CONNECTION_CLOSED; } - rx_buf_len_ += received; - if ((size_t) received != to_read) { + rx_buf_len_ += static_cast(received); + if (static_cast(received) != to_read) { // not all read return APIError::WOULD_BLOCK; } @@ -962,7 +962,6 @@ APIError APIPlaintextFrameHelper::try_read_frame_(ParsedFrame *frame) { rx_header_parsed_ = false; return APIError::OK; } - APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { APIError aerr; @@ -990,7 +989,7 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { "Bad indicator byte"; iov[0].iov_base = (void *) msg; iov[0].iov_len = 19; - write_raw_(iov, 1); + this->write_raw_(iov, 1); } return aerr; } @@ -1001,7 +1000,6 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { buffer->type = rx_header_parsed_type_; return APIError::OK; } -bool APIPlaintextFrameHelper::can_write_without_blocking() { return state_ == State::DATA && tx_buf_.empty(); } APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) { if (state_ != State::DATA) { return APIError::BAD_STATE; @@ -1009,12 +1007,12 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWrit std::vector *raw_buffer = buffer.get_buffer(); // Message data starts after padding (frame_header_padding_ = 6) - size_t payload_len = raw_buffer->size() - frame_header_padding_; + uint16_t payload_len = static_cast(raw_buffer->size() - frame_header_padding_); // Calculate varint sizes for header components - size_t size_varint_len = api::ProtoSize::varint(static_cast(payload_len)); - size_t type_varint_len = api::ProtoSize::varint(static_cast(type)); - size_t total_header_len = 1 + size_varint_len + type_varint_len; + uint8_t size_varint_len = api::ProtoSize::varint(static_cast(payload_len)); + uint8_t type_varint_len = api::ProtoSize::varint(static_cast(type)); + uint8_t total_header_len = 1 + size_varint_len + type_varint_len; if (total_header_len > frame_header_padding_) { // Header is too large to fit in the padding @@ -1044,7 +1042,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWrit // [4-5] - Message type varint (2 bytes, for types 128-32767) // [6...] - Actual payload data uint8_t *buf_start = raw_buffer->data(); - size_t header_offset = frame_header_padding_ - total_header_len; + uint8_t header_offset = frame_header_padding_ - total_header_len; // Write the plaintext header buf_start[header_offset] = 0x00; // indicator @@ -1063,46 +1061,7 @@ APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWrit return write_raw_(&iov, 1); } -APIError APIPlaintextFrameHelper::try_send_tx_buf_() { - // try send from tx_buf - while (state_ != State::CLOSED && !tx_buf_.empty()) { - ssize_t sent = socket_->write(tx_buf_.data(), tx_buf_.size()); - if (is_would_block(sent)) { - break; - } else if (sent == -1) { - state_ = State::FAILED; - HELPER_LOG("Socket write failed with errno %d", errno); - return APIError::SOCKET_WRITE_FAILED; - } - // TODO: inefficient if multiple packets in txbuf - // replace with deque of buffers - tx_buf_.erase(tx_buf_.begin(), tx_buf_.begin() + sent); - } - return APIError::OK; -} - -APIError APIPlaintextFrameHelper::close() { - state_ = State::CLOSED; - int err = socket_->close(); - if (err == -1) - return APIError::CLOSE_FAILED; - return APIError::OK; -} -APIError APIPlaintextFrameHelper::shutdown(int how) { - int err = socket_->shutdown(how); - if (err == -1) - return APIError::SHUTDOWN_FAILED; - if (how == SHUT_RDWR) { - state_ = State::CLOSED; - } - return APIError::OK; -} - -// Explicit template instantiation for Plaintext -template APIError APIFrameHelper::write_raw_( - const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector &tx_buf_, const std::string &info, - APIPlaintextFrameHelper::State &state, APIPlaintextFrameHelper::State failed_state); #endif // USE_API_PLAINTEXT } // namespace api diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 25bfd594ec..ea91c3a7f9 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -21,15 +21,8 @@ class ProtoWriteBuffer; struct ReadPacketBuffer { std::vector container; uint16_t type; - size_t data_offset; - size_t data_len; -}; - -struct PacketBuffer { - const std::vector container; - uint16_t type; - uint8_t data_offset; - uint8_t data_len; + uint16_t data_offset; + uint16_t data_len; }; enum class APIError : int { @@ -62,38 +55,117 @@ const char *api_error_to_str(APIError err); class APIFrameHelper { public: + APIFrameHelper() = default; + explicit APIFrameHelper(std::unique_ptr socket) : socket_owned_(std::move(socket)) { + socket_ = socket_owned_.get(); + } virtual ~APIFrameHelper() = default; virtual APIError init() = 0; virtual APIError loop() = 0; virtual APIError read_packet(ReadPacketBuffer *buffer) = 0; - virtual bool can_write_without_blocking() = 0; - virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0; - virtual std::string getpeername() = 0; - virtual int getpeername(struct sockaddr *addr, socklen_t *addrlen) = 0; - virtual APIError close() = 0; - virtual APIError shutdown(int how) = 0; + bool can_write_without_blocking() { return state_ == State::DATA && tx_buf_.empty(); } + std::string getpeername() { return socket_->getpeername(); } + int getpeername(struct sockaddr *addr, socklen_t *addrlen) { return socket_->getpeername(addr, addrlen); } + APIError close() { + state_ = State::CLOSED; + int err = this->socket_->close(); + if (err == -1) + return APIError::CLOSE_FAILED; + return APIError::OK; + } + APIError shutdown(int how) { + int err = this->socket_->shutdown(how); + if (err == -1) + return APIError::SHUTDOWN_FAILED; + if (how == SHUT_RDWR) { + state_ = State::CLOSED; + } + return APIError::OK; + } // Give this helper a name for logging - virtual void set_log_info(std::string info) = 0; + void set_log_info(std::string info) { info_ = std::move(info); } + virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0; // Get the frame header padding required by this protocol virtual uint8_t frame_header_padding() = 0; // Get the frame footer size required by this protocol virtual uint8_t frame_footer_size() = 0; protected: + // Struct for holding parsed frame data + struct ParsedFrame { + std::vector msg; + }; + + // Buffer containing data to be sent + struct SendBuffer { + std::vector data; + uint16_t offset{0}; // Current offset within the buffer (uint16_t to reduce memory usage) + + // Using uint16_t reduces memory usage since ESPHome API messages are limited to 64KB max + uint16_t remaining() const { return static_cast(data.size()) - offset; } + const uint8_t *current_data() const { return data.data() + offset; } + }; + + // Queue of data buffers to be sent + std::deque tx_buf_; + + // Common state enum for all frame helpers + // Note: Not all states are used by all implementations + // - INITIALIZE: Used by both Noise and Plaintext + // - CLIENT_HELLO, SERVER_HELLO, HANDSHAKE: Only used by Noise protocol + // - DATA: Used by both Noise and Plaintext + // - CLOSED: Used by both Noise and Plaintext + // - FAILED: Used by both Noise and Plaintext + // - EXPLICIT_REJECT: Only used by Noise protocol + enum class State { + INITIALIZE = 1, + CLIENT_HELLO = 2, // Noise only + SERVER_HELLO = 3, // Noise only + HANDSHAKE = 4, // Noise only + DATA = 5, + CLOSED = 6, + FAILED = 7, + EXPLICIT_REJECT = 8, // Noise only + }; + + // Current state of the frame helper + State state_{State::INITIALIZE}; + + // Helper name for logging + std::string info_; + + // Socket for communication + socket::Socket *socket_{nullptr}; + std::unique_ptr socket_owned_; + // Common implementation for writing raw data to socket + APIError write_raw_(const struct iovec *iov, int iovcnt); + + // Try to send data from the tx buffer + APIError try_send_tx_buf_(); + + // Helper method to buffer data from IOVs + void buffer_data_from_iov_(const struct iovec *iov, int iovcnt, uint16_t total_write_len); template APIError write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector &tx_buf, const std::string &info, StateEnum &state, StateEnum failed_state); uint8_t frame_header_padding_{0}; uint8_t frame_footer_size_{0}; + + // Receive buffer for reading frame data + std::vector rx_buf_; + uint16_t rx_buf_len_ = 0; + + // Common initialization for both plaintext and noise protocols + APIError init_common_(); }; #ifdef USE_API_NOISE class APINoiseFrameHelper : public APIFrameHelper { public: APINoiseFrameHelper(std::unique_ptr socket, std::shared_ptr ctx) - : socket_(std::move(socket)), ctx_(std::move(ctx)) { + : APIFrameHelper(std::move(socket)), ctx_(std::move(ctx)) { // Noise header structure: // Pos 0: indicator (0x01) // Pos 1-2: encrypted payload size (16-bit big-endian) @@ -105,49 +177,25 @@ class APINoiseFrameHelper : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - bool can_write_without_blocking() override; APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override; - std::string getpeername() override { return this->socket_->getpeername(); } - int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { - return this->socket_->getpeername(addr, addrlen); - } - APIError close() override; - APIError shutdown(int how) override; - // Give this helper a name for logging - void set_log_info(std::string info) override { info_ = std::move(info); } // Get the frame header padding required by this protocol uint8_t frame_header_padding() override { return frame_header_padding_; } // Get the frame footer size required by this protocol uint8_t frame_footer_size() override { return frame_footer_size_; } protected: - struct ParsedFrame { - std::vector msg; - }; - APIError state_action_(); APIError try_read_frame_(ParsedFrame *frame); - APIError try_send_tx_buf_(); - APIError write_frame_(const uint8_t *data, size_t len); - inline APIError write_raw_(const struct iovec *iov, int iovcnt) { - return APIFrameHelper::write_raw_(iov, iovcnt, socket_.get(), tx_buf_, info_, state_, State::FAILED); - } + APIError write_frame_(const uint8_t *data, uint16_t len); APIError init_handshake_(); APIError check_handshake_finished_(); void send_explicit_handshake_reject_(const std::string &reason); - - std::unique_ptr socket_; - - std::string info_; // Fixed-size header buffer for noise protocol: // 1 byte for indicator + 2 bytes for message size (16-bit value, not varint) // Note: Maximum message size is 65535, with a limit of 128 bytes during handshake phase uint8_t rx_header_buf_[3]; - size_t rx_header_buf_len_ = 0; - std::vector rx_buf_; - size_t rx_buf_len_ = 0; + uint8_t rx_header_buf_len_ = 0; - std::vector tx_buf_; std::vector prologue_; std::shared_ptr ctx_; @@ -155,24 +203,13 @@ class APINoiseFrameHelper : public APIFrameHelper { NoiseCipherState *send_cipher_{nullptr}; NoiseCipherState *recv_cipher_{nullptr}; NoiseProtocolId nid_; - - enum class State { - INITIALIZE = 1, - CLIENT_HELLO = 2, - SERVER_HELLO = 3, - HANDSHAKE = 4, - DATA = 5, - CLOSED = 6, - FAILED = 7, - EXPLICIT_REJECT = 8, - } state_ = State::INITIALIZE; }; #endif // USE_API_NOISE #ifdef USE_API_PLAINTEXT class APIPlaintextFrameHelper : public APIFrameHelper { public: - APIPlaintextFrameHelper(std::unique_ptr socket) : socket_(std::move(socket)) { + APIPlaintextFrameHelper(std::unique_ptr socket) : APIFrameHelper(std::move(socket)) { // Plaintext header structure (worst case): // Pos 0: indicator (0x00) // Pos 1-3: payload size varint (up to 3 bytes) @@ -184,35 +221,13 @@ class APIPlaintextFrameHelper : public APIFrameHelper { APIError init() override; APIError loop() override; APIError read_packet(ReadPacketBuffer *buffer) override; - bool can_write_without_blocking() override; APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override; - std::string getpeername() override { return this->socket_->getpeername(); } - int getpeername(struct sockaddr *addr, socklen_t *addrlen) override { - return this->socket_->getpeername(addr, addrlen); - } - APIError close() override; - APIError shutdown(int how) override; - // Give this helper a name for logging - void set_log_info(std::string info) override { info_ = std::move(info); } - // Get the frame header padding required by this protocol uint8_t frame_header_padding() override { return frame_header_padding_; } // Get the frame footer size required by this protocol uint8_t frame_footer_size() override { return frame_footer_size_; } protected: - struct ParsedFrame { - std::vector msg; - }; - APIError try_read_frame_(ParsedFrame *frame); - APIError try_send_tx_buf_(); - inline APIError write_raw_(const struct iovec *iov, int iovcnt) { - return APIFrameHelper::write_raw_(iov, iovcnt, socket_.get(), tx_buf_, info_, state_, State::FAILED); - } - - std::unique_ptr socket_; - - std::string info_; // Fixed-size header buffer for plaintext protocol: // We only need space for the two varints since we validate the indicator byte separately. // To match noise protocol's maximum message size (65535), we need: @@ -224,20 +239,8 @@ class APIPlaintextFrameHelper : public APIFrameHelper { uint8_t rx_header_buf_[5]; // 5 bytes for varints (3 for size + 2 for type) uint8_t rx_header_buf_pos_ = 0; bool rx_header_parsed_ = false; - uint32_t rx_header_parsed_type_ = 0; - uint32_t rx_header_parsed_len_ = 0; - - std::vector rx_buf_; - size_t rx_buf_len_ = 0; - - std::vector tx_buf_; - - enum class State { - INITIALIZE = 1, - DATA = 2, - CLOSED = 3, - FAILED = 4, - } state_ = State::INITIALIZE; + uint16_t rx_header_parsed_type_ = 0; + uint16_t rx_header_parsed_len_ = 0; }; #endif diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index 65bef0b6f7..fae722f750 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -55,6 +55,7 @@ class ProtoVarInt { return {}; // Incomplete or invalid varint } + uint16_t as_uint16() const { return this->value_; } uint32_t as_uint32() const { return this->value_; } uint64_t as_uint64() const { return this->value_; } bool as_bool() const { return this->value_; } diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h index 6c637d08c5..558a5c8b19 100644 --- a/esphome/components/at581x/at581x.h +++ b/esphome/components/at581x/at581x.h @@ -14,11 +14,8 @@ namespace esphome { namespace at581x { class AT581XComponent : public Component, public i2c::I2CDevice { -#ifdef USE_SWITCH - protected: - switch_::Switch *rf_power_switch_{nullptr}; - public: +#ifdef USE_SWITCH void set_rf_power_switch(switch_::Switch *s) { this->rf_power_switch_ = s; s->turn_on(); @@ -48,6 +45,9 @@ class AT581XComponent : public Component, public i2c::I2CDevice { bool i2c_read_reg(uint8_t addr, uint8_t &data); protected: +#ifdef USE_SWITCH + switch_::Switch *rf_power_switch_{nullptr}; +#endif int freq_; int self_check_time_ms_; /*!< Power-on self-test time, range: 0 ~ 65536 ms */ int protect_time_ms_; /*!< Protection time, recommended 1000 ms */ diff --git a/esphome/components/cm1106/__init__.py b/esphome/components/cm1106/__init__.py new file mode 100644 index 0000000000..fa3c3f1925 --- /dev/null +++ b/esphome/components/cm1106/__init__.py @@ -0,0 +1 @@ +"""CM1106 component for ESPHome.""" diff --git a/esphome/components/cm1106/cm1106.cpp b/esphome/components/cm1106/cm1106.cpp new file mode 100644 index 0000000000..b7b0fe0063 --- /dev/null +++ b/esphome/components/cm1106/cm1106.cpp @@ -0,0 +1,112 @@ +#include "cm1106.h" +#include "esphome/core/log.h" + +#include + +namespace esphome { +namespace cm1106 { + +static const char *const TAG = "cm1106"; +static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED}; +static const uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00}; +static const uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6}; + +uint8_t cm1106_checksum(const uint8_t *response, size_t len) { + uint8_t crc = 0; + for (int i = 0; i < len - 1; i++) { + crc -= response[i]; + } + return crc; +} + +void CM1106Component::setup() { + ESP_LOGCONFIG(TAG, "Setting up CM1106..."); + uint8_t response[8] = {0}; + if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) { + ESP_LOGE(TAG, "Communication with CM1106 failed!"); + this->mark_failed(); + return; + } +} + +void CM1106Component::update() { + uint8_t response[8] = {0}; + if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) { + ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + this->status_set_warning(); + return; + } + + if (response[0] != 0x16 || response[1] != 0x05 || response[2] != 0x01) { + ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X...", response[0], response[1], response[2], + response[3]); + this->status_set_warning(); + return; + } + + uint8_t checksum = cm1106_checksum(response, sizeof(response)); + if (response[7] != checksum) { + ESP_LOGW(TAG, "CM1106 Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum); + this->status_set_warning(); + return; + } + + this->status_clear_warning(); + + uint16_t ppm = response[3] << 8 | response[4]; + ESP_LOGD(TAG, "CM1106 Received CO₂=%uppm DF3=%02X DF4=%02X", ppm, response[5], response[6]); + if (this->co2_sensor_ != nullptr) + this->co2_sensor_->publish_state(ppm); +} + +void CM1106Component::calibrate_zero(uint16_t ppm) { + uint8_t cmd[6]; + memcpy(cmd, C_M1106_CMD_SET_CO2_CALIB, sizeof(cmd)); + cmd[3] = ppm >> 8; + cmd[4] = ppm & 0xFF; + uint8_t response[4] = {0}; + + if (!this->cm1106_write_command_(cmd, sizeof(cmd), response, sizeof(response))) { + ESP_LOGW(TAG, "Reading data from CM1106 failed!"); + this->status_set_warning(); + return; + } + + // check if correct response received + if (memcmp(response, C_M1106_CMD_SET_CO2_CALIB_RESPONSE, sizeof(response)) != 0) { + ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2], + response[3]); + this->status_set_warning(); + return; + } + + this->status_clear_warning(); + ESP_LOGD(TAG, "CM1106 Successfully calibrated sensor to %uppm", ppm); +} + +bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, + size_t response_len) { + // Empty RX Buffer + while (this->available()) + this->read(); + this->write_array(command, command_len - 1); + this->write_byte(cm1106_checksum(command, command_len)); + this->flush(); + + if (response == nullptr) + return true; + + return this->read_array(response, response_len); +} + +void CM1106Component::dump_config() { + ESP_LOGCONFIG(TAG, "CM1106:"); + LOG_SENSOR(" ", "CO2", this->co2_sensor_); + this->check_uart_settings(9600); + if (this->is_failed()) { + ESP_LOGE(TAG, "Communication with CM1106 failed!"); + } +} + +} // namespace cm1106 +} // namespace esphome diff --git a/esphome/components/cm1106/cm1106.h b/esphome/components/cm1106/cm1106.h new file mode 100644 index 0000000000..3b78e17cf4 --- /dev/null +++ b/esphome/components/cm1106/cm1106.h @@ -0,0 +1,40 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +namespace esphome { +namespace cm1106 { + +class CM1106Component : public PollingComponent, public uart::UARTDevice { + public: + float get_setup_priority() const override { return esphome::setup_priority::DATA; } + + void setup() override; + void update() override; + void dump_config() override; + + void calibrate_zero(uint16_t ppm); + + void set_co2_sensor(sensor::Sensor *co2_sensor) { this->co2_sensor_ = co2_sensor; } + + protected: + sensor::Sensor *co2_sensor_{nullptr}; + + bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len); +}; + +template class CM1106CalibrateZeroAction : public Action { + public: + CM1106CalibrateZeroAction(CM1106Component *cm1106) : cm1106_(cm1106) {} + + void play(Ts... x) override { this->cm1106_->calibrate_zero(400); } + + protected: + CM1106Component *cm1106_; +}; + +} // namespace cm1106 +} // namespace esphome diff --git a/esphome/components/cm1106/sensor.py b/esphome/components/cm1106/sensor.py new file mode 100644 index 0000000000..1b8ac14fbe --- /dev/null +++ b/esphome/components/cm1106/sensor.py @@ -0,0 +1,72 @@ +"""CM1106 Sensor component for ESPHome.""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation +from esphome.automation import maybe_simple_id +from esphome.components import sensor, uart +from esphome.const import ( + CONF_CO2, + CONF_ID, + DEVICE_CLASS_CARBON_DIOXIDE, + ICON_MOLECULE_CO2, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_MILLION, +) + +DEPENDENCIES = ["uart"] +CODEOWNERS = ["@andrewjswan"] + +cm1106_ns = cg.esphome_ns.namespace("cm1106") +CM1106Component = cm1106_ns.class_( + "CM1106Component", cg.PollingComponent, uart.UARTDevice +) +CM1106CalibrateZeroAction = cm1106_ns.class_( + "CM1106CalibrateZeroAction", + automation.Action, +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CM1106Component), + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + }, + ) + .extend(cv.polling_component_schema("60s")) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +async def to_code(config) -> None: + """Code generation entry point.""" + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + if co2_config := config.get(CONF_CO2): + sens = await sensor.new_sensor(co2_config) + cg.add(var.set_co2_sensor(sens)) + + +CALIBRATION_ACTION_SCHEMA = maybe_simple_id( + { + cv.GenerateID(): cv.use_id(CM1106Component), + }, +) + + +@automation.register_action( + "cm1106.calibrate_zero", + CM1106CalibrateZeroAction, + CALIBRATION_ACTION_SCHEMA, +) +async def cm1106_calibration_to_code(config, action_id, template_arg, args) -> None: + """Service code generation entry point.""" + paren = await cg.get_variable(config[CONF_ID]) + return cg.new_Pvariable(action_id, template_arg, paren) diff --git a/esphome/components/dashboard_import/__init__.py b/esphome/components/dashboard_import/__init__.py index acaadab544..dbe5532902 100644 --- a/esphome/components/dashboard_import/__init__.py +++ b/esphome/components/dashboard_import/__init__.py @@ -2,7 +2,6 @@ import base64 from pathlib import Path import re import secrets -from typing import Optional import requests from ruamel.yaml import YAML @@ -84,7 +83,7 @@ async def to_code(config): def import_config( path: str, name: str, - friendly_name: Optional[str], + friendly_name: str | None, project_name: str, import_url: str, network: str = CONF_WIFI, diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index a55cc7bf44..50d26ad2ea 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -34,13 +34,15 @@ class DebugComponent : public PollingComponent { #endif void set_loop_time_sensor(sensor::Sensor *loop_time_sensor) { loop_time_sensor_ = loop_time_sensor; } #ifdef USE_ESP32 - void on_shutdown() override; void set_psram_sensor(sensor::Sensor *psram_sensor) { this->psram_sensor_ = psram_sensor; } #endif // USE_ESP32 void set_cpu_frequency_sensor(sensor::Sensor *cpu_frequency_sensor) { this->cpu_frequency_sensor_ = cpu_frequency_sensor; } #endif // USE_SENSOR +#ifdef USE_ESP32 + void on_shutdown() override; +#endif // USE_ESP32 protected: uint32_t free_heap_{}; diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 12d0f9fcd5..9c507d8a21 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3,7 +3,6 @@ import itertools import logging import os from pathlib import Path -from typing import Optional, Union from esphome import git import esphome.codegen as cg @@ -60,6 +59,7 @@ from .const import ( # noqa VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, VARIANT_FRIENDLY, @@ -90,6 +90,7 @@ CPU_FREQUENCIES = { VARIANT_ESP32C3: get_cpu_frequencies(80, 160), VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), + VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), } # Make sure not missed here if a new variant added. @@ -189,7 +190,7 @@ class RawSdkconfigValue: value: str -SdkconfigValueType = Union[bool, int, HexInt, str, RawSdkconfigValue] +SdkconfigValueType = bool | int | HexInt | str | RawSdkconfigValue def add_idf_sdkconfig_option(name: str, value: SdkconfigValueType): @@ -206,8 +207,8 @@ def add_idf_component( ref: str = None, path: str = None, refresh: TimePeriod = None, - components: Optional[list[str]] = None, - submodules: Optional[list[str]] = None, + components: list[str] | None = None, + submodules: list[str] | None = None, ): """Add an esp-idf component to the project.""" if not CORE.using_esp_idf: @@ -296,11 +297,11 @@ ARDUINO_PLATFORM_VERSION = cv.Version(5, 4, 0) # The default/recommended esp-idf framework version # - https://github.com/espressif/esp-idf/releases # - https://api.registry.platformio.org/v3/packages/platformio/tool/framework-espidf -RECOMMENDED_ESP_IDF_FRAMEWORK_VERSION = cv.Version(5, 1, 6) +RECOMMENDED_ESP_IDF_FRAMEWORK_VERSION = cv.Version(5, 3, 2) # The platformio/espressif32 version to use for esp-idf frameworks # - https://github.com/platformio/platform-espressif32/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/espressif32 -ESP_IDF_PLATFORM_VERSION = cv.Version(51, 3, 7) +ESP_IDF_PLATFORM_VERSION = cv.Version(53, 3, 13) # List based on https://registry.platformio.org/tools/platformio/framework-espidf/versions SUPPORTED_PLATFORMIO_ESP_IDF_5X = [ @@ -369,8 +370,8 @@ def _arduino_check_versions(value): def _esp_idf_check_versions(value): value = value.copy() lookups = { - "dev": (cv.Version(5, 1, 6), "https://github.com/espressif/esp-idf.git"), - "latest": (cv.Version(5, 1, 6), None), + "dev": (cv.Version(5, 3, 2), "https://github.com/espressif/esp-idf.git"), + "latest": (cv.Version(5, 3, 2), None), "recommended": (RECOMMENDED_ESP_IDF_FRAMEWORK_VERSION, None), } diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index 81400eb9c3..1a8f6d5332 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -4,6 +4,7 @@ from .const import ( VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, ) @@ -1632,6 +1633,14 @@ BOARDS = { "name": "Espressif ESP32-H2-DevKit", "variant": VARIANT_ESP32H2, }, + "esp32-p4": { + "name": "Espressif ESP32-P4 generic", + "variant": VARIANT_ESP32P4, + }, + "esp32-p4-evboard": { + "name": "Espressif ESP32-P4 Function EV Board", + "variant": VARIANT_ESP32P4, + }, "esp32-pico-devkitm-2": { "name": "Espressif ESP32-PICO-DevKitM-2", "variant": VARIANT_ESP32, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index a86713e857..8ca3905be9 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -19,6 +19,7 @@ VARIANT_ESP32C2 = "ESP32C2" VARIANT_ESP32C3 = "ESP32C3" VARIANT_ESP32C6 = "ESP32C6" VARIANT_ESP32H2 = "ESP32H2" +VARIANT_ESP32P4 = "ESP32P4" VARIANTS = [ VARIANT_ESP32, VARIANT_ESP32S2, @@ -27,6 +28,7 @@ VARIANTS = [ VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32P4, ] VARIANT_FRIENDLY = { @@ -37,6 +39,7 @@ VARIANT_FRIENDLY = { VARIANT_ESP32C3: "ESP32-C3", VARIANT_ESP32C6: "ESP32-C6", VARIANT_ESP32H2: "ESP32-H2", + VARIANT_ESP32P4: "ESP32-P4", } esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 2bb10ce6ec..85bae3f52a 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -1,6 +1,7 @@ +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, Callable +from typing import Any from esphome import pins import esphome.codegen as cg @@ -28,6 +29,7 @@ from .const import ( VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, esp32_ns, @@ -37,6 +39,7 @@ from .gpio_esp32_c2 import esp32_c2_validate_gpio_pin, esp32_c2_validate_support from .gpio_esp32_c3 import esp32_c3_validate_gpio_pin, esp32_c3_validate_supports from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports +from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports from .gpio_esp32_s3 import esp32_s3_validate_gpio_pin, esp32_s3_validate_supports @@ -105,6 +108,10 @@ _esp32_validations = { pin_validation=esp32_h2_validate_gpio_pin, usage_validation=esp32_h2_validate_supports, ), + VARIANT_ESP32P4: ESP32ValidationFunctions( + pin_validation=esp32_p4_validate_gpio_pin, + usage_validation=esp32_p4_validate_supports, + ), VARIANT_ESP32S2: ESP32ValidationFunctions( pin_validation=esp32_s2_validate_gpio_pin, usage_validation=esp32_s2_validate_supports, diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py new file mode 100644 index 0000000000..650d06e108 --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -0,0 +1,43 @@ +import logging + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER + +_ESP32P4_USB_JTAG_PINS = {24, 25} + +_ESP32P4_STRAPPING_PINS = {34, 35, 36, 37, 38} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_p4_validate_gpio_pin(value): + if value < 0 or value > 54: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") + if value in _ESP32P4_STRAPPING_PINS: + _LOGGER.warning( + "GPIO%d is a Strapping PIN and should be avoided.\n" + "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" + "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", + value, + ) + if value in _ESP32P4_USB_JTAG_PINS: + _LOGGER.warning( + "GPIO%d is reserved for the USB-Serial-JTAG interface.\n" + "To use this pin as GPIO, USB-Serial-JTAG will be disabled.", + value, + ) + + return value + + +def esp32_p4_validate_supports(value): + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 54: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-54)") + if is_input: + # All ESP32 pins support input mode + pass + return value diff --git a/esphome/components/esp32_ble_tracker/__init__.py b/esphome/components/esp32_ble_tracker/__init__.py index a4425b9680..b7eddeb0dd 100644 --- a/esphome/components/esp32_ble_tracker/__init__.py +++ b/esphome/components/esp32_ble_tracker/__init__.py @@ -1,8 +1,8 @@ from __future__ import annotations -from collections.abc import MutableMapping +from collections.abc import Callable, MutableMapping import logging -from typing import Any, Callable +from typing import Any from esphome import automation import esphome.codegen as cg diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index 7e2ef42a97..6067da15cb 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -111,6 +111,8 @@ void ESPHomeOTAComponent::handle_() { int err = client_->setsockopt(IPPROTO_TCP, TCP_NODELAY, &enable, sizeof(int)); if (err != 0) { ESP_LOGW(TAG, "Socket could not enable TCP nodelay, errno %d", errno); + client_->close(); + client_ = nullptr; return; } diff --git a/esphome/components/libretiny/const.py b/esphome/components/libretiny/const.py index 525d8b7786..362609df44 100644 --- a/esphome/components/libretiny/const.py +++ b/esphome/components/libretiny/const.py @@ -1,5 +1,5 @@ +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable import esphome.codegen as cg diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index 4698c1d9f1..2fc1601d28 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -10,6 +10,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, ) @@ -89,6 +90,7 @@ UART_SELECTION_ESP32 = { VARIANT_ESP32C2: [UART0, UART1], VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], } UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] @@ -206,6 +208,7 @@ CONFIG_SCHEMA = cv.All( esp32_c3_idf=USB_SERIAL_JTAG, esp32_c6_arduino=USB_CDC, esp32_c6_idf=USB_SERIAL_JTAG, + esp32_p4_idf=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, rtl87xx=DEFAULT, diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index c9de3d815a..b5ac84a665 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -18,12 +18,12 @@ #endif #endif -#include "freertos/FreeRTOS.h" #include "esp_idf_version.h" +#include "freertos/FreeRTOS.h" +#include #include #include -#include #endif // USE_ESP_IDF @@ -174,11 +174,11 @@ void Logger::pre_setup() { #ifdef USE_ESP_IDF void HOT Logger::write_msg_(const char *msg) { if ( -#if defined(USE_ESP32_VARIANT_ESP32S2) +#if defined(USE_LOGGER_USB_CDC) && !defined(USE_LOGGER_USB_SERIAL_JTAG) this->uart_ == UART_SELECTION_USB_CDC -#elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) +#elif defined(USE_LOGGER_USB_SERIAL_JTAG) && !defined(USE_LOGGER_USB_CDC) this->uart_ == UART_SELECTION_USB_SERIAL_JTAG -#elif defined(USE_ESP32_VARIANT_ESP32S3) +#elif defined(USE_LOGGER_USB_CDC) && defined(USE_LOGGER_USB_SERIAL_JTAG) this->uart_ == UART_SELECTION_USB_CDC || this->uart_ == UART_SELECTION_USB_SERIAL_JTAG #else /* DISABLES CODE */ (false) // NOLINT diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index f60d60d9a4..dd49efd447 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -321,7 +321,7 @@ async def to_code(configs): frac = 2 elif frac > 0.19: frac = 4 - else: + elif frac != 0: frac = 8 displays = [ await cg.get_variable(display) for display in config[df.CONF_DISPLAYS] @@ -422,7 +422,7 @@ LVGL_SCHEMA = cv.All( ): lvalid.lv_font, cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, - cv.Optional(CONF_BUFFER_SIZE, default="100%"): cv.percentage, + cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, cv.Optional(df.CONF_LOG_LEVEL, default="WARN"): cv.one_of( *df.LV_LOG_LEVELS, upper=True ), diff --git a/esphome/components/lvgl/automation.py b/esphome/components/lvgl/automation.py index 5fea9bfdb1..f49356604b 100644 --- a/esphome/components/lvgl/automation.py +++ b/esphome/components/lvgl/automation.py @@ -1,4 +1,5 @@ -from typing import Any, Callable +from collections.abc import Callable +from typing import Any from esphome import automation import esphome.codegen as cg diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 3755d35d27..92fe74eb52 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -1,5 +1,3 @@ -from typing import Union - import esphome.codegen as cg from esphome.components import image from esphome.components.color import CONF_HEX, ColorStruct, from_rgbw @@ -361,7 +359,7 @@ lv_image_list = LValidator( lv_bool = LValidator(cv.boolean, cg.bool_, retmapper=literal) -def lv_pct(value: Union[int, float]): +def lv_pct(value: int | float): if isinstance(value, float): value = int(value * 100) return literal(f"lv_pct({value})") diff --git a/esphome/components/lvgl/lvcode.py b/esphome/components/lvgl/lvcode.py index 67a87d24bf..7a5c35f896 100644 --- a/esphome/components/lvgl/lvcode.py +++ b/esphome/components/lvgl/lvcode.py @@ -1,5 +1,4 @@ import abc -from typing import Union from esphome import codegen as cg from esphome.config import Config @@ -75,7 +74,7 @@ class CodeContext(abc.ABC): code_context = None @abc.abstractmethod - def add(self, expression: Union[Expression, Statement]): + def add(self, expression: Expression | Statement): pass @staticmethod @@ -89,13 +88,13 @@ class CodeContext(abc.ABC): CodeContext.append(RawStatement("}")) @staticmethod - def append(expression: Union[Expression, Statement]): + def append(expression: Expression | Statement): if CodeContext.code_context is not None: CodeContext.code_context.add(expression) return expression def __init__(self): - self.previous: Union[CodeContext | None] = None + self.previous: CodeContext | None = None self.indent_level = 0 async def __aenter__(self): @@ -121,7 +120,7 @@ class MainContext(CodeContext): Code generation into the main() function """ - def add(self, expression: Union[Expression, Statement]): + def add(self, expression: Expression | Statement): return cg.add(self.indented_statement(expression)) @@ -144,7 +143,7 @@ class LambdaContext(CodeContext): self.capture = capture self.where = where - def add(self, expression: Union[Expression, Statement]): + def add(self, expression: Expression | Statement): self.code_list.append(self.indented_statement(expression)) return expression @@ -186,7 +185,7 @@ class LvContext(LambdaContext): async def __aexit__(self, exc_type, exc_val, exc_tb): await super().__aexit__(exc_type, exc_val, exc_tb) - def add(self, expression: Union[Expression, Statement]): + def add(self, expression: Expression | Statement): cg.add(expression) return expression @@ -303,7 +302,7 @@ lvgl_static = MockObj("LvglComponent", "::") # equivalent to cg.add() for the current code context -def lv_add(expression: Union[Expression, Statement]): +def lv_add(expression: Expression | Statement): return CodeContext.append(expression) diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 4c30d14e15..d58fb24584 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -11,6 +11,8 @@ namespace esphome { namespace lvgl { static const char *const TAG = "lvgl"; +static const size_t MIN_BUFFER_FRAC = 8; + static const char *const EVENT_NAMES[] = { "NONE", "PRESSED", @@ -85,6 +87,7 @@ lv_event_code_t lv_update_event; // NOLINT void LvglComponent::dump_config() { ESP_LOGCONFIG(TAG, "LVGL:"); ESP_LOGCONFIG(TAG, " Display width/height: %d x %d", this->disp_drv_.hor_res, this->disp_drv_.ver_res); + ESP_LOGCONFIG(TAG, " Buffer size: %zu%%", 100 / this->buffer_frac_); ESP_LOGCONFIG(TAG, " Rotation: %d", this->rotation); ESP_LOGCONFIG(TAG, " Draw rounding: %d", (int) this->draw_rounding); } @@ -432,18 +435,28 @@ void LvglComponent::setup() { auto *display = this->displays_[0]; auto width = display->get_width(); auto height = display->get_height(); - size_t buffer_pixels = width * height / this->buffer_frac_; + auto frac = this->buffer_frac_; + if (frac == 0) + frac = 1; + size_t buffer_pixels = width * height / frac; auto buf_bytes = buffer_pixels * LV_COLOR_DEPTH / 8; void *buffer = nullptr; - if (this->buffer_frac_ >= 4) + if (this->buffer_frac_ >= MIN_BUFFER_FRAC / 2) buffer = malloc(buf_bytes); // NOLINT if (buffer == nullptr) buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT + // if specific buffer size not set and can't get 100%, try for a smaller one + if (buffer == nullptr && this->buffer_frac_ == 0) { + frac = MIN_BUFFER_FRAC; + buffer_pixels /= MIN_BUFFER_FRAC; + buffer = lv_custom_mem_alloc(buf_bytes / MIN_BUFFER_FRAC); // NOLINT + } if (buffer == nullptr) { - this->mark_failed(); this->status_set_error("Memory allocation failure"); + this->mark_failed(); return; } + this->buffer_frac_ = frac; lv_disp_draw_buf_init(&this->draw_buf_, buffer, nullptr, buffer_pixels); this->disp_drv_.hor_res = width; this->disp_drv_.ver_res = height; @@ -453,8 +466,8 @@ void LvglComponent::setup() { if (this->rotation != display::DISPLAY_ROTATION_0_DEGREES) { this->rotate_buf_ = static_cast(lv_custom_mem_alloc(buf_bytes)); // NOLINT if (this->rotate_buf_ == nullptr) { - this->mark_failed(); this->status_set_error("Memory allocation failure"); + this->mark_failed(); return; } } diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index d0dde01421..2bae560041 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -36,29 +36,43 @@ from .types import ( # this will be populated later, in __init__.py to avoid circular imports. WIDGET_TYPES: dict = {} +TIME_TEXT_SCHEMA = cv.Schema( + { + cv.Required(CONF_TIME_FORMAT): cv.string, + cv.GenerateID(CONF_TIME): cv.templatable(cv.use_id(RealTimeClock)), + } +) + +PRINTF_TEXT_SCHEMA = cv.All( + cv.Schema( + { + cv.Required(CONF_FORMAT): cv.string, + cv.Optional(CONF_ARGS, default=list): cv.ensure_list(cv.lambda_), + }, + ), + validate_printf, +) + + +def _validate_text(value): + """ + Do some sanity checking of the format to get better error messages + than using cv.Any + """ + if value is None: + raise cv.Invalid("No text specified") + if isinstance(value, dict): + if CONF_TIME_FORMAT in value: + return TIME_TEXT_SCHEMA(value) + return PRINTF_TEXT_SCHEMA(value) + + return cv.templatable(cv.string)(value) + + # A schema for text properties TEXT_SCHEMA = cv.Schema( { - cv.Optional(CONF_TEXT): cv.Any( - cv.All( - cv.Schema( - { - cv.Required(CONF_FORMAT): cv.string, - cv.Optional(CONF_ARGS, default=list): cv.ensure_list( - cv.lambda_ - ), - }, - ), - validate_printf, - ), - cv.Schema( - { - cv.Required(CONF_TIME_FORMAT): cv.string, - cv.GenerateID(CONF_TIME): cv.templatable(cv.use_id(RealTimeClock)), - } - ), - cv.templatable(cv.string), - ) + cv.Optional(CONF_TEXT): _validate_text, } ) @@ -247,11 +261,13 @@ FLAG_LIST = cv.ensure_list(df.LvConstant("LV_OBJ_FLAG_", *df.OBJ_FLAGS).one_of) def part_schema(parts): """ Generate a schema for the various parts (e.g. main:, indicator:) of a widget type - :param parts: The parts to include in the schema + :param parts: The parts to include :return: The schema """ - return cv.Schema({cv.Optional(part): STATE_SCHEMA for part in parts}).extend( - STATE_SCHEMA + return ( + cv.Schema({cv.Optional(part): STATE_SCHEMA for part in parts}) + .extend(STATE_SCHEMA) + .extend(FLAG_SCHEMA) ) @@ -288,22 +304,18 @@ def base_update_schema(widget_type, parts): :param parts: The allowable parts to specify :return: """ - return ( - part_schema(parts) - .extend( - { - cv.Required(CONF_ID): cv.ensure_list( - cv.maybe_simple_value( - { - cv.Required(CONF_ID): cv.use_id(widget_type), - }, - key=CONF_ID, - ) - ), - cv.Optional(CONF_STATE): SET_STATE_SCHEMA, - } - ) - .extend(FLAG_SCHEMA) + return part_schema(parts).extend( + { + cv.Required(CONF_ID): cv.ensure_list( + cv.maybe_simple_value( + { + cv.Required(CONF_ID): cv.use_id(widget_type), + }, + key=CONF_ID, + ) + ), + cv.Optional(CONF_STATE): SET_STATE_SCHEMA, + } ) @@ -321,7 +333,6 @@ def obj_schema(widget_type: WidgetType): """ return ( part_schema(widget_type.parts) - .extend(FLAG_SCHEMA) .extend(LAYOUT_SCHEMA) .extend(ALIGN_TO_SCHEMA) .extend(automation_schema(widget_type.w_type)) diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index ccad45bdc6..9d53c0df26 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -1,5 +1,5 @@ import sys -from typing import Any, Union +from typing import Any from esphome import codegen as cg, config_validation as cv from esphome.config_validation import Invalid @@ -262,7 +262,7 @@ async def wait_for_widgets(): await FakeAwaitable(widgets_wait_generator()) -async def get_widgets(config: Union[dict, list], id: str = CONF_ID) -> list[Widget]: +async def get_widgets(config: dict | list, id: str = CONF_ID) -> list[Widget]: if not config: return [] if not isinstance(config, list): diff --git a/esphome/components/lvgl/widgets/tabview.py b/esphome/components/lvgl/widgets/tabview.py index 1d18ddd259..42cf486e1c 100644 --- a/esphome/components/lvgl/widgets/tabview.py +++ b/esphome/components/lvgl/widgets/tabview.py @@ -24,6 +24,7 @@ from .obj import obj_spec CONF_TABVIEW = "tabview" CONF_TAB_STYLE = "tab_style" +CONF_CONTENT_STYLE = "content_style" lv_tab_t = LvType("lv_obj_t") @@ -39,6 +40,7 @@ TABVIEW_SCHEMA = cv.Schema( ) ), cv.Optional(CONF_TAB_STYLE): part_schema(buttonmatrix_spec.parts), + cv.Optional(CONF_CONTENT_STYLE): part_schema(obj_spec.parts), cv.Optional(CONF_POSITION, default="top"): DIRECTIONS.one_of, cv.Optional(CONF_SIZE, default="10%"): size, } @@ -79,6 +81,11 @@ class TabviewType(WidgetType): "tabview_btnmatrix", lv_obj_t, rhs=lv_expr.tabview_get_tab_btns(w.obj) ) as btnmatrix_obj: await set_obj_properties(Widget(btnmatrix_obj, obj_spec), button_style) + if content_style := config.get(CONF_CONTENT_STYLE): + with LocalVariable( + "tabview_content", lv_obj_t, rhs=lv_expr.tabview_get_content(w.obj) + ) as content_obj: + await set_obj_properties(Widget(content_obj, obj_spec), content_style) def obj_creator(self, parent: MockObjClass, config: dict): return lv_expr.call( diff --git a/esphome/components/micro_wake_word/streaming_model.cpp b/esphome/components/micro_wake_word/streaming_model.cpp index ce3d8c2e4c..38b88557e6 100644 --- a/esphome/components/micro_wake_word/streaming_model.cpp +++ b/esphome/components/micro_wake_word/streaming_model.cpp @@ -147,7 +147,11 @@ bool StreamingModel::perform_streaming_inference(const int8_t features[PREPROCES this->recent_streaming_probabilities_[this->last_n_index_] = output->data.uint8[0]; // probability; this->unprocessed_probability_status_ = true; } - this->ignore_windows_ = std::min(this->ignore_windows_ + 1, 0); + if (this->recent_streaming_probabilities_[this->last_n_index_] < this->probability_cutoff_) { + // Only increment ignore windows if less than the probability cutoff; this forces the model to "cool-off" from a + // previous detection and calling ``reset_probabilities`` so it avoids duplicate detections + this->ignore_windows_ = std::min(this->ignore_windows_ + 1, 0); + } } return true; } diff --git a/esphome/components/number/__init__.py b/esphome/components/number/__init__.py index 7aa103e9d9..fd9e948ea3 100644 --- a/esphome/components/number/__init__.py +++ b/esphome/components/number/__init__.py @@ -21,8 +21,10 @@ from esphome.const import ( CONF_WEB_SERVER, DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_AQI, + DEVICE_CLASS_AREA, DEVICE_CLASS_ATMOSPHERIC_PRESSURE, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_BLOOD_GLUCOSE_CONCENTRATION, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_CARBON_MONOXIDE, DEVICE_CLASS_CONDUCTIVITY, @@ -33,6 +35,7 @@ from esphome.const import ( DEVICE_CLASS_DURATION, DEVICE_CLASS_EMPTY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_ENERGY_DISTANCE, DEVICE_CLASS_ENERGY_STORAGE, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, @@ -54,6 +57,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_SOUND_PRESSURE, @@ -68,6 +72,7 @@ from esphome.const import ( DEVICE_CLASS_VOLUME_STORAGE, DEVICE_CLASS_WATER, DEVICE_CLASS_WEIGHT, + DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ) from esphome.core import CORE, coroutine_with_priority @@ -78,8 +83,10 @@ CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_AQI, + DEVICE_CLASS_AREA, DEVICE_CLASS_ATMOSPHERIC_PRESSURE, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_BLOOD_GLUCOSE_CONCENTRATION, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_CARBON_MONOXIDE, DEVICE_CLASS_CONDUCTIVITY, @@ -90,6 +97,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_DURATION, DEVICE_CLASS_EMPTY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_ENERGY_DISTANCE, DEVICE_CLASS_ENERGY_STORAGE, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, @@ -111,6 +119,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_SOUND_PRESSURE, @@ -125,6 +134,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_VOLUME_STORAGE, DEVICE_CLASS_WATER, DEVICE_CLASS_WEIGHT, + DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ] IS_PLATFORM_COMPONENT = True diff --git a/esphome/components/online_image/__init__.py b/esphome/components/online_image/__init__.py index 6b69bc240b..55b9037176 100644 --- a/esphome/components/online_image/__init__.py +++ b/esphome/components/online_image/__init__.py @@ -75,7 +75,7 @@ class PNGFormat(Format): def actions(self): cg.add_define("USE_ONLINE_IMAGE_PNG_SUPPORT") - cg.add_library("pngle", "1.0.2") + cg.add_library("pngle", "1.1.0") IMAGE_FORMATS = { diff --git a/esphome/components/online_image/png_image.cpp b/esphome/components/online_image/png_image.cpp index fc5fb554bf..2038d09ed0 100644 --- a/esphome/components/online_image/png_image.cpp +++ b/esphome/components/online_image/png_image.cpp @@ -34,12 +34,32 @@ static void init_callback(pngle_t *pngle, uint32_t w, uint32_t h) { * @param h The height of the rectangle to draw. * @param rgba The color to paint the rectangle in. */ -static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, uint32_t h, uint8_t rgba[4]) { +static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, uint32_t h, const uint8_t rgba[4]) { PngDecoder *decoder = (PngDecoder *) pngle_get_user_data(pngle); Color color(rgba[0], rgba[1], rgba[2], rgba[3]); decoder->draw(x, y, w, h, color); } +PngDecoder::PngDecoder(OnlineImage *image) : ImageDecoder(image) { + { + pngle_t *pngle = this->allocator_.allocate(1, PNGLE_T_SIZE); + if (!pngle) { + ESP_LOGE(TAG, "Failed to allocate memory for PNGLE engine!"); + return; + } + memset(pngle, 0, PNGLE_T_SIZE); + pngle_reset(pngle); + this->pngle_ = pngle; + } +} + +PngDecoder::~PngDecoder() { + if (this->pngle_) { + pngle_reset(this->pngle_); + this->allocator_.deallocate(this->pngle_, PNGLE_T_SIZE); + } +} + int PngDecoder::prepare(size_t download_size) { ImageDecoder::prepare(download_size); if (!this->pngle_) { diff --git a/esphome/components/online_image/png_image.h b/esphome/components/online_image/png_image.h index 39f445c588..46519f8ef4 100644 --- a/esphome/components/online_image/png_image.h +++ b/esphome/components/online_image/png_image.h @@ -1,7 +1,8 @@ #pragma once -#include "image_decoder.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" +#include "image_decoder.h" #ifdef USE_ONLINE_IMAGE_PNG_SUPPORT #include @@ -18,13 +19,14 @@ class PngDecoder : public ImageDecoder { * * @param display The image to decode the stream into. */ - PngDecoder(OnlineImage *image) : ImageDecoder(image), pngle_(pngle_new()) {} - ~PngDecoder() override { pngle_destroy(this->pngle_); } + PngDecoder(OnlineImage *image); + ~PngDecoder() override; int prepare(size_t download_size) override; int HOT decode(uint8_t *buffer, size_t size) override; protected: + RAMAllocator allocator_; pngle_t *pngle_; }; diff --git a/esphome/components/opentherm/generate.py b/esphome/components/opentherm/generate.py index a97754d52c..4e6f3b0a12 100644 --- a/esphome/components/opentherm/generate.py +++ b/esphome/components/opentherm/generate.py @@ -1,5 +1,5 @@ -from collections.abc import Awaitable -from typing import Any, Callable, Optional +from collections.abc import Awaitable, Callable +from typing import Any import esphome.codegen as cg from esphome.const import CONF_ID @@ -103,7 +103,7 @@ def define_setting_readers(component_type: str, keys: list[str]) -> None: def add_messages(hub: cg.MockObj, keys: list[str], schemas: dict[str, TSchema]): - messages: dict[str, tuple[bool, Optional[int]]] = {} + messages: dict[str, tuple[bool, int | None]] = {} for key in keys: messages[schemas[key].message] = ( schemas[key].keep_updated, diff --git a/esphome/components/opentherm/schema.py b/esphome/components/opentherm/schema.py index 791ba215e0..f70c8e24db 100644 --- a/esphome/components/opentherm/schema.py +++ b/esphome/components/opentherm/schema.py @@ -2,7 +2,7 @@ # inputs of the OpenTherm component. from dataclasses import dataclass -from typing import Any, Optional, TypeVar +from typing import Any, TypeVar import esphome.config_validation as cv from esphome.const import ( @@ -61,11 +61,11 @@ TSchema = TypeVar("TSchema", bound=EntitySchema) class SensorSchema(EntitySchema): accuracy_decimals: int state_class: str - unit_of_measurement: Optional[str] = None - icon: Optional[str] = None - device_class: Optional[str] = None + unit_of_measurement: str | None = None + icon: str | None = None + device_class: str | None = None disabled_by_default: bool = False - order: Optional[int] = None + order: int | None = None SENSORS: dict[str, SensorSchema] = { @@ -461,9 +461,9 @@ SENSORS: dict[str, SensorSchema] = { @dataclass class BinarySensorSchema(EntitySchema): - icon: Optional[str] = None - device_class: Optional[str] = None - order: Optional[int] = None + icon: str | None = None + device_class: str | None = None + order: int | None = None BINARY_SENSORS: dict[str, BinarySensorSchema] = { @@ -654,7 +654,7 @@ BINARY_SENSORS: dict[str, BinarySensorSchema] = { @dataclass class SwitchSchema(EntitySchema): - default_mode: Optional[str] = None + default_mode: str | None = None SWITCHES: dict[str, SwitchSchema] = { @@ -721,9 +721,9 @@ class InputSchema(EntitySchema): unit_of_measurement: str step: float range: tuple[int, int] - icon: Optional[str] = None - auto_max_value: Optional[AutoConfigure] = None - auto_min_value: Optional[AutoConfigure] = None + icon: str | None = None + auto_max_value: AutoConfigure | None = None + auto_min_value: AutoConfigure | None = None INPUTS: dict[str, InputSchema] = { @@ -834,7 +834,7 @@ class SettingSchema(EntitySchema): backing_type: str validation_schema: cv.Schema default_value: Any - order: Optional[int] = None + order: int | None = None SETTINGS: dict[str, SettingSchema] = { diff --git a/esphome/components/opentherm/validate.py b/esphome/components/opentherm/validate.py index 2b80e59f7b..998bcde57f 100644 --- a/esphome/components/opentherm/validate.py +++ b/esphome/components/opentherm/validate.py @@ -1,4 +1,4 @@ -from typing import Callable +from collections.abc import Callable from voluptuous import Schema diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 53ba54dd28..c00cbb2ba2 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -2,6 +2,7 @@ import logging import esphome.codegen as cg from esphome.components.esp32 import ( + CONF_CPU_FREQUENCY, CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, VARIANT_ESP32, add_idf_sdkconfig_option, @@ -50,18 +51,23 @@ SPIRAM_SPEEDS = { def validate_psram_mode(config): - if config[CONF_MODE] == TYPE_OCTAL and config[CONF_SPEED] == 120e6: - esp32_config = fv.full_config.get()[PLATFORM_ESP32] - if ( - esp32_config[CONF_FRAMEWORK] - .get(CONF_ADVANCED, {}) - .get(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES) - ): - _LOGGER.warning( - "120MHz PSRAM in octal mode is an experimental feature - use at your own risk" + esp32_config = fv.full_config.get()[PLATFORM_ESP32] + if config[CONF_SPEED] == 120e6: + if esp32_config[CONF_CPU_FREQUENCY] != "240MHZ": + raise cv.Invalid( + "PSRAM 120MHz requires 240MHz CPU frequency (set in esp32 component)" ) - else: - raise cv.Invalid("PSRAM 120MHz is not supported in octal mode") + if config[CONF_MODE] == TYPE_OCTAL: + if ( + esp32_config[CONF_FRAMEWORK] + .get(CONF_ADVANCED, {}) + .get(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES) + ): + _LOGGER.warning( + "120MHz PSRAM in octal mode is an experimental feature - use at your own risk" + ) + else: + raise cv.Invalid("PSRAM 120MHz is not supported in octal mode") if config[CONF_MODE] != TYPE_OCTAL and config[CONF_ENABLE_ECC]: raise cv.Invalid("ECC is only available in octal mode.") if config[CONF_MODE] == TYPE_OCTAL: @@ -112,7 +118,7 @@ async def to_code(config): add_idf_sdkconfig_option(f"{SPIRAM_MODES[config[CONF_MODE]]}", True) add_idf_sdkconfig_option(f"{SPIRAM_SPEEDS[config[CONF_SPEED]]}", True) if config[CONF_MODE] == TYPE_OCTAL and config[CONF_SPEED] == 120e6: - add_idf_sdkconfig_option("CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240", True) + add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 4, 0): add_idf_sdkconfig_option( "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index 051098f6e4..c6b3469ebe 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -43,8 +43,10 @@ from esphome.const import ( CONF_WINDOW_SIZE, DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_AQI, + DEVICE_CLASS_AREA, DEVICE_CLASS_ATMOSPHERIC_PRESSURE, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_BLOOD_GLUCOSE_CONCENTRATION, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_CARBON_MONOXIDE, DEVICE_CLASS_CONDUCTIVITY, @@ -56,6 +58,7 @@ from esphome.const import ( DEVICE_CLASS_DURATION, DEVICE_CLASS_EMPTY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_ENERGY_DISTANCE, DEVICE_CLASS_ENERGY_STORAGE, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, @@ -77,6 +80,7 @@ from esphome.const import ( DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_SOUND_PRESSURE, @@ -92,6 +96,7 @@ from esphome.const import ( DEVICE_CLASS_VOLUME_STORAGE, DEVICE_CLASS_WATER, DEVICE_CLASS_WEIGHT, + DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ENTITY_CATEGORY_CONFIG, ) @@ -104,8 +109,10 @@ CODEOWNERS = ["@esphome/core"] DEVICE_CLASSES = [ DEVICE_CLASS_APPARENT_POWER, DEVICE_CLASS_AQI, + DEVICE_CLASS_AREA, DEVICE_CLASS_ATMOSPHERIC_PRESSURE, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_BLOOD_GLUCOSE_CONCENTRATION, DEVICE_CLASS_CARBON_DIOXIDE, DEVICE_CLASS_CARBON_MONOXIDE, DEVICE_CLASS_CONDUCTIVITY, @@ -117,6 +124,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_DURATION, DEVICE_CLASS_EMPTY, DEVICE_CLASS_ENERGY, + DEVICE_CLASS_ENERGY_DISTANCE, DEVICE_CLASS_ENERGY_STORAGE, DEVICE_CLASS_FREQUENCY, DEVICE_CLASS_GAS, @@ -138,6 +146,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_PRECIPITATION, DEVICE_CLASS_PRECIPITATION_INTENSITY, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_REACTIVE_ENERGY, DEVICE_CLASS_REACTIVE_POWER, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_SOUND_PRESSURE, @@ -153,6 +162,7 @@ DEVICE_CLASSES = [ DEVICE_CLASS_VOLUME_STORAGE, DEVICE_CLASS_WATER, DEVICE_CLASS_WEIGHT, + DEVICE_CLASS_WIND_DIRECTION, DEVICE_CLASS_WIND_SPEED, ] diff --git a/esphome/components/speaker/media_player/audio_pipeline.cpp b/esphome/components/speaker/media_player/audio_pipeline.cpp index 60f562cc2c..ac122b6e0c 100644 --- a/esphome/components/speaker/media_player/audio_pipeline.cpp +++ b/esphome/components/speaker/media_player/audio_pipeline.cpp @@ -174,6 +174,16 @@ AudioPipelineState AudioPipeline::process_state() { } } + if ((event_bits & EventGroupBits::READER_MESSAGE_ERROR)) { + xEventGroupClearBits(this->event_group_, EventGroupBits::READER_MESSAGE_ERROR); + return AudioPipelineState::ERROR_READING; + } + + if ((event_bits & EventGroupBits::DECODER_MESSAGE_ERROR)) { + xEventGroupClearBits(this->event_group_, EventGroupBits::DECODER_MESSAGE_ERROR); + return AudioPipelineState::ERROR_DECODING; + } + if ((event_bits & EventGroupBits::READER_MESSAGE_FINISHED) && (!(event_bits & EventGroupBits::READER_MESSAGE_LOADED_MEDIA_TYPE) && (event_bits & EventGroupBits::DECODER_MESSAGE_FINISHED))) { @@ -203,16 +213,6 @@ AudioPipelineState AudioPipeline::process_state() { return AudioPipelineState::STOPPED; } - if ((event_bits & EventGroupBits::READER_MESSAGE_ERROR)) { - xEventGroupClearBits(this->event_group_, EventGroupBits::READER_MESSAGE_ERROR); - return AudioPipelineState::ERROR_READING; - } - - if ((event_bits & EventGroupBits::DECODER_MESSAGE_ERROR)) { - xEventGroupClearBits(this->event_group_, EventGroupBits::DECODER_MESSAGE_ERROR); - return AudioPipelineState::ERROR_DECODING; - } - if (this->pause_state_) { return AudioPipelineState::PAUSED; } diff --git a/esphome/components/text/__init__.py b/esphome/components/text/__init__.py index 1cc9283e45..a864a0ba4f 100644 --- a/esphome/components/text/__init__.py +++ b/esphome/components/text/__init__.py @@ -1,5 +1,3 @@ -from typing import Optional - from esphome import automation import esphome.codegen as cg from esphome.components import mqtt, web_server @@ -92,9 +90,9 @@ async def setup_text_core_( var, config, *, - min_length: Optional[int], - max_length: Optional[int], - pattern: Optional[str], + min_length: int | None, + max_length: int | None, + pattern: str | None, ): await setup_entity(var, config) @@ -121,9 +119,9 @@ async def register_text( var, config, *, - min_length: Optional[int] = 0, - max_length: Optional[int] = 255, - pattern: Optional[str] = None, + min_length: int | None = 0, + max_length: int | None = 255, + pattern: str | None = None, ): if not CORE.has_id(config[CONF_ID]): var = cg.Pvariable(config[CONF_ID], var) @@ -136,9 +134,9 @@ async def register_text( async def new_text( config, *, - min_length: Optional[int] = 0, - max_length: Optional[int] = 255, - pattern: Optional[str] = None, + min_length: int | None = 0, + max_length: int | None = 255, + pattern: str | None = None, ): var = cg.new_Pvariable(config[CONF_ID]) await register_text( diff --git a/esphome/components/time/__init__.py b/esphome/components/time/__init__.py index 6a3368ca73..6b3ff6f4d3 100644 --- a/esphome/components/time/__init__.py +++ b/esphome/components/time/__init__.py @@ -1,6 +1,5 @@ from importlib import resources import logging -from typing import Optional import tzlocal @@ -40,7 +39,7 @@ SyncTrigger = time_ns.class_("SyncTrigger", automation.Trigger.template(), cg.Co TimeHasTimeCondition = time_ns.class_("TimeHasTimeCondition", Condition) -def _load_tzdata(iana_key: str) -> Optional[bytes]: +def _load_tzdata(iana_key: str) -> bytes | None: # From https://tzdata.readthedocs.io/en/latest/#examples try: package_loc, resource = iana_key.rsplit("/", 1) diff --git a/esphome/components/tuya/select/__init__.py b/esphome/components/tuya/select/__init__.py index e5b2e36ce7..9f2b6f1e12 100644 --- a/esphome/components/tuya/select/__init__.py +++ b/esphome/components/tuya/select/__init__.py @@ -54,8 +54,8 @@ async def to_code(config): cg.add(var.set_select_mappings(list(options_map.keys()))) parent = await cg.get_variable(config[CONF_TUYA_ID]) cg.add(var.set_tuya_parent(parent)) - if enum_datapoint := config.get(CONF_ENUM_DATAPOINT, None) is not None: + if (enum_datapoint := config.get(CONF_ENUM_DATAPOINT, None)) is not None: cg.add(var.set_select_id(enum_datapoint, False)) - if int_datapoint := config.get(CONF_INT_DATAPOINT, None) is not None: + if (int_datapoint := config.get(CONF_INT_DATAPOINT, None)) is not None: cg.add(var.set_select_id(int_datapoint, True)) cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index bee037774f..a0908a299c 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,5 +1,4 @@ import re -from typing import Optional from esphome import automation, pins import esphome.codegen as cg @@ -322,12 +321,12 @@ def final_validate_device_schema( name: str, *, uart_bus: str = CONF_UART_ID, - baud_rate: Optional[int] = None, + baud_rate: int | None = None, require_tx: bool = False, require_rx: bool = False, - data_bits: Optional[int] = None, - parity: Optional[str] = None, - stop_bits: Optional[int] = None, + data_bits: int | None = None, + parity: str | None = None, + stop_bits: int | None = None, ): def validate_baud_rate(value): if value != baud_rate: diff --git a/esphome/components/usb_host/__init__.py b/esphome/components/usb_host/__init__.py new file mode 100644 index 0000000000..b6ca779706 --- /dev/null +++ b/esphome/components/usb_host/__init__.py @@ -0,0 +1,64 @@ +import esphome.codegen as cg +from esphome.components.esp32 import ( + VARIANT_ESP32S2, + VARIANT_ESP32S3, + add_idf_sdkconfig_option, + only_on_variant, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.cpp_types import Component + +AUTO_LOAD = ["bytebuffer"] +CODEOWNERS = ["@clydebarrow"] +DEPENDENCIES = ["esp32"] +usb_host_ns = cg.esphome_ns.namespace("usb_host") +USBHost = usb_host_ns.class_("USBHost", Component) +USBClient = usb_host_ns.class_("USBClient", Component) + +CONF_DEVICES = "devices" +CONF_VID = "vid" +CONF_PID = "pid" + + +def usb_device_schema(cls=USBClient, vid: int = None, pid: [int] = None) -> cv.Schema: + schema = cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(cls), + } + ) + if vid: + schema = schema.extend({cv.Optional(CONF_VID, default=vid): cv.hex_uint16_t}) + else: + schema = schema.extend({cv.Required(CONF_VID): cv.hex_uint16_t}) + if pid: + schema = schema.extend({cv.Optional(CONF_PID, default=pid): cv.hex_uint16_t}) + else: + schema = schema.extend({cv.Required(CONF_PID): cv.hex_uint16_t}) + return schema + + +CONFIG_SCHEMA = cv.All( + cv.COMPONENT_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(USBHost), + cv.Optional(CONF_DEVICES): cv.ensure_list(usb_device_schema()), + } + ), + cv.only_with_esp_idf, + only_on_variant(supported=[VARIANT_ESP32S2, VARIANT_ESP32S3]), +) + + +async def register_usb_client(config): + var = cg.new_Pvariable(config[CONF_ID], config[CONF_VID], config[CONF_PID]) + await cg.register_component(var, config) + return var + + +async def to_code(config): + add_idf_sdkconfig_option("CONFIG_USB_HOST_CONTROL_TRANSFER_MAX_SIZE", 1024) + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + for device in config.get(CONF_DEVICES) or (): + await register_usb_client(device) diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h new file mode 100644 index 0000000000..c5466eb1f0 --- /dev/null +++ b/esphome/components/usb_host/usb_host.h @@ -0,0 +1,116 @@ +#pragma once + +// Should not be needed, but it's required to pass CI clang-tidy checks +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/core/component.h" +#include +#include "usb/usb_host.h" + +#include + +namespace esphome { +namespace usb_host { + +static const char *const TAG = "usb_host"; + +// constants for setup packet type +static const uint8_t USB_RECIP_DEVICE = 0; +static const uint8_t USB_RECIP_INTERFACE = 1; +static const uint8_t USB_RECIP_ENDPOINT = 2; +static const uint8_t USB_TYPE_STANDARD = 0 << 5; +static const uint8_t USB_TYPE_CLASS = 1 << 5; +static const uint8_t USB_TYPE_VENDOR = 2 << 5; +static const uint8_t USB_DIR_MASK = 1 << 7; +static const uint8_t USB_DIR_IN = 1 << 7; +static const uint8_t USB_DIR_OUT = 0; +static const size_t SETUP_PACKET_SIZE = 8; + +static const size_t MAX_REQUESTS = 16; // maximum number of outstanding requests possible. + +// used to report a transfer status +struct TransferStatus { + bool success; + uint16_t error_code; + uint8_t *data; + size_t data_len; + uint8_t endpoint; + void *user_data; +}; + +using transfer_cb_t = std::function; + +class USBClient; + +// struct used to capture all data needed for a transfer +struct TransferRequest { + usb_transfer_t *transfer; + transfer_cb_t callback; + TransferStatus status; + USBClient *client; +}; + +// callback function type. + +enum ClientState { + USB_CLIENT_INIT = 0, + USB_CLIENT_OPEN, + USB_CLIENT_CLOSE, + USB_CLIENT_GET_DESC, + USB_CLIENT_GET_INFO, + USB_CLIENT_CONNECTED, +}; +class USBClient : public Component { + friend class USBHost; + + public: + USBClient(uint16_t vid, uint16_t pid) : vid_(vid), pid_(pid) { init_pool(); } + + void init_pool() { + this->trq_pool_.clear(); + for (size_t i = 0; i != MAX_REQUESTS; i++) + this->trq_pool_.push_back(&this->requests_[i]); + } + void setup() override; + void loop() override; + // setup must happen after the host bus has been setup + float get_setup_priority() const override { return setup_priority::IO; } + void on_opened(uint8_t addr); + void on_removed(usb_device_handle_t handle); + void control_transfer_callback(const usb_transfer_t *xfer) const; + void transfer_in(uint8_t ep_address, const transfer_cb_t &callback, uint16_t length); + void transfer_out(uint8_t ep_address, const transfer_cb_t &callback, const uint8_t *data, uint16_t length); + void dump_config() override; + void release_trq(TransferRequest *trq); + bool control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, const transfer_cb_t &callback, + const std::vector &data = {}); + + protected: + bool register_(); + TransferRequest *get_trq_(); + virtual void disconnect(); + virtual void on_connected() {} + virtual void on_disconnected() { this->init_pool(); } + + usb_host_client_handle_t handle_{}; + usb_device_handle_t device_handle_{}; + int device_addr_{-1}; + int state_{USB_CLIENT_INIT}; + uint16_t vid_{}; + uint16_t pid_{}; + std::list trq_pool_{}; + TransferRequest requests_[MAX_REQUESTS]{}; +}; +class USBHost : public Component { + public: + float get_setup_priority() const override { return setup_priority::BUS; } + void loop() override; + void setup() override; + + protected: + std::vector clients_{}; +}; + +} // namespace usb_host +} // namespace esphome + +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp new file mode 100644 index 0000000000..09422f570f --- /dev/null +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -0,0 +1,392 @@ +// Should not be needed, but it's required to pass CI clang-tidy checks +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_host.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" +#include "esphome/components/bytebuffer/bytebuffer.h" + +#include +#include +namespace esphome { +namespace usb_host { + +#pragma GCC diagnostic ignored "-Wparentheses" + +using namespace bytebuffer; + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE +static void print_ep_desc(const usb_ep_desc_t *ep_desc) { + const char *ep_type_str; + int type = ep_desc->bmAttributes & USB_BM_ATTRIBUTES_XFERTYPE_MASK; + + switch (type) { + case USB_BM_ATTRIBUTES_XFER_CONTROL: + ep_type_str = "CTRL"; + break; + case USB_BM_ATTRIBUTES_XFER_ISOC: + ep_type_str = "ISOC"; + break; + case USB_BM_ATTRIBUTES_XFER_BULK: + ep_type_str = "BULK"; + break; + case USB_BM_ATTRIBUTES_XFER_INT: + ep_type_str = "INT"; + break; + default: + ep_type_str = NULL; + break; + } + + ESP_LOGV(TAG, "\t\t*** Endpoint descriptor ***"); + ESP_LOGV(TAG, "\t\tbLength %d", ep_desc->bLength); + ESP_LOGV(TAG, "\t\tbDescriptorType %d", ep_desc->bDescriptorType); + ESP_LOGV(TAG, "\t\tbEndpointAddress 0x%x\tEP %d %s", ep_desc->bEndpointAddress, USB_EP_DESC_GET_EP_NUM(ep_desc), + USB_EP_DESC_GET_EP_DIR(ep_desc) ? "IN" : "OUT"); + ESP_LOGV(TAG, "\t\tbmAttributes 0x%x\t%s", ep_desc->bmAttributes, ep_type_str); + ESP_LOGV(TAG, "\t\twMaxPacketSize %d", ep_desc->wMaxPacketSize); + ESP_LOGV(TAG, "\t\tbInterval %d", ep_desc->bInterval); +} + +static void usbh_print_intf_desc(const usb_intf_desc_t *intf_desc) { + ESP_LOGV(TAG, "\t*** Interface descriptor ***"); + ESP_LOGV(TAG, "\tbLength %d", intf_desc->bLength); + ESP_LOGV(TAG, "\tbDescriptorType %d", intf_desc->bDescriptorType); + ESP_LOGV(TAG, "\tbInterfaceNumber %d", intf_desc->bInterfaceNumber); + ESP_LOGV(TAG, "\tbAlternateSetting %d", intf_desc->bAlternateSetting); + ESP_LOGV(TAG, "\tbNumEndpoints %d", intf_desc->bNumEndpoints); + ESP_LOGV(TAG, "\tbInterfaceClass 0x%x", intf_desc->bInterfaceProtocol); + ESP_LOGV(TAG, "\tiInterface %d", intf_desc->iInterface); +} + +static void usbh_print_cfg_desc(const usb_config_desc_t *cfg_desc) { + ESP_LOGV(TAG, "*** Configuration descriptor ***"); + ESP_LOGV(TAG, "bLength %d", cfg_desc->bLength); + ESP_LOGV(TAG, "bDescriptorType %d", cfg_desc->bDescriptorType); + ESP_LOGV(TAG, "wTotalLength %d", cfg_desc->wTotalLength); + ESP_LOGV(TAG, "bNumInterfaces %d", cfg_desc->bNumInterfaces); + ESP_LOGV(TAG, "bConfigurationValue %d", cfg_desc->bConfigurationValue); + ESP_LOGV(TAG, "iConfiguration %d", cfg_desc->iConfiguration); + ESP_LOGV(TAG, "bmAttributes 0x%x", cfg_desc->bmAttributes); + ESP_LOGV(TAG, "bMaxPower %dmA", cfg_desc->bMaxPower * 2); +} + +void usb_client_print_device_descriptor(const usb_device_desc_t *devc_desc) { + if (devc_desc == NULL) { + return; + } + + ESP_LOGV(TAG, "*** Device descriptor ***"); + ESP_LOGV(TAG, "bLength %d", devc_desc->bLength); + ESP_LOGV(TAG, "bDescriptorType %d", devc_desc->bDescriptorType); + ESP_LOGV(TAG, "bcdUSB %d.%d0", ((devc_desc->bcdUSB >> 8) & 0xF), ((devc_desc->bcdUSB >> 4) & 0xF)); + ESP_LOGV(TAG, "bDeviceClass 0x%x", devc_desc->bDeviceClass); + ESP_LOGV(TAG, "bDeviceSubClass 0x%x", devc_desc->bDeviceSubClass); + ESP_LOGV(TAG, "bDeviceProtocol 0x%x", devc_desc->bDeviceProtocol); + ESP_LOGV(TAG, "bMaxPacketSize0 %d", devc_desc->bMaxPacketSize0); + ESP_LOGV(TAG, "idVendor 0x%x", devc_desc->idVendor); + ESP_LOGV(TAG, "idProduct 0x%x", devc_desc->idProduct); + ESP_LOGV(TAG, "bcdDevice %d.%d0", ((devc_desc->bcdDevice >> 8) & 0xF), ((devc_desc->bcdDevice >> 4) & 0xF)); + ESP_LOGV(TAG, "iManufacturer %d", devc_desc->iManufacturer); + ESP_LOGV(TAG, "iProduct %d", devc_desc->iProduct); + ESP_LOGV(TAG, "iSerialNumber %d", devc_desc->iSerialNumber); + ESP_LOGV(TAG, "bNumConfigurations %d", devc_desc->bNumConfigurations); +} + +void usb_client_print_config_descriptor(const usb_config_desc_t *cfg_desc, + print_class_descriptor_cb class_specific_cb) { + if (cfg_desc == nullptr) { + return; + } + + int offset = 0; + uint16_t w_total_length = cfg_desc->wTotalLength; + const usb_standard_desc_t *next_desc = (const usb_standard_desc_t *) cfg_desc; + + do { + switch (next_desc->bDescriptorType) { + case USB_W_VALUE_DT_CONFIG: + usbh_print_cfg_desc((const usb_config_desc_t *) next_desc); + break; + case USB_W_VALUE_DT_INTERFACE: + usbh_print_intf_desc((const usb_intf_desc_t *) next_desc); + break; + case USB_W_VALUE_DT_ENDPOINT: + print_ep_desc((const usb_ep_desc_t *) next_desc); + break; + default: + if (class_specific_cb) { + class_specific_cb(next_desc); + } + break; + } + + next_desc = usb_parse_next_descriptor(next_desc, w_total_length, &offset); + + } while (next_desc != NULL); +} +#endif +static std::string get_descriptor_string(const usb_str_desc_t *desc) { + char buffer[256]; + if (desc == nullptr) + return "(unknown)"; + char *p = buffer; + for (size_t i = 0; i != desc->bLength / 2; i++) { + auto c = desc->wData[i]; + if (c < 0x100) + *p++ = static_cast(c); + } + *p = '\0'; + return {buffer}; +} + +static void client_event_cb(const usb_host_client_event_msg_t *event_msg, void *ptr) { + auto *client = static_cast(ptr); + switch (event_msg->event) { + case USB_HOST_CLIENT_EVENT_NEW_DEV: { + auto addr = event_msg->new_dev.address; + ESP_LOGD(TAG, "New device %d", event_msg->new_dev.address); + client->on_opened(addr); + break; + } + case USB_HOST_CLIENT_EVENT_DEV_GONE: { + client->on_removed(event_msg->dev_gone.dev_hdl); + ESP_LOGD(TAG, "Device gone %d", event_msg->new_dev.address); + break; + } + default: + ESP_LOGD(TAG, "Unknown event %d", event_msg->event); + break; + } +} +void USBClient::setup() { + usb_host_client_config_t config{.is_synchronous = false, + .max_num_event_msg = 5, + .async = {.client_event_callback = client_event_cb, .callback_arg = this}}; + auto err = usb_host_client_register(&config, &this->handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "client register failed: %s", esp_err_to_name(err)); + this->status_set_error("Client register failed"); + this->mark_failed(); + return; + } + for (auto trq : this->trq_pool_) { + usb_host_transfer_alloc(64, 0, &trq->transfer); + trq->client = this; + } + ESP_LOGCONFIG(TAG, "client setup complete"); +} + +void USBClient::loop() { + switch (this->state_) { + case USB_CLIENT_OPEN: { + int err; + ESP_LOGD(TAG, "Open device %d", this->device_addr_); + err = usb_host_device_open(this->handle_, this->device_addr_, &this->device_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Device open failed: %s", esp_err_to_name(err)); + this->state_ = USB_CLIENT_INIT; + break; + } + ESP_LOGD(TAG, "Get descriptor device %d", this->device_addr_); + const usb_device_desc_t *desc; + err = usb_host_get_device_descriptor(this->device_handle_, &desc); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Device get_desc failed: %s", esp_err_to_name(err)); + this->disconnect(); + } else { + ESP_LOGD(TAG, "Device descriptor: vid %X pid %X", desc->idVendor, desc->idProduct); + if (desc->idVendor == this->vid_ && desc->idProduct == this->pid_ || this->vid_ == 0 && this->pid_ == 0) { + usb_device_info_t dev_info; + if ((err = usb_host_device_info(this->device_handle_, &dev_info)) != ESP_OK) { + ESP_LOGW(TAG, "Device info failed: %s", esp_err_to_name(err)); + this->disconnect(); + break; + } + this->state_ = USB_CLIENT_CONNECTED; + ESP_LOGD(TAG, "Device connected: Manuf: %s; Prod: %s; Serial: %s", + get_descriptor_string(dev_info.str_desc_manufacturer).c_str(), + get_descriptor_string(dev_info.str_desc_product).c_str(), + get_descriptor_string(dev_info.str_desc_serial_num).c_str()); + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + const usb_device_desc_t *device_desc; + err = usb_host_get_device_descriptor(this->device_handle_, &device_desc); + if (err == ESP_OK) + usb_client_print_device_descriptor(device_desc); + const usb_config_desc_t *config_desc; + err = usb_host_get_active_config_descriptor(this->device_handle_, &config_desc); + if (err == ESP_OK) + usb_client_print_config_descriptor(config_desc, nullptr); +#endif + this->on_connected(); + } else { + ESP_LOGD(TAG, "Not our device, closing"); + this->disconnect(); + } + } + break; + } + + default: + usb_host_client_handle_events(this->handle_, 0); + break; + } +} + +void USBClient::on_opened(uint8_t addr) { + if (this->state_ == USB_CLIENT_INIT) { + this->device_addr_ = addr; + this->state_ = USB_CLIENT_OPEN; + } +} +void USBClient::on_removed(usb_device_handle_t handle) { + if (this->device_handle_ == handle) { + this->disconnect(); + } +} + +static void control_callback(const usb_transfer_t *xfer) { + auto *trq = static_cast(xfer->context); + trq->status.error_code = xfer->status; + trq->status.success = xfer->status == USB_TRANSFER_STATUS_COMPLETED; + trq->status.endpoint = xfer->bEndpointAddress; + trq->status.data = xfer->data_buffer; + trq->status.data_len = xfer->actual_num_bytes; + if (trq->callback != nullptr) + trq->callback(trq->status); + trq->client->release_trq(trq); +} + +TransferRequest *USBClient::get_trq_() { + if (this->trq_pool_.empty()) { + ESP_LOGE(TAG, "Too many requests queued"); + return nullptr; + } + auto *trq = this->trq_pool_.front(); + this->trq_pool_.pop_front(); + trq->client = this; + trq->transfer->context = trq; + trq->transfer->device_handle = this->device_handle_; + return trq; +} +void USBClient::disconnect() { + this->on_disconnected(); + auto err = usb_host_device_close(this->handle_, this->device_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Device close failed: %s", esp_err_to_name(err)); + } + this->state_ = USB_CLIENT_INIT; + this->device_handle_ = nullptr; + this->device_addr_ = -1; +} + +bool USBClient::control_transfer(uint8_t type, uint8_t request, uint16_t value, uint16_t index, + const transfer_cb_t &callback, const std::vector &data) { + auto *trq = this->get_trq_(); + if (trq == nullptr) + return false; + auto length = data.size(); + if (length > sizeof(trq->transfer->data_buffer_size) - SETUP_PACKET_SIZE) { + ESP_LOGE(TAG, "Control transfer data size too large: %u > %u", length, + sizeof(trq->transfer->data_buffer_size) - sizeof(usb_setup_packet_t)); + this->release_trq(trq); + return false; + } + auto control_packet = ByteBuffer(SETUP_PACKET_SIZE, LITTLE); + control_packet.put_uint8(type); + control_packet.put_uint8(request); + control_packet.put_uint16(value); + control_packet.put_uint16(index); + control_packet.put_uint16(length); + memcpy(trq->transfer->data_buffer, control_packet.get_data().data(), SETUP_PACKET_SIZE); + if (length != 0 && !(type & USB_DIR_IN)) { + memcpy(trq->transfer->data_buffer + SETUP_PACKET_SIZE, data.data(), length); + } + trq->callback = callback; + trq->transfer->bEndpointAddress = type & USB_DIR_MASK; + trq->transfer->num_bytes = static_cast(length + SETUP_PACKET_SIZE); + trq->transfer->callback = reinterpret_cast(control_callback); + auto err = usb_host_transfer_submit_control(this->handle_, trq->transfer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to submit control transfer, err=%s", esp_err_to_name(err)); + this->release_trq(trq); + return false; + } + return true; +} + +static void transfer_callback(usb_transfer_t *xfer) { + auto *trq = static_cast(xfer->context); + trq->status.error_code = xfer->status; + trq->status.success = xfer->status == USB_TRANSFER_STATUS_COMPLETED; + trq->status.endpoint = xfer->bEndpointAddress; + trq->status.data = xfer->data_buffer; + trq->status.data_len = xfer->actual_num_bytes; + if (trq->callback != nullptr) + trq->callback(trq->status); + trq->client->release_trq(trq); +} +/** + * Performs a transfer input operation. + * + * @param ep_address The endpoint address. + * @param callback The callback function to be called when the transfer is complete. + * @param length The length of the data to be transferred. + * + * @throws None. + */ +void USBClient::transfer_in(uint8_t ep_address, const transfer_cb_t &callback, uint16_t length) { + auto trq = this->get_trq_(); + if (trq == nullptr) { + ESP_LOGE(TAG, "Too many requests queued"); + return; + } + trq->callback = callback; + trq->transfer->callback = transfer_callback; + trq->transfer->bEndpointAddress = ep_address | USB_DIR_IN; + trq->transfer->num_bytes = length; + auto err = usb_host_transfer_submit(trq->transfer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to submit transfer, address=%x, length=%d, err=%x", ep_address, length, err); + this->release_trq(trq); + this->disconnect(); + } +} + +/** + * Performs an output transfer operation. + * + * @param ep_address The endpoint address. + * @param callback The callback function to be called when the transfer is complete. + * @param data The data to be transferred. + * @param length The length of the data to be transferred. + * + * @throws None. + */ +void USBClient::transfer_out(uint8_t ep_address, const transfer_cb_t &callback, const uint8_t *data, uint16_t length) { + auto trq = this->get_trq_(); + if (trq == nullptr) { + ESP_LOGE(TAG, "Too many requests queued"); + return; + } + trq->callback = callback; + trq->transfer->callback = transfer_callback; + trq->transfer->bEndpointAddress = ep_address | USB_DIR_OUT; + trq->transfer->num_bytes = length; + memcpy(trq->transfer->data_buffer, data, length); + auto err = usb_host_transfer_submit(trq->transfer); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to submit transfer, address=%x, length=%d, err=%x", ep_address, length, err); + this->release_trq(trq); + } +} +void USBClient::dump_config() { + ESP_LOGCONFIG(TAG, "USBClient"); + ESP_LOGCONFIG(TAG, " Vendor id %04X", this->vid_); + ESP_LOGCONFIG(TAG, " Product id %04X", this->pid_); +} +void USBClient::release_trq(TransferRequest *trq) { this->trq_pool_.push_back(trq); } + +} // namespace usb_host +} // namespace esphome +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp new file mode 100644 index 0000000000..63a2ab77cc --- /dev/null +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -0,0 +1,35 @@ +// Should not be needed, but it's required to pass CI clang-tidy checks +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_host.h" +#include +#include "esphome/core/log.h" + +namespace esphome { +namespace usb_host { + +void USBHost::setup() { + ESP_LOGCONFIG(TAG, "Setup starts"); + usb_host_config_t config{}; + + if (usb_host_install(&config) != ESP_OK) { + this->status_set_error("usb_host_install failed"); + this->mark_failed(); + return; + } +} +void USBHost::loop() { + int err; + uint32_t event_flags; + err = usb_host_lib_handle_events(0, &event_flags); + if (err != ESP_OK && err != ESP_ERR_TIMEOUT) { + ESP_LOGD(TAG, "lib_handle_events failed failed: %s", esp_err_to_name(err)); + } + if (event_flags != 0) { + ESP_LOGD(TAG, "Event flags %" PRIu32 "X", event_flags); + } +} + +} // namespace usb_host +} // namespace esphome + +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py new file mode 100644 index 0000000000..6999b1b955 --- /dev/null +++ b/esphome/components/usb_uart/__init__.py @@ -0,0 +1,134 @@ +import esphome.codegen as cg +from esphome.components.uart import ( + CONF_DATA_BITS, + CONF_PARITY, + CONF_STOP_BITS, + UARTComponent, +) +from esphome.components.usb_host import register_usb_client, usb_device_schema +import esphome.config_validation as cv +from esphome.const import ( + CONF_BAUD_RATE, + CONF_BUFFER_SIZE, + CONF_CHANNELS, + CONF_DEBUG, + CONF_DUMMY_RECEIVER, + CONF_ID, +) +from esphome.cpp_types import Component + +AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] +CODEOWNERS = ["@clydebarrow"] + +usb_uart_ns = cg.esphome_ns.namespace("usb_uart") +USBUartComponent = usb_uart_ns.class_("USBUartComponent", Component) +USBUartChannel = usb_uart_ns.class_("USBUartChannel", UARTComponent) + + +UARTParityOptions = usb_uart_ns.enum("UARTParityOptions") +UART_PARITY_OPTIONS = { + "NONE": UARTParityOptions.UART_CONFIG_PARITY_NONE, + "EVEN": UARTParityOptions.UART_CONFIG_PARITY_EVEN, + "ODD": UARTParityOptions.UART_CONFIG_PARITY_ODD, + "MARK": UARTParityOptions.UART_CONFIG_PARITY_MARK, + "SPACE": UARTParityOptions.UART_CONFIG_PARITY_SPACE, +} + +UARTStopBitsOptions = usb_uart_ns.enum("UARTStopBitsOptions") +UART_STOP_BITS_OPTIONS = { + "1": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_1, + "1.5": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_1_5, + "2": UARTStopBitsOptions.UART_CONFIG_STOP_BITS_2, +} + +DEFAULT_BAUD_RATE = 9600 + + +class Type: + def __init__(self, name, vid, pid, cls, max_channels=1, baud_rate_required=True): + self.name = name + cls = cls or name + self.vid = vid + self.pid = pid + self.cls = usb_uart_ns.class_(f"USBUartType{cls}", USBUartComponent) + self.max_channels = max_channels + self.baud_rate_required = baud_rate_required + + +uart_types = ( + Type("CH34X", 0x1A86, 0x55D5, "CH34X", 3), + Type("CH340", 0x1A86, 0x7523, "CH34X", 1), + Type("ESP_JTAG", 0x303A, 0x1001, "CdcAcm", 1, baud_rate_required=False), + Type("STM32_VCP", 0x0483, 0x5740, "CdcAcm", 1, baud_rate_required=False), + Type("CDC_ACM", 0, 0, "CdcAcm", 1, baud_rate_required=False), + Type("CP210X", 0x10C4, 0xEA60, "CP210X", 3), +) + + +def channel_schema(channels, baud_rate_required): + return cv.Schema( + { + cv.Required(CONF_CHANNELS): cv.All( + cv.ensure_list( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBUartChannel), + cv.Optional(CONF_BUFFER_SIZE, default=256): cv.int_range( + min=64, max=8192 + ), + ( + cv.Required(CONF_BAUD_RATE) + if baud_rate_required + else cv.Optional( + CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE + ) + ): cv.int_range(min=300, max=1000000), + cv.Optional(CONF_STOP_BITS, default="1"): cv.enum( + UART_STOP_BITS_OPTIONS, upper=True + ), + cv.Optional(CONF_PARITY, default="NONE"): cv.enum( + UART_PARITY_OPTIONS, upper=True + ), + cv.Optional(CONF_DATA_BITS, default=8): cv.int_range( + min=5, max=8 + ), + cv.Optional(CONF_DUMMY_RECEIVER, default=False): cv.boolean, + cv.Optional(CONF_DEBUG, default=False): cv.boolean, + } + ) + ), + cv.Length(max=channels), + ) + } + ) + + +CONFIG_SCHEMA = cv.ensure_list( + cv.typed_schema( + { + it.name: usb_device_schema(it.cls, it.vid, it.pid).extend( + channel_schema(it.max_channels, it.baud_rate_required) + ) + for it in uart_types + }, + upper=True, + ) +) + + +async def to_code(config): + for device in config: + var = await register_usb_client(device) + for index, channel in enumerate(device[CONF_CHANNELS]): + chvar = cg.new_Pvariable(channel[CONF_ID], index, channel[CONF_BUFFER_SIZE]) + await cg.register_parented(chvar, var) + cg.add(chvar.set_rx_buffer_size(channel[CONF_BUFFER_SIZE])) + cg.add(chvar.set_stop_bits(channel[CONF_STOP_BITS])) + cg.add(chvar.set_data_bits(channel[CONF_DATA_BITS])) + cg.add(chvar.set_parity(channel[CONF_PARITY])) + cg.add(chvar.set_baud_rate(channel[CONF_BAUD_RATE])) + cg.add(chvar.set_dummy_receiver(channel[CONF_DUMMY_RECEIVER])) + cg.add(chvar.set_debug(channel[CONF_DEBUG])) + cg.add(var.add_channel(chvar)) + if channel[CONF_DEBUG]: + cg.add_define("USE_UART_DEBUGGER") diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp new file mode 100644 index 0000000000..74e7933824 --- /dev/null +++ b/esphome/components/usb_uart/ch34x.cpp @@ -0,0 +1,80 @@ +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_uart.h" +#include "usb/usb_host.h" +#include "esphome/core/log.h" + +#include "esphome/components/bytebuffer/bytebuffer.h" + +namespace esphome { +namespace usb_uart { + +using namespace bytebuffer; +/** + * CH34x + */ + +void USBUartTypeCH34X::enable_channels() { + // enable the channels + for (auto channel : this->channels_) { + if (!channel->initialised_) + continue; + usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_ = false; + } + }; + + uint8_t divisor = 7; + uint32_t clk = 12000000; + + auto baud_rate = channel->baud_rate_; + if (baud_rate < 256000) { + if (baud_rate > 6000000 / 255) { + divisor = 3; + clk = 6000000; + } else if (baud_rate > 750000 / 255) { + divisor = 2; + clk = 750000; + } else if (baud_rate > 93750 / 255) { + divisor = 1; + clk = 93750; + } else { + divisor = 0; + clk = 11719; + } + } + ESP_LOGV(TAG, "baud_rate: %" PRIu32 ", divisor: %d, clk: %" PRIu32, baud_rate, divisor, clk); + auto factor = static_cast(clk / baud_rate); + if (factor == 0 || factor == 0xFF) { + ESP_LOGE(TAG, "Invalid baud rate %" PRIu32, baud_rate); + channel->initialised_ = false; + continue; + } + if ((clk / factor - baud_rate) > (baud_rate - clk / (factor + 1))) + factor++; + factor = 256 - factor; + + uint16_t value = 0xC0; + if (channel->stop_bits_ == UART_CONFIG_STOP_BITS_2) + value |= 4; + switch (channel->parity_) { + case UART_CONFIG_PARITY_NONE: + break; + default: + value |= 8 | ((channel->parity_ - 1) << 4); + break; + } + value |= channel->data_bits_ - 5; + value <<= 8; + value |= 0x8C; + uint8_t cmd = 0xA1 + channel->index_; + if (channel->index_ >= 2) + cmd += 0xE; + this->control_transfer(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor, callback); + } + USBUartTypeCdcAcm::enable_channels(); +} +} // namespace usb_uart +} // namespace esphome +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp new file mode 100644 index 0000000000..267385d1bd --- /dev/null +++ b/esphome/components/usb_uart/cp210x.cpp @@ -0,0 +1,126 @@ +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_uart.h" +#include "usb/usb_host.h" +#include "esphome/core/log.h" + +#include "esphome/components/bytebuffer/bytebuffer.h" + +namespace esphome { +namespace usb_uart { + +using namespace bytebuffer; +/** + * Silabs CP210x Commands + */ + +static constexpr uint8_t IFC_ENABLE = 0x00; // Enable or disable the interface. +static constexpr uint8_t SET_BAUDDIV = 0x01; // Set the baud rate divisor. +static constexpr uint8_t GET_BAUDDIV = 0x02; // Get the baud rate divisor. +static constexpr uint8_t SET_LINE_CTL = 0x03; // Set the line control. +static constexpr uint8_t GET_LINE_CTL = 0x04; // Get the line control. +static constexpr uint8_t SET_BREAK = 0x05; // Set a BREAK. +static constexpr uint8_t IMM_CHAR = 0x06; // Send character out of order. +static constexpr uint8_t SET_MHS = 0x07; // Set modem handshaking. +static constexpr uint8_t GET_MDMSTS = 0x08; // Get modem status. +static constexpr uint8_t SET_XON = 0x09; // Emulate XON. +static constexpr uint8_t SET_XOFF = 0x0A; // Emulate XOFF. +static constexpr uint8_t SET_EVENTMASK = 0x0B; // Set the event mask. +static constexpr uint8_t GET_EVENTMASK = 0x0C; // Get the event mask. +static constexpr uint8_t GET_EVENTSTATE = 0x16; // Get the event state. +static constexpr uint8_t SET_RECEIVE = 0x17; // Set receiver max timeout. +static constexpr uint8_t GET_RECEIVE = 0x18; // Get receiver max timeout. +static constexpr uint8_t SET_CHAR = 0x0D; // Set special character individually. +static constexpr uint8_t GET_CHARS = 0x0E; // Get special characters. +static constexpr uint8_t GET_PROPS = 0x0F; // Get properties. +static constexpr uint8_t GET_COMM_STATUS = 0x10; // Get the serial status. +static constexpr uint8_t RESET = 0x11; // Reset. +static constexpr uint8_t PURGE = 0x12; // Purge. +static constexpr uint8_t SET_FLOW = 0x13; // Set flow control. +static constexpr uint8_t GET_FLOW = 0x14; // Get flow control. +static constexpr uint8_t EMBED_EVENTS = 0x15; // Control embedding of events in the data stream. +static constexpr uint8_t GET_BAUDRATE = 0x1D; // Get the baud rate. +static constexpr uint8_t SET_BAUDRATE = 0x1E; // Set the baud rate. +static constexpr uint8_t SET_CHARS = 0x19; // Set special characters. +static constexpr uint8_t VENDOR_SPECIFIC = 0xFF; // Vendor specific command. + +std::vector USBUartTypeCP210X::parse_descriptors_(usb_device_handle_t dev_hdl) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + int conf_offset = 0, ep_offset; + std::vector cdc_devs{}; + + // Get required descriptors + if (usb_host_get_device_descriptor(dev_hdl, &device_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_device_descriptor failed"); + return {}; + } + if (usb_host_get_active_config_descriptor(dev_hdl, &config_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_active_config_descriptor failed"); + return {}; + } + ESP_LOGD(TAG, "bDeviceClass: %u, bDeviceSubClass: %u", device_desc->bDeviceClass, device_desc->bDeviceSubClass); + ESP_LOGD(TAG, "bNumInterfaces: %u", config_desc->bNumInterfaces); + if (device_desc->bDeviceClass != 0) { + ESP_LOGE(TAG, "bDeviceClass != 0"); + return {}; + } + + for (uint8_t i = 0; i != config_desc->bNumInterfaces; i++) { + auto data_desc = usb_parse_interface_descriptor(config_desc, 0, 0, &conf_offset); + if (!data_desc) { + ESP_LOGE(TAG, "data_desc: usb_parse_interface_descriptor failed"); + break; + } + if (data_desc->bNumEndpoints != 2 || data_desc->bInterfaceClass != USB_CLASS_VENDOR_SPEC) { + ESP_LOGE(TAG, "data_desc: bInterfaceClass == %u, bInterfaceSubClass == %u, bNumEndpoints == %u", + data_desc->bInterfaceClass, data_desc->bInterfaceSubClass, data_desc->bNumEndpoints); + continue; + } + ep_offset = conf_offset; + auto out_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 0, config_desc->wTotalLength, &ep_offset); + if (!out_ep) { + ESP_LOGE(TAG, "out_ep: usb_parse_endpoint_descriptor_by_index failed"); + continue; + } + ep_offset = conf_offset; + auto in_ep = usb_parse_endpoint_descriptor_by_index(data_desc, 1, config_desc->wTotalLength, &ep_offset); + if (!in_ep) { + ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); + continue; + } + if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) { + cdc_devs.push_back({CdcEps{nullptr, in_ep, out_ep, data_desc->bInterfaceNumber}}); + } else { + cdc_devs.push_back({CdcEps{nullptr, out_ep, in_ep, data_desc->bInterfaceNumber}}); + } + } + return cdc_devs; +} + +void USBUartTypeCP210X::enable_channels() { + // enable the channels + for (auto channel : this->channels_) { + if (!channel->initialised_) + continue; + usb_host::transfer_cb_t callback = [=](const usb_host::TransferStatus &status) { + if (!status.success) { + ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); + channel->initialised_ = false; + } + }; + this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_, callback); + uint16_t line_control = channel->stop_bits_; + line_control |= static_cast(channel->parity_) << 4; + line_control |= channel->data_bits_ << 8; + ESP_LOGD(TAG, "Line control value 0x%X", line_control); + this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_LINE_CTL, line_control, channel->index_, + callback); + auto baud = ByteBuffer::wrap(channel->baud_rate_, LITTLE); + this->control_transfer(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, callback, + baud.get_data()); + } + USBUartTypeCdcAcm::enable_channels(); +} +} // namespace usb_uart +} // namespace esphome +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp new file mode 100644 index 0000000000..30a45f9cb0 --- /dev/null +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -0,0 +1,325 @@ +// Should not be needed, but it's required to pass CI clang-tidy checks +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_uart.h" +#include "esphome/core/log.h" +#include "esphome/components/uart/uart_debugger.h" + +#include + +namespace esphome { +namespace usb_uart { + +/** + * + * Given a configuration, look for the required interfaces defining a CDC-ACM device + * @param config_desc The configuration descriptor + * @param intf_idx The index of the interface to be examined + * @return + */ +static optional get_cdc(const usb_config_desc_t *config_desc, uint8_t intf_idx) { + int conf_offset, ep_offset; + const usb_ep_desc_t *notify_ep{}, *in_ep{}, *out_ep{}; + uint8_t interface_number = 0; + // look for an interface with one interrupt endpoint (notify), and an interface with two bulk endpoints (data in/out) + for (;;) { + auto intf_desc = usb_parse_interface_descriptor(config_desc, intf_idx++, 0, &conf_offset); + if (!intf_desc) { + ESP_LOGE(TAG, "usb_parse_interface_descriptor failed"); + return nullopt; + } + if (intf_desc->bNumEndpoints == 1) { + ep_offset = conf_offset; + notify_ep = usb_parse_endpoint_descriptor_by_index(intf_desc, 0, config_desc->wTotalLength, &ep_offset); + if (!notify_ep) { + ESP_LOGE(TAG, "notify_ep: usb_parse_endpoint_descriptor_by_index failed"); + return nullopt; + } + if (notify_ep->bmAttributes != USB_BM_ATTRIBUTES_XFER_INT) + notify_ep = nullptr; + } else if (USB_CLASS_CDC_DATA && intf_desc->bNumEndpoints == 2) { + interface_number = intf_desc->bInterfaceNumber; + ep_offset = conf_offset; + out_ep = usb_parse_endpoint_descriptor_by_index(intf_desc, 0, config_desc->wTotalLength, &ep_offset); + if (!out_ep) { + ESP_LOGE(TAG, "out_ep: usb_parse_endpoint_descriptor_by_index failed"); + return nullopt; + } + if (out_ep->bmAttributes != USB_BM_ATTRIBUTES_XFER_BULK) + out_ep = nullptr; + ep_offset = conf_offset; + in_ep = usb_parse_endpoint_descriptor_by_index(intf_desc, 1, config_desc->wTotalLength, &ep_offset); + if (!in_ep) { + ESP_LOGE(TAG, "in_ep: usb_parse_endpoint_descriptor_by_index failed"); + return nullopt; + } + if (in_ep->bmAttributes != USB_BM_ATTRIBUTES_XFER_BULK) + in_ep = nullptr; + } + if (in_ep != nullptr && out_ep != nullptr && notify_ep != nullptr) + break; + } + if (in_ep->bEndpointAddress & usb_host::USB_DIR_IN) + return CdcEps{notify_ep, in_ep, out_ep, interface_number}; + return CdcEps{notify_ep, out_ep, in_ep, interface_number}; +} + +std::vector USBUartTypeCdcAcm::parse_descriptors_(usb_device_handle_t dev_hdl) { + const usb_config_desc_t *config_desc; + const usb_device_desc_t *device_desc; + int desc_offset = 0; + std::vector cdc_devs{}; + + // Get required descriptors + if (usb_host_get_device_descriptor(dev_hdl, &device_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_device_descriptor failed"); + return {}; + } + if (usb_host_get_active_config_descriptor(dev_hdl, &config_desc) != ESP_OK) { + ESP_LOGE(TAG, "get_active_config_descriptor failed"); + return {}; + } + if (device_desc->bDeviceClass == USB_CLASS_COMM) { + // single CDC-ACM device + if (auto eps = get_cdc(config_desc, 0)) { + ESP_LOGV(TAG, "Found CDC-ACM device"); + cdc_devs.push_back(*eps); + } + return cdc_devs; + } + if (((device_desc->bDeviceClass == USB_CLASS_MISC) && (device_desc->bDeviceSubClass == USB_SUBCLASS_COMMON) && + (device_desc->bDeviceProtocol == USB_DEVICE_PROTOCOL_IAD)) || + ((device_desc->bDeviceClass == USB_CLASS_PER_INTERFACE) && (device_desc->bDeviceSubClass == USB_SUBCLASS_NULL) && + (device_desc->bDeviceProtocol == USB_PROTOCOL_NULL))) { + // This is a composite device, that uses Interface Association Descriptor + const auto *this_desc = reinterpret_cast(config_desc); + for (;;) { + this_desc = usb_parse_next_descriptor_of_type(this_desc, config_desc->wTotalLength, + USB_B_DESCRIPTOR_TYPE_INTERFACE_ASSOCIATION, &desc_offset); + if (!this_desc) + break; + const auto *iad_desc = reinterpret_cast(this_desc); + + if (iad_desc->bFunctionClass == USB_CLASS_COMM && iad_desc->bFunctionSubClass == USB_CDC_SUBCLASS_ACM) { + ESP_LOGV(TAG, "Found CDC-ACM device in composite device"); + if (auto eps = get_cdc(config_desc, iad_desc->bFirstInterface)) + cdc_devs.push_back(*eps); + } + } + } + return cdc_devs; +} + +void RingBuffer::push(uint8_t item) { + this->buffer_[this->insert_pos_] = item; + this->insert_pos_ = (this->insert_pos_ + 1) % this->buffer_size_; +} +void RingBuffer::push(const uint8_t *data, size_t len) { + for (size_t i = 0; i != len; i++) { + this->buffer_[this->insert_pos_] = *data++; + this->insert_pos_ = (this->insert_pos_ + 1) % this->buffer_size_; + } +} + +uint8_t RingBuffer::pop() { + uint8_t item = this->buffer_[this->read_pos_]; + this->read_pos_ = (this->read_pos_ + 1) % this->buffer_size_; + return item; +} +size_t RingBuffer::pop(uint8_t *data, size_t len) { + len = std::min(len, this->get_available()); + for (size_t i = 0; i != len; i++) { + *data++ = this->buffer_[this->read_pos_]; + this->read_pos_ = (this->read_pos_ + 1) % this->buffer_size_; + } + return len; +} +void USBUartChannel::write_array(const uint8_t *data, size_t len) { + if (!this->initialised_) { + ESP_LOGV(TAG, "Channel not initialised - write ignored"); + return; + } + while (this->output_buffer_.get_free_space() != 0 && len-- != 0) { + this->output_buffer_.push(*data++); + } + len++; + if (len > 0) { + ESP_LOGE(TAG, "Buffer full - failed to write %d bytes", len); + } + this->parent_->start_output(this); +} + +bool USBUartChannel::peek_byte(uint8_t *data) { + if (this->input_buffer_.is_empty()) { + return false; + } + *data = this->input_buffer_.peek(); + return true; +} +bool USBUartChannel::read_array(uint8_t *data, size_t len) { + if (!this->initialised_) { + ESP_LOGV(TAG, "Channel not initialised - read ignored"); + return false; + } + auto available = this->available(); + bool status = true; + if (len > available) { + ESP_LOGV(TAG, "underflow: requested %zu but returned %d, bytes", len, available); + len = available; + status = false; + } + for (size_t i = 0; i != len; i++) { + *data++ = this->input_buffer_.pop(); + } + this->parent_->start_input(this); + return status; +} +void USBUartComponent::setup() { USBClient::setup(); } +void USBUartComponent::loop() { USBClient::loop(); } +void USBUartComponent::dump_config() { + USBClient::dump_config(); + for (auto &channel : this->channels_) { + ESP_LOGCONFIG(TAG, " UART Channel %d", channel->index_); + ESP_LOGCONFIG(TAG, " Baud Rate: %" PRIu32 " baud", channel->baud_rate_); + ESP_LOGCONFIG(TAG, " Data Bits: %u", channel->data_bits_); + ESP_LOGCONFIG(TAG, " Parity: %s", PARITY_NAMES[channel->parity_]); + ESP_LOGCONFIG(TAG, " Stop bits: %s", STOP_BITS_NAMES[channel->stop_bits_]); + ESP_LOGCONFIG(TAG, " Debug: %s", YESNO(channel->debug_)); + ESP_LOGCONFIG(TAG, " Dummy receiver: %s", YESNO(channel->dummy_receiver_)); + } +} +void USBUartComponent::start_input(USBUartChannel *channel) { + if (!channel->initialised_ || channel->input_started_ || + channel->input_buffer_.get_free_space() < channel->cdc_dev_.in_ep->wMaxPacketSize) + return; + auto ep = channel->cdc_dev_.in_ep; + auto callback = [this, channel](const usb_host::TransferStatus &status) { + ESP_LOGV(TAG, "Transfer result: length: %u; status %X", status.data_len, status.error_code); + if (!status.success) { + ESP_LOGE(TAG, "Control transfer failed, status=%s", esp_err_to_name(status.error_code)); + return; + } +#ifdef USE_UART_DEBUGGER + if (channel->debug_) { + uart::UARTDebug::log_hex(uart::UART_DIRECTION_RX, + std::vector(status.data, status.data + status.data_len), ','); // NOLINT() + } +#endif + channel->input_started_ = false; + if (!channel->dummy_receiver_) { + for (size_t i = 0; i != status.data_len; i++) { + channel->input_buffer_.push(status.data[i]); + } + } + if (channel->input_buffer_.get_free_space() >= channel->cdc_dev_.in_ep->wMaxPacketSize) { + this->defer([this, channel] { this->start_input(channel); }); + } + }; + channel->input_started_ = true; + this->transfer_in(ep->bEndpointAddress, callback, ep->wMaxPacketSize); +} + +void USBUartComponent::start_output(USBUartChannel *channel) { + if (channel->output_started_) + return; + if (channel->output_buffer_.is_empty()) { + return; + } + auto ep = channel->cdc_dev_.out_ep; + auto callback = [this, channel](const usb_host::TransferStatus &status) { + ESP_LOGV(TAG, "Output Transfer result: length: %u; status %X", status.data_len, status.error_code); + channel->output_started_ = false; + this->defer([this, channel] { this->start_output(channel); }); + }; + channel->output_started_ = true; + uint8_t data[ep->wMaxPacketSize]; + auto len = channel->output_buffer_.pop(data, ep->wMaxPacketSize); + this->transfer_out(ep->bEndpointAddress, callback, data, len); +#ifdef USE_UART_DEBUGGER + if (channel->debug_) { + uart::UARTDebug::log_hex(uart::UART_DIRECTION_TX, std::vector(data, data + len), ','); // NOLINT() + } +#endif + ESP_LOGV(TAG, "Output %d bytes started", len); +} + +/** + * Hacky fix for some devices that report incorrect MPS values + * @param ep The endpoint descriptor + */ +static void fix_mps(const usb_ep_desc_t *ep) { + if (ep != nullptr) { + auto *ep_mutable = const_cast(ep); + if (ep->wMaxPacketSize > 64) { + ESP_LOGW(TAG, "Corrected MPS of EP %u from %u to 64", ep->bEndpointAddress, ep->wMaxPacketSize); + ep_mutable->wMaxPacketSize = 64; + } + } +} +void USBUartTypeCdcAcm::on_connected() { + auto cdc_devs = this->parse_descriptors_(this->device_handle_); + if (cdc_devs.empty()) { + this->status_set_error("No CDC-ACM device found"); + this->disconnect(); + return; + } + ESP_LOGD(TAG, "Found %zu CDC-ACM devices", cdc_devs.size()); + auto i = 0; + for (auto channel : this->channels_) { + if (i == cdc_devs.size()) { + ESP_LOGE(TAG, "No configuration found for channel %d", channel->index_); + this->status_set_warning("No configuration found for channel"); + break; + } + channel->cdc_dev_ = cdc_devs[i++]; + fix_mps(channel->cdc_dev_.in_ep); + fix_mps(channel->cdc_dev_.out_ep); + channel->initialised_ = true; + auto err = usb_host_interface_claim(this->handle_, this->device_handle_, channel->cdc_dev_.interface_number, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "usb_host_interface_claim failed: %s, channel=%d, intf=%d", esp_err_to_name(err), channel->index_, + channel->cdc_dev_.interface_number); + this->status_set_error("usb_host_interface_claim failed"); + this->disconnect(); + return; + } + } + this->enable_channels(); +} + +void USBUartTypeCdcAcm::on_disconnected() { + for (auto channel : this->channels_) { + if (channel->cdc_dev_.in_ep != nullptr) { + usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); + usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.in_ep->bEndpointAddress); + } + if (channel->cdc_dev_.out_ep != nullptr) { + usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); + usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.out_ep->bEndpointAddress); + } + if (channel->cdc_dev_.notify_ep != nullptr) { + usb_host_endpoint_halt(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); + usb_host_endpoint_flush(this->device_handle_, channel->cdc_dev_.notify_ep->bEndpointAddress); + } + usb_host_interface_release(this->handle_, this->device_handle_, channel->cdc_dev_.interface_number); + channel->initialised_ = false; + channel->input_started_ = false; + channel->output_started_ = false; + channel->input_buffer_.clear(); + channel->output_buffer_.clear(); + } + USBClient::on_disconnected(); +} + +void USBUartTypeCdcAcm::enable_channels() { + for (auto channel : this->channels_) { + if (!channel->initialised_) + continue; + channel->input_started_ = false; + channel->output_started_ = false; + this->start_input(channel); + } +} + +} // namespace usb_uart +} // namespace esphome +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h new file mode 100644 index 0000000000..fd0fb2c59a --- /dev/null +++ b/esphome/components/usb_uart/usb_uart.h @@ -0,0 +1,151 @@ +#pragma once + +#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/usb_host/usb_host.h" + +namespace esphome { +namespace usb_uart { +class USBUartTypeCdcAcm; +class USBUartComponent; + +static const char *const TAG = "usb_uart"; + +static constexpr uint8_t USB_CDC_SUBCLASS_ACM = 0x02; +static constexpr uint8_t USB_SUBCLASS_COMMON = 0x02; +static constexpr uint8_t USB_SUBCLASS_NULL = 0x00; +static constexpr uint8_t USB_PROTOCOL_NULL = 0x00; +static constexpr uint8_t USB_DEVICE_PROTOCOL_IAD = 0x01; +static constexpr uint8_t USB_VENDOR_IFC = usb_host::USB_TYPE_VENDOR | usb_host::USB_RECIP_INTERFACE; +static constexpr uint8_t USB_VENDOR_DEV = usb_host::USB_TYPE_VENDOR | usb_host::USB_RECIP_DEVICE; + +struct CdcEps { + const usb_ep_desc_t *notify_ep; + const usb_ep_desc_t *in_ep; + const usb_ep_desc_t *out_ep; + uint8_t interface_number; +}; + +enum UARTParityOptions { + UART_CONFIG_PARITY_NONE = 0, + UART_CONFIG_PARITY_ODD, + UART_CONFIG_PARITY_EVEN, + UART_CONFIG_PARITY_MARK, + UART_CONFIG_PARITY_SPACE, +}; + +enum UARTStopBitsOptions { + UART_CONFIG_STOP_BITS_1 = 0, + UART_CONFIG_STOP_BITS_1_5, + UART_CONFIG_STOP_BITS_2, +}; + +static const char *const PARITY_NAMES[] = {"NONE", "ODD", "EVEN", "MARK", "SPACE"}; +static const char *const STOP_BITS_NAMES[] = {"1", "1.5", "2"}; + +class RingBuffer { + public: + RingBuffer(uint16_t buffer_size) : buffer_size_(buffer_size), buffer_(new uint8_t[buffer_size]) {} + bool is_empty() const { return this->read_pos_ == this->insert_pos_; } + size_t get_available() const { + return (this->insert_pos_ + this->buffer_size_ - this->read_pos_) % this->buffer_size_; + }; + size_t get_free_space() const { return this->buffer_size_ - 1 - this->get_available(); } + uint8_t peek() const { return this->buffer_[this->read_pos_]; } + void push(uint8_t item); + void push(const uint8_t *data, size_t len); + uint8_t pop(); + size_t pop(uint8_t *data, size_t len); + void clear() { this->read_pos_ = this->insert_pos_ = 0; } + + protected: + uint16_t insert_pos_ = 0; + uint16_t read_pos_ = 0; + uint16_t buffer_size_; + uint8_t *buffer_; +}; + +class USBUartChannel : public uart::UARTComponent, public Parented { + friend class USBUartComponent; + friend class USBUartTypeCdcAcm; + friend class USBUartTypeCP210X; + friend class USBUartTypeCH34X; + + public: + USBUartChannel(uint8_t index, uint16_t buffer_size) + : index_(index), input_buffer_(RingBuffer(buffer_size)), output_buffer_(RingBuffer(buffer_size)) {} + void write_array(const uint8_t *data, size_t len) override; + ; + bool peek_byte(uint8_t *data) override; + ; + bool read_array(uint8_t *data, size_t len) override; + int available() override { return static_cast(this->input_buffer_.get_available()); } + void flush() override {} + void check_logger_conflict() override {} + void set_parity(UARTParityOptions parity) { this->parity_ = parity; } + void set_debug(bool debug) { this->debug_ = debug; } + void set_dummy_receiver(bool dummy_receiver) { this->dummy_receiver_ = dummy_receiver; } + + protected: + const uint8_t index_; + RingBuffer input_buffer_; + RingBuffer output_buffer_; + UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; + bool input_started_{true}; + bool output_started_{true}; + CdcEps cdc_dev_{}; + bool debug_{}; + bool dummy_receiver_{}; + bool initialised_{}; +}; + +class USBUartComponent : public usb_host::USBClient { + public: + USBUartComponent(uint16_t vid, uint16_t pid) : usb_host::USBClient(vid, pid) {} + void setup() override; + void loop() override; + void dump_config() override; + std::vector get_channels() { return this->channels_; } + + void add_channel(USBUartChannel *channel) { this->channels_.push_back(channel); } + + void start_input(USBUartChannel *channel); + void start_output(USBUartChannel *channel); + + protected: + std::vector channels_{}; +}; + +class USBUartTypeCdcAcm : public USBUartComponent { + public: + USBUartTypeCdcAcm(uint16_t vid, uint16_t pid) : USBUartComponent(vid, pid) {} + + protected: + virtual std::vector parse_descriptors_(usb_device_handle_t dev_hdl); + void on_connected() override; + virtual void enable_channels(); + void on_disconnected() override; +}; + +class USBUartTypeCP210X : public USBUartTypeCdcAcm { + public: + USBUartTypeCP210X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + + protected: + std::vector parse_descriptors_(usb_device_handle_t dev_hdl) override; + void enable_channels() override; +}; +class USBUartTypeCH34X : public USBUartTypeCdcAcm { + public: + USBUartTypeCH34X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {} + + protected: + void enable_channels() override; +}; + +} // namespace usb_uart +} // namespace esphome + +#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/config.py b/esphome/config.py index 4b26b33c78..c6351cdabd 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -7,7 +7,7 @@ import functools import heapq import logging import re -from typing import Any, Union +from typing import Any import voluptuous as vol @@ -63,7 +63,7 @@ def iter_component_configs(config): yield p_name, platform, p_config -ConfigPath = list[Union[str, int]] +ConfigPath = list[str | int] path_context = contextvars.ContextVar("Config path") diff --git a/esphome/const.py b/esphome/const.py index 8c2a1066bd..eab979e88d 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -1130,11 +1130,13 @@ UNIT_WATT_HOURS = "Wh" # device classes DEVICE_CLASS_APPARENT_POWER = "apparent_power" DEVICE_CLASS_AQI = "aqi" +DEVICE_CLASS_AREA = "area" DEVICE_CLASS_ATMOSPHERIC_PRESSURE = "atmospheric_pressure" DEVICE_CLASS_AWNING = "awning" DEVICE_CLASS_BATTERY = "battery" DEVICE_CLASS_BATTERY_CHARGING = "battery_charging" DEVICE_CLASS_BLIND = "blind" +DEVICE_CLASS_BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" DEVICE_CLASS_BUTTON = "button" DEVICE_CLASS_CARBON_DIOXIDE = "carbon_dioxide" DEVICE_CLASS_CARBON_MONOXIDE = "carbon_monoxide" @@ -1153,6 +1155,7 @@ DEVICE_CLASS_DOORBELL = "doorbell" DEVICE_CLASS_DURATION = "duration" DEVICE_CLASS_EMPTY = "" DEVICE_CLASS_ENERGY = "energy" +DEVICE_CLASS_ENERGY_DISTANCE = "energy_distance" DEVICE_CLASS_ENERGY_STORAGE = "energy_storage" DEVICE_CLASS_FIRMWARE = "firmware" DEVICE_CLASS_FREQUENCY = "frequency" @@ -1190,6 +1193,7 @@ DEVICE_CLASS_PRECIPITATION_INTENSITY = "precipitation_intensity" DEVICE_CLASS_PRESENCE = "presence" DEVICE_CLASS_PRESSURE = "pressure" DEVICE_CLASS_PROBLEM = "problem" +DEVICE_CLASS_REACTIVE_ENERGY = "reactive_energy" DEVICE_CLASS_REACTIVE_POWER = "reactive_power" DEVICE_CLASS_RESTART = "restart" DEVICE_CLASS_RUNNING = "running" @@ -1217,6 +1221,7 @@ DEVICE_CLASS_VOLUME_STORAGE = "volume_storage" DEVICE_CLASS_WATER = "water" DEVICE_CLASS_WEIGHT = "weight" DEVICE_CLASS_WINDOW = "window" +DEVICE_CLASS_WIND_DIRECTION = "wind_direction" DEVICE_CLASS_WIND_SPEED = "wind_speed" # state classes diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 3a02c95c82..bf61307021 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -2,7 +2,7 @@ import logging import math import os import re -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING from esphome.const import ( CONF_COMMENT, @@ -326,7 +326,7 @@ class ID: else: self.is_manual = is_manual self.is_declaration = is_declaration - self.type: Optional[MockObjClass] = type + self.type: MockObjClass | None = type def resolve(self, registered_ids): from esphome.config_validation import RESERVED_IDS @@ -476,20 +476,20 @@ class EsphomeCore: # True if command is run from vscode api self.vscode = False # The name of the node - self.name: Optional[str] = None + self.name: str | None = None # The friendly name of the node - self.friendly_name: Optional[str] = None + self.friendly_name: str | None = None # The area / zone of the node - self.area: Optional[str] = None + self.area: str | None = None # Additional data components can store temporary data in # The first key to this dict should always be the integration name self.data = {} # The relative path to the configuration YAML - self.config_path: Optional[str] = None + self.config_path: str | None = None # The relative path to where all build files are stored - self.build_path: Optional[str] = None + self.build_path: str | None = None # The validated configuration, this is None until the config has been validated - self.config: Optional[ConfigType] = None + self.config: ConfigType | None = None # The pending tasks in the task queue (mostly for C++ generation) # This is a priority queue (with heapq) # Each item is a tuple of form: (-priority, unique number, task) @@ -509,7 +509,7 @@ class EsphomeCore: # A set of defines to set for the compile process in esphome/core/defines.h self.defines: set[Define] = set() # A map of all platformio options to apply - self.platformio_options: dict[str, Union[str, list[str]]] = {} + self.platformio_options: dict[str, str | list[str]] = {} # A set of strings of names of loaded integrations, used to find namespace ID conflicts self.loaded_integrations = set() # A set of component IDs to track what Component subclasses are declared @@ -546,7 +546,7 @@ class EsphomeCore: PIN_SCHEMA_REGISTRY.reset() @property - def address(self) -> Optional[str]: + def address(self) -> str | None: if self.config is None: raise ValueError("Config has not been loaded yet") @@ -559,7 +559,7 @@ class EsphomeCore: return None @property - def web_port(self) -> Optional[int]: + def web_port(self) -> int | None: if self.config is None: raise ValueError("Config has not been loaded yet") @@ -572,7 +572,7 @@ class EsphomeCore: return None @property - def comment(self) -> Optional[str]: + def comment(self) -> str | None: if self.config is None: raise ValueError("Config has not been loaded yet") @@ -773,7 +773,7 @@ class EsphomeCore: _LOGGER.debug("Adding define: %s", define) return define - def add_platformio_option(self, key: str, value: Union[str, list[str]]) -> None: + def add_platformio_option(self, key: str, value: str | list[str]) -> None: new_val = value old_val = self.platformio_options.get(key) if isinstance(old_val, list): diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8bc554d5f4..770e091205 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -160,7 +160,7 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC #elif defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) #define USE_LOGGER_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG #endif diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 7866eaa9bd..4212aeca98 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index f3dc3a38b0..c4320107e3 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include diff --git a/esphome/coroutine.py b/esphome/coroutine.py index 30ebb8147e..8d952246f3 100644 --- a/esphome/coroutine.py +++ b/esphome/coroutine.py @@ -42,14 +42,13 @@ Here everything is combined in `yield` expressions. You await other coroutines u the last `yield` expression defines what is returned. """ -import collections -from collections.abc import Awaitable, Generator, Iterator +from collections.abc import Awaitable, Callable, Generator, Iterator import functools import heapq import inspect import logging import types -from typing import Any, Callable +from typing import Any _LOGGER = logging.getLogger(__name__) @@ -126,7 +125,7 @@ def _flatten_generator(gen: Generator[Any, Any, Any]): ret = to_send if e.value is None else e.value return ret - if isinstance(val, collections.abc.Awaitable): + if isinstance(val, Awaitable): # yielded object that is awaitable (like `yield some_new_style_method()`) # yield from __await__() like actual coroutines would. to_send = yield from val.__await__() diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 93ebb4cb95..e7d6195915 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -1,9 +1,9 @@ import abc -from collections.abc import Sequence +from collections.abc import Callable, Sequence import inspect import math import re -from typing import Any, Callable, Optional, Union +from typing import Any from esphome.core import ( CORE, @@ -35,19 +35,19 @@ class Expression(abc.ABC): """ -SafeExpType = Union[ - Expression, - bool, - str, - str, - int, - float, - TimePeriod, - type[bool], - type[int], - type[float], - Sequence[Any], -] +SafeExpType = ( + Expression + | bool + | str + | str + | int + | float + | TimePeriod + | type[bool] + | type[int] + | type[float] + | Sequence[Any] +) class RawExpression(Expression): @@ -90,7 +90,7 @@ class VariableDeclarationExpression(Expression): class ExpressionList(Expression): __slots__ = ("args",) - def __init__(self, *args: Optional[SafeExpType]): + def __init__(self, *args: SafeExpType | None): # Remove every None on end args = list(args) while args and args[-1] is None: @@ -139,7 +139,7 @@ class CallExpression(Expression): class StructInitializer(Expression): __slots__ = ("base", "args") - def __init__(self, base: Expression, *args: tuple[str, Optional[SafeExpType]]): + def __init__(self, base: Expression, *args: tuple[str, SafeExpType | None]): self.base = base # TODO: args is always a Tuple, is this check required? if not isinstance(args, OrderedDict): @@ -197,9 +197,7 @@ class ParameterExpression(Expression): class ParameterListExpression(Expression): __slots__ = ("parameters",) - def __init__( - self, *parameters: Union[ParameterExpression, tuple[SafeExpType, str]] - ): + def __init__(self, *parameters: ParameterExpression | tuple[SafeExpType, str]): self.parameters = [] for parameter in parameters: if not isinstance(parameter, ParameterExpression): @@ -362,7 +360,7 @@ def safe_exp(obj: SafeExpType) -> Expression: return IntLiteral(int(obj.total_seconds)) if isinstance(obj, TimePeriodMinutes): return IntLiteral(int(obj.total_minutes)) - if isinstance(obj, (tuple, list)): + if isinstance(obj, tuple | list): return ArrayInitializer(*[safe_exp(o) for o in obj]) if obj is bool: return bool_ @@ -461,7 +459,7 @@ def static_const_array(id_, rhs) -> "MockObj": return obj -def statement(expression: Union[Expression, Statement]) -> Statement: +def statement(expression: Expression | Statement) -> Statement: """Convert expression into a statement unless is already a statement.""" if isinstance(expression, Statement): return expression @@ -579,7 +577,7 @@ def new_Pvariable(id_: ID, *args: SafeExpType) -> Pvariable: return Pvariable(id_, rhs) -def add(expression: Union[Expression, Statement]): +def add(expression: Expression | Statement): """Add an expression to the codegen section. After this is called, the given given expression will @@ -588,12 +586,12 @@ def add(expression: Union[Expression, Statement]): CORE.add(expression) -def add_global(expression: Union[SafeExpType, Statement], prepend: bool = False): +def add_global(expression: SafeExpType | Statement, prepend: bool = False): """Add an expression to the codegen global storage (above setup()).""" CORE.add_global(expression, prepend) -def add_library(name: str, version: Optional[str], repository: Optional[str] = None): +def add_library(name: str, version: str | None, repository: str | None = None): """Add a library to the codegen library storage. :param name: The name of the library (for example 'AsyncTCP') @@ -619,7 +617,7 @@ def add_define(name: str, value: SafeExpType = None): CORE.add_define(Define(name, safe_exp(value))) -def add_platformio_option(key: str, value: Union[str, list[str]]): +def add_platformio_option(key: str, value: str | list[str]): CORE.add_platformio_option(key, value) @@ -654,7 +652,7 @@ async def process_lambda( parameters: list[tuple[SafeExpType, str]], capture: str = "=", return_type: SafeExpType = None, -) -> Union[LambdaExpression, None]: +) -> LambdaExpression | None: """Process the given lambda value into a LambdaExpression. This is a coroutine because lambdas can depend on other IDs, @@ -711,8 +709,8 @@ def is_template(value): async def templatable( value: Any, args: list[tuple[SafeExpType, str]], - output_type: Optional[SafeExpType], - to_exp: Union[Callable, dict] = None, + output_type: SafeExpType | None, + to_exp: Callable | dict = None, ): """Generate code for a templatable config option. @@ -821,7 +819,7 @@ class MockObj(Expression): assert self.op == "::" return MockObj(f"using namespace {self.base}") - def __getitem__(self, item: Union[str, Expression]) -> "MockObj": + def __getitem__(self, item: str | Expression) -> "MockObj": next_op = "." if isinstance(item, str) and item.startswith("P"): item = item[1:] diff --git a/esphome/dashboard/core.py b/esphome/dashboard/core.py index 416442c426..410ef0c29d 100644 --- a/esphome/dashboard/core.py +++ b/esphome/dashboard/core.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine import contextlib from dataclasses import dataclass from functools import partial @@ -9,7 +9,7 @@ import json import logging from pathlib import Path import threading -from typing import Any, Callable +from typing import Any from esphome.storage_json import ignored_devices_storage_path diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 6196e01760..a297885782 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio import base64 -from collections.abc import Iterable +from collections.abc import Callable, Iterable import datetime import functools import gzip @@ -17,7 +17,7 @@ import shutil import subprocess import threading import time -from typing import TYPE_CHECKING, Any, Callable, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from urllib.parse import urlparse import tornado diff --git a/esphome/git.py b/esphome/git.py index 144c160b20..005bcae702 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime import hashlib @@ -5,7 +6,6 @@ import logging from pathlib import Path import re import subprocess -from typing import Callable, Optional import urllib.parse import esphome.config_validation as cv @@ -45,12 +45,12 @@ def clone_or_update( *, url: str, ref: str = None, - refresh: Optional[TimePeriodSeconds], + refresh: TimePeriodSeconds | None, domain: str, username: str = None, password: str = None, - submodules: Optional[list[str]] = None, -) -> tuple[Path, Optional[Callable[[], None]]]: + submodules: list[str] | None = None, +) -> tuple[Path, Callable[[], None] | None]: key = f"{url}@{ref}" if username is not None and password is not None: diff --git a/esphome/helpers.py b/esphome/helpers.py index b649465d69..d95546ac94 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -7,7 +7,6 @@ from pathlib import Path import platform import re import tempfile -from typing import Union from urllib.parse import urlparse _LOGGER = logging.getLogger(__name__) @@ -219,8 +218,8 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]: int, int, int, - Union[str, None], - Union[tuple[str, int], tuple[str, int, int, int]], + str | None, + tuple[str, int] | tuple[str, int, int, int], ] ] = [] for addr in address_list: @@ -282,7 +281,7 @@ def read_file(path): raise EsphomeError(f"Error reading file {path}: {err}") from err -def _write_file(path: Union[Path, str], text: Union[str, bytes]): +def _write_file(path: Path | str, text: str | bytes): """Atomically writes `text` to the given path. Automatically creates all parent directories. @@ -315,7 +314,7 @@ def _write_file(path: Union[Path, str], text: Union[str, bytes]): _LOGGER.error("Write file cleanup failed: %s", err) -def write_file(path: Union[Path, str], text: str): +def write_file(path: Path | str, text: str): try: _write_file(path, text) except OSError as err: @@ -324,7 +323,7 @@ def write_file(path: Union[Path, str], text: str): raise EsphomeError(f"Could not write file at {path}") from err -def write_file_if_changed(path: Union[Path, str], text: str) -> bool: +def write_file_if_changed(path: Path | str, text: str) -> bool: """Write text to the given path, but not if the contents match already. Returns true if the file was changed. diff --git a/esphome/loader.py b/esphome/loader.py index dbaa2ac661..79a1d7f576 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from contextlib import AbstractContextManager from dataclasses import dataclass import importlib @@ -8,7 +9,7 @@ import logging from pathlib import Path import sys from types import ModuleType -from typing import Any, Callable, Optional +from typing import Any from esphome.const import SOURCE_FILE_EXTENSIONS from esphome.core import CORE @@ -57,7 +58,7 @@ class ComponentManifest: return getattr(self.module, "IS_TARGET_PLATFORM", False) @property - def config_schema(self) -> Optional[Any]: + def config_schema(self) -> Any | None: return getattr(self.module, "CONFIG_SCHEMA", None) @property @@ -69,7 +70,7 @@ class ComponentManifest: return getattr(self.module, "MULTI_CONF_NO_DEFAULT", False) @property - def to_code(self) -> Optional[Callable[[Any], None]]: + def to_code(self) -> Callable[[Any], None] | None: return getattr(self.module, "to_code", None) @property @@ -96,7 +97,7 @@ class ComponentManifest: return getattr(self.module, "INSTANCE_TYPE", None) @property - def final_validate_schema(self) -> Optional[Callable[[ConfigType], None]]: + def final_validate_schema(self) -> Callable[[ConfigType], None] | None: """Components can declare a `FINAL_VALIDATE_SCHEMA` cv.Schema that gets called after the main validation. In that function checks across components can be made. @@ -129,7 +130,7 @@ class ComponentManifest: class ComponentMetaFinder(importlib.abc.MetaPathFinder): def __init__( - self, components_path: Path, allowed_components: Optional[list[str]] = None + self, components_path: Path, allowed_components: list[str] | None = None ) -> None: self._allowed_components = allowed_components self._finders = [] @@ -140,7 +141,7 @@ class ComponentMetaFinder(importlib.abc.MetaPathFinder): continue self._finders.append(finder) - def find_spec(self, fullname: str, path: Optional[list[str]], target=None): + def find_spec(self, fullname: str, path: list[str] | None, target=None): if not fullname.startswith("esphome.components."): return None parts = fullname.split(".") @@ -167,7 +168,7 @@ def clear_component_meta_finders(): def install_meta_finder( - components_path: Path, allowed_components: Optional[list[str]] = None + components_path: Path, allowed_components: list[str] | None = None ): sys.meta_path.insert(0, ComponentMetaFinder(components_path, allowed_components)) diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index ed95fa125e..808db03231 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -5,7 +5,6 @@ import os from pathlib import Path import re import subprocess -from typing import Union from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE from esphome.core import CORE, EsphomeError @@ -73,7 +72,7 @@ FILTER_PLATFORMIO_LINES = [ ] -def run_platformio_cli(*args, **kwargs) -> Union[str, int]: +def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = os.path.abspath(CORE.relative_pioenvs_path()) os.environ.setdefault( @@ -93,7 +92,7 @@ def run_platformio_cli(*args, **kwargs) -> Union[str, int]: return run_external_command(platformio.__main__.main, *cmd, **kwargs) -def run_platformio_cli_run(config, verbose, *args, **kwargs) -> Union[str, int]: +def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: command = ["run", "-d", CORE.build_path] if verbose: command += ["-v"] diff --git a/esphome/types.py b/esphome/types.py index 4e69e3cbd7..f68f503993 100644 --- a/esphome/types.py +++ b/esphome/types.py @@ -1,19 +1,18 @@ """This helper module tracks commonly used types in the esphome python codebase.""" -from typing import Union - from esphome.core import ID, EsphomeCore, Lambda -ConfigFragmentType = Union[ - str, - int, - float, - None, - dict[Union[str, int], "ConfigFragmentType"], - list["ConfigFragmentType"], - ID, - Lambda, -] +ConfigFragmentType = ( + str + | int + | float + | None + | dict[str | int, "ConfigFragmentType"] + | list["ConfigFragmentType"] + | ID + | Lambda +) + ConfigType = dict[str, ConfigFragmentType] CoreType = EsphomeCore -ConfigPathType = Union[str, int] +ConfigPathType = str | int diff --git a/esphome/util.py b/esphome/util.py index 32fd90cd25..ba26b8adc1 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -6,7 +6,6 @@ from pathlib import Path import re import subprocess import sys -from typing import Union from esphome import const @@ -162,7 +161,7 @@ class RedirectText: def run_external_command( func, *cmd, capture_stdout: bool = False, filter_lines: str = None -) -> Union[int, str]: +) -> int | str: """ Run a function from an external package that acts like a main method. diff --git a/esphome/writer.py b/esphome/writer.py index 39423db64c..0452098e24 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -3,7 +3,6 @@ import logging import os from pathlib import Path import re -from typing import Union from esphome import loader from esphome.config import iter_component_configs, iter_components @@ -132,7 +131,7 @@ def update_storage_json(): new.save(path) -def format_ini(data: dict[str, Union[str, list[str]]]) -> str: +def format_ini(data: dict[str, str | list[str]]) -> str: content = "" for key, value in sorted(data.items()): if isinstance(value, list): diff --git a/esphome/yaml_util.py b/esphome/yaml_util.py index cbe3fef272..02778a6de9 100644 --- a/esphome/yaml_util.py +++ b/esphome/yaml_util.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Callable import fnmatch import functools import inspect @@ -8,7 +9,7 @@ from ipaddress import _BaseAddress import logging import math import os -from typing import Any, Callable +from typing import Any import uuid import yaml diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index c6a143a42f..fa496b3488 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -1,9 +1,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Callable from zeroconf import ( AddressResolver, diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000000..7783414a91 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,3 @@ +[build] + command = "script/build-api-docs" + publish = "api-docs" diff --git a/platformio.ini b/platformio.ini index 292188c6fa..06a0666d67 100644 --- a/platformio.ini +++ b/platformio.ini @@ -40,7 +40,7 @@ lib_deps = wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - kikuchan98/pngle@1.0.2 ; online_image + kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image ; This is using the repository until a new release is published to PlatformIO @@ -140,9 +140,9 @@ extra_scripts = post:esphome/components/esp32/post_build.py.script ; This are common settings for the ESP32 (all variants) using IDF. [common:esp32-idf] extends = common:idf -platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.06/platform-espressif32.zip +platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13/platform-espressif32.zip platform_packages = - pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.1.6/esp-idf-v5.1.6.zip + pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.3.2/esp-idf-v5.3.2.zip framework = espidf lib_deps = diff --git a/pyproject.toml b/pyproject.toml index 60f1638975..e783799e58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==80.7.1", "wheel>=0.43,<0.46"] +requires = ["setuptools==80.8.0", "wheel>=0.43,<0.46"] build-backend = "setuptools.build_meta" [project] @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Topic :: Home Automation", ] -requires-python = ">=3.9.0" +requires-python = ">=3.10.0" dynamic = ["dependencies", "optional-dependencies", "version"] @@ -62,7 +62,7 @@ addopts = [ ] [tool.pylint.MAIN] -py-version = "3.9" +py-version = "3.10" ignore = [ "api_pb2.py", ] @@ -106,7 +106,7 @@ expected-line-ending-format = "LF" [tool.ruff] required-version = ">=0.5.0" -target-version = "py39" +target-version = "py310" exclude = ['generated'] [tool.ruff.lint] diff --git a/requirements.txt b/requirements.txt index 498834ddb1..af98606369 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ PyYAML==6.0.2 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.4.2 +tornado==6.5.1 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 @@ -13,7 +13,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==4.8.1 click==8.1.7 esphome-dashboard==20250514.0 -aioesphomeapi==31.0.1 +aioesphomeapi==31.1.0 zeroconf==0.147.0 puremagic==1.29 ruamel.yaml==0.18.10 # dashboard_import diff --git a/requirements_test.txt b/requirements_test.txt index 863c320d96..b1f3355fbd 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==3.3.7 flake8==7.2.0 # also change in .pre-commit-config.yaml when updating -ruff==0.11.10 # also change in .pre-commit-config.yaml when updating +ruff==0.11.11 # also change in .pre-commit-config.yaml when updating pyupgrade==3.19.1 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/script/build-api-docs b/script/build-api-docs new file mode 100755 index 0000000000..dbd215c9ee --- /dev/null +++ b/script/build-api-docs @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +DOXYGEN_VERSION="1.12.0" +DOXYGEN_TAG="Release_${DOXYGEN_VERSION//./_}" + +DOXYGEN_PATH="doxygen" + +download_doxygen() { + TEMP_DIR="$(mktemp -d)" + trap 'rm -rf "$TEMP_DIR"' EXIT + pushd "$TEMP_DIR" >/dev/null + echo "downloading doxygen..." + curl -o doxygen.tar.gz -L "https://github.com/doxygen/doxygen/releases/download/${DOXYGEN_TAG}/doxygen-${DOXYGEN_VERSION}.linux.bin.tar.gz" + tar -xzf doxygen.tar.gz + rm doxygen.tar.gz + DOXYGEN_PATH="$TEMP_DIR/doxygen-${DOXYGEN_VERSION}/bin/doxygen" + popd >/dev/null + echo "doxygen downloaded successfully" +} + +# if doxygen command not found or version not as above, download it +if command -v doxygen &>/dev/null; then + DOXYGEN_VERSION_INSTALLED=$(doxygen --version) + if [[ $DOXYGEN_VERSION_INSTALLED != $DOXYGEN_VERSION ]]; then + echo "doxygen version $DOXYGEN_VERSION_INSTALLED found, but not the expected version $DOXYGEN_VERSION" + download_doxygen + else + echo "doxygen version $DOXYGEN_VERSION_INSTALLED found, using it" + fi +else + download_doxygen +fi + +exec $DOXYGEN_PATH diff --git a/script/devcontainer-post-create b/script/devcontainer-post-create index 2d376786ac..f4835ba6aa 100755 --- a/script/devcontainer-post-create +++ b/script/devcontainer-post-create @@ -3,9 +3,6 @@ set -e # set -x -apt update -apt-get install avahi-utils -y - mkdir -p config script/setup diff --git a/script/lint-python b/script/lint-python index c9f1789160..2c25e4aee0 100755 --- a/script/lint-python +++ b/script/lint-python @@ -137,7 +137,7 @@ def main(): print() print("Running pyupgrade...") print() - PYUPGRADE_TARGET = "--py39-plus" + PYUPGRADE_TARGET = "--py310-plus" for files in filesets: cmd = ["pyupgrade", PYUPGRADE_TARGET] + files log = get_err(*cmd) diff --git a/script/run-in-env.py b/script/run-in-env.py index b03f5f19d3..d9bd01a62f 100644 --- a/script/run-in-env.py +++ b/script/run-in-env.py @@ -7,11 +7,7 @@ import sys def find_and_activate_virtualenv(): - if ( - ("VIRTUAL_ENV" in os.environ) - or os.environ.get("DEVCONTAINER") - or os.environ.get("ESPHOME_NO_VENV") - ): + if "VIRTUAL_ENV" in os.environ: return try: diff --git a/script/setup b/script/setup index acc2ec58b4..b17d3235a7 100755 --- a/script/setup +++ b/script/setup @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ ! -n "$DEVCONTAINER" ] && [ ! -n "$VIRTUAL_ENV" ] && [ ! "$ESPHOME_NO_VENV" ]; then +if [ ! -n "$VIRTUAL_ENV" ]; then if [ -x "$(command -v uv)" ]; then uv venv venv else diff --git a/script/setup.bat b/script/setup.bat index ea2591bb71..f89d5aea1a 100644 --- a/script/setup.bat +++ b/script/setup.bat @@ -1,8 +1,6 @@ @echo off -if defined DEVCONTAINER goto :install if defined VIRTUAL_ENV goto :install -if defined ESPHOME_NO_VENV goto :install echo Starting the Virtual Environment python -m venv venv diff --git a/tests/components/cm1106/common.yaml b/tests/components/cm1106/common.yaml new file mode 100644 index 0000000000..a01e78024e --- /dev/null +++ b/tests/components/cm1106/common.yaml @@ -0,0 +1,11 @@ +uart: + - id: uart_cm1106 + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 9600 + +sensor: + - platform: cm1106 + co2: + name: CM1106 CO2 Value + update_interval: 15s diff --git a/tests/components/cm1106/test.esp32-ard.yaml b/tests/components/cm1106/test.esp32-ard.yaml new file mode 100644 index 0000000000..f486544afa --- /dev/null +++ b/tests/components/cm1106/test.esp32-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +<<: !include common.yaml diff --git a/tests/components/cm1106/test.esp32-c3-ard.yaml b/tests/components/cm1106/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/cm1106/test.esp32-c3-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/cm1106/test.esp32-c3-idf.yaml b/tests/components/cm1106/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/cm1106/test.esp32-c3-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/cm1106/test.esp32-idf.yaml b/tests/components/cm1106/test.esp32-idf.yaml new file mode 100644 index 0000000000..f486544afa --- /dev/null +++ b/tests/components/cm1106/test.esp32-idf.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +<<: !include common.yaml diff --git a/tests/components/cm1106/test.esp8266-ard.yaml b/tests/components/cm1106/test.esp8266-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/cm1106/test.esp8266-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/cm1106/test.rp2040-ard.yaml b/tests/components/cm1106/test.rp2040-ard.yaml new file mode 100644 index 0000000000..b516342f3b --- /dev/null +++ b/tests/components/cm1106/test.rp2040-ard.yaml @@ -0,0 +1,5 @@ +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +<<: !include common.yaml diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index db55da9225..c77983461d 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -781,6 +781,13 @@ lvgl: value: !lambda return (int)((float)rand() / RAND_MAX * 100); - tabview: id: tabview_id + tab_style: + border_color: 0x00FF00 + border_width: 6 + items: + text_color: 0x0000FF + content_style: + scrollable: false width: 100% height: 80% position: top diff --git a/tests/components/psram/test.esp32-s3-idf.yaml b/tests/components/psram/test.esp32-s3-idf.yaml index e0e7fb52f6..75d4ee539c 100644 --- a/tests/components/psram/test.esp32-s3-idf.yaml +++ b/tests/components/psram/test.esp32-s3-idf.yaml @@ -1,4 +1,5 @@ esp32: + cpu_frequency: 240MHz framework: type: esp-idf advanced: diff --git a/tests/components/usb_host/test.esp32-s3-idf.yaml b/tests/components/usb_host/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..a2892872e5 --- /dev/null +++ b/tests/components/usb_host/test.esp32-s3-idf.yaml @@ -0,0 +1,5 @@ +usb_host: + devices: + - id: device_1 + vid: 0x1234 + pid: 0x1234 diff --git a/tests/components/usb_uart/common.yaml b/tests/components/usb_uart/common.yaml new file mode 100644 index 0000000000..46ad6291f9 --- /dev/null +++ b/tests/components/usb_uart/common.yaml @@ -0,0 +1,33 @@ +usb_uart: + - id: uart_0 + type: cdc_acm + vid: 0x1234 + pid: 0x5678 + channels: + - id: channel_0_1 + - id: uart_1 + type: cp210x + channels: + - id: channel_1_1 + baud_rate: 115200 + stop_bits: 2 + data_bits: 7 + parity: even + - id: uart_2 + type: ch34x + channels: + - id: channel_2_1 + baud_rate: 115200 + - id: channel_2_2 + baud_rate: 9600 + - id: uart_3 + type: ch340 + channels: + - id: channel_3_1 + baud_rate: 57600 + - id: uart_4 + type: esp_jtag + channels: + - id: channel_4_1 + debug: true + dummy_receiver: true diff --git a/tests/components/usb_uart/test.esp32-s3-idf.yaml b/tests/components/usb_uart/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..380ca87628 --- /dev/null +++ b/tests/components/usb_uart/test.esp32-s3-idf.yaml @@ -0,0 +1 @@ +!include common.yaml