diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js index fc63198019..fb9dadc6a0 100644 --- a/.github/scripts/auto-label-pr/detectors.js +++ b/.github/scripts/auto-label-pr/detectors.js @@ -235,19 +235,20 @@ async function detectDeprecatedComponents(github, context, changedFiles) { } } - // Get PR head to fetch files from the PR branch - const prNumber = context.payload.pull_request.number; + // Get base branch ref to check if deprecation already exists for the component + // This prevents flagging a PR that simply adds deprecation + const baseRef = context.payload.pull_request.base.ref; // Check each component's __init__.py for DEPRECATED_COMPONENT constant for (const component of components) { const initFile = `esphome/components/${component}/__init__.py`; try { - // Fetch file content from PR head using GitHub API + // Fetch file content from base branch using GitHub API const { data: fileData } = await github.rest.repos.getContent({ owner, repo, path: initFile, - ref: `refs/pull/${prNumber}/head` + ref: baseRef }); // Decode base64 content diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71703652e8..06e8189f54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -723,7 +723,7 @@ jobs: cache-key: ${{ needs.common.outputs.cache-key }} - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: - SKIP: pylint,clang-tidy-hash + SKIP: pylint,clang-tidy-hash,ci-custom - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4aa63f6a16..ba6db99b84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,12 +102,12 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to docker hub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -182,13 +182,13 @@ jobs: - name: Log in to docker hub if: matrix.registry == 'dockerhub' - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to the GitHub container registry if: matrix.registry == 'ghcr' - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f4729f211c..f8c21aad36 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.15.8 + rev: v0.15.9 hooks: # Run the linter. - id: ruff @@ -65,3 +65,7 @@ repos: files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$ pass_filenames: false additional_dependencies: [] + - id: ci-custom + name: ci-custom + entry: python3 script/run-in-env.py script/ci-custom.py + language: system diff --git a/CODEOWNERS b/CODEOWNERS index fffe5ce91c..c466204b66 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -142,7 +142,7 @@ esphome/components/dlms_meter/* @SimonFischer04 esphome/components/dps310/* @kbx81 esphome/components/ds1307/* @badbadc0ffee esphome/components/ds2484/* @mrk-its -esphome/components/dsmr/* @glmnet @PolarGoose @zuidwijk +esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M esphome/components/ektf2232/touchscreen/* @jesserockz diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py index 0780814ea6..34e6570628 100644 --- a/esphome/components/at581x/__init__.py +++ b/esphome/components/at581x/__init__.py @@ -177,7 +177,7 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): template_ = int(template_ / 1000000) cg.add(var.set_frequency(template_)) - if sens_dist := config.get(CONF_SENSING_DISTANCE): + if (sens_dist := config.get(CONF_SENSING_DISTANCE)) is not None: template_ = await cg.templatable(sens_dist, args, int) cg.add(var.set_sensing_distance(template_)) @@ -209,7 +209,7 @@ async def at581x_settings_to_code(config, action_id, template_arg, args): template_ = int(template_) cg.add(var.set_trigger_keep(template_)) - if stage_gain := config.get(CONF_STAGE_GAIN): + if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None: template_ = await cg.templatable(stage_gain, args, int) cg.add(var.set_stage_gain(template_)) diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index acc3b5d351..8f2102de6a 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -210,6 +210,7 @@ async def to_code(config): data = _get_data() if data.flac_support: cg.add_define("USE_AUDIO_FLAC_SUPPORT") + add_idf_component(name="esphome/micro-flac", ref="0.1.1") if data.mp3_support: cg.add_define("USE_AUDIO_MP3_SUPPORT") if data.opus_support: diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp index bc05bc0006..baa4c41c06 100644 --- a/esphome/components/audio/audio_decoder.cpp +++ b/esphome/components/audio/audio_decoder.cpp @@ -84,13 +84,10 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) { switch (this->audio_file_type_) { #ifdef USE_AUDIO_FLAC_SUPPORT case AudioFileType::FLAC: - this->flac_decoder_ = make_unique(); - // CRC check slows down decoding by 15-20% on an ESP32-S3. FLAC sources in ESPHome are either from an http source - // or built into the firmware, so the data integrity is already verified by the time it gets to the decoder, - // making the CRC check unnecessary. - this->flac_decoder_->set_crc_check_enabled(false); + this->flac_decoder_ = make_unique(); this->free_buffer_required_ = this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header + this->decoder_buffers_internally_ = true; break; #endif #ifdef USE_AUDIO_MP3_SUPPORT @@ -268,59 +265,45 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) { #ifdef USE_AUDIO_FLAC_SUPPORT FileDecoderState AudioDecoder::decode_flac_() { - if (!this->audio_stream_info_.has_value()) { - // Header hasn't been read - auto result = this->flac_decoder_->read_header(this->input_buffer_->data(), this->input_buffer_->available()); + size_t bytes_consumed, samples_decoded; - if (result > esp_audio_libs::flac::FLAC_DECODER_HEADER_OUT_OF_DATA) { - // Serrious error reading FLAC header, there is no recovery - return FileDecoderState::FAILED; + micro_flac::FLACDecoderResult result = this->flac_decoder_->decode( + this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(), + this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded); + + if (result == micro_flac::FLAC_DECODER_SUCCESS) { + if (samples_decoded > 0 && this->audio_stream_info_.has_value()) { + this->output_transfer_buffer_->increase_buffer_length( + this->audio_stream_info_.value().samples_to_bytes(samples_decoded)); } - - size_t bytes_consumed = this->flac_decoder_->get_bytes_index(); this->input_buffer_->consume(bytes_consumed); + } else if (result == micro_flac::FLAC_DECODER_HEADER_READY) { + // Header just parsed, stream info now available + const auto &info = this->flac_decoder_->get_stream_info(); + this->audio_stream_info_ = audio::AudioStreamInfo(info.bits_per_sample(), info.num_channels(), info.sample_rate()); - if (result == esp_audio_libs::flac::FLAC_DECODER_HEADER_OUT_OF_DATA) { - return FileDecoderState::MORE_TO_PROCESS; - } - - // Reallocate the output transfer buffer to the smallest necessary size - this->free_buffer_required_ = flac_decoder_->get_output_buffer_size_bytes(); + // Reallocate the output transfer buffer to the required size + this->free_buffer_required_ = this->flac_decoder_->get_output_buffer_size_samples() * info.bytes_per_sample(); if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) { - // Couldn't reallocate output buffer return FileDecoderState::FAILED; } - - this->audio_stream_info_ = - audio::AudioStreamInfo(this->flac_decoder_->get_sample_depth(), this->flac_decoder_->get_num_channels(), - this->flac_decoder_->get_sample_rate()); - - return FileDecoderState::MORE_TO_PROCESS; - } - - uint32_t output_samples = 0; - auto result = this->flac_decoder_->decode_frame(this->input_buffer_->data(), this->input_buffer_->available(), - this->output_transfer_buffer_->get_buffer_end(), &output_samples); - - if (result == esp_audio_libs::flac::FLAC_DECODER_ERROR_OUT_OF_DATA) { - // Not an issue, just needs more data that we'll get next time. - return FileDecoderState::POTENTIALLY_FAILED; - } - - size_t bytes_consumed = this->flac_decoder_->get_bytes_index(); - this->input_buffer_->consume(bytes_consumed); - - if (result > esp_audio_libs::flac::FLAC_DECODER_ERROR_OUT_OF_DATA) { - // Corrupted frame, don't retry with current buffer content, wait for new sync - return FileDecoderState::POTENTIALLY_FAILED; - } - - // We have successfully decoded some input data and have new output data - this->output_transfer_buffer_->increase_buffer_length( - this->audio_stream_info_.value().samples_to_bytes(output_samples)); - - if (result == esp_audio_libs::flac::FLAC_DECODER_NO_MORE_FRAMES) { + this->input_buffer_->consume(bytes_consumed); + } else if (result == micro_flac::FLAC_DECODER_END_OF_STREAM) { + this->input_buffer_->consume(bytes_consumed); return FileDecoderState::END_OF_FILE; + } else if (result == micro_flac::FLAC_DECODER_NEED_MORE_DATA) { + this->input_buffer_->consume(bytes_consumed); + return FileDecoderState::MORE_TO_PROCESS; + } else if (result == micro_flac::FLAC_DECODER_ERROR_OUTPUT_TOO_SMALL) { + // Reallocate to decode the frame on the next call + const auto &info = this->flac_decoder_->get_stream_info(); + this->free_buffer_required_ = this->flac_decoder_->get_output_buffer_size_samples() * info.bytes_per_sample(); + if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) { + return FileDecoderState::FAILED; + } + } else { + ESP_LOGE(TAG, "FLAC decoder failed: %d", static_cast(result)); + return FileDecoderState::POTENTIALLY_FAILED; } return FileDecoderState::MORE_TO_PROCESS; diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h index 726baa289e..6e3a228a68 100644 --- a/esphome/components/audio/audio_decoder.h +++ b/esphome/components/audio/audio_decoder.h @@ -16,14 +16,16 @@ #include "esp_err.h" // esp-audio-libs -#ifdef USE_AUDIO_FLAC_SUPPORT -#include -#endif #ifdef USE_AUDIO_MP3_SUPPORT #include #endif #include +// micro-flac +#ifdef USE_AUDIO_FLAC_SUPPORT +#include +#endif + // micro-opus #ifdef USE_AUDIO_OPUS_SUPPORT #include @@ -119,7 +121,7 @@ class AudioDecoder { std::unique_ptr wav_decoder_; #ifdef USE_AUDIO_FLAC_SUPPORT FileDecoderState decode_flac_(); - std::unique_ptr flac_decoder_; + std::unique_ptr flac_decoder_; #endif #ifdef USE_AUDIO_MP3_SUPPORT FileDecoderState decode_mp3_(); diff --git a/esphome/components/bl0940/sensor.py b/esphome/components/bl0940/sensor.py index f36250ecdf..992064943b 100644 --- a/esphome/components/bl0940/sensor.py +++ b/esphome/components/bl0940/sensor.py @@ -126,7 +126,7 @@ def set_reference_values(config): config.setdefault(CONF_POWER_REFERENCE, DEFAULT_BL0940_LEGACY_PREF) config.setdefault(CONF_ENERGY_REFERENCE, DEFAULT_BL0940_LEGACY_EREF) else: - vref = config.get(CONF_VOLTAGE_REFERENCE, DEFAULT_BL0940_VREF) + vref = config.get(CONF_REFERENCE_VOLTAGE, DEFAULT_BL0940_VREF) r_one = config.get(CONF_RESISTOR_ONE, DEFAULT_BL0940_R1) r_two = config.get(CONF_RESISTOR_TWO, DEFAULT_BL0940_R2) r_shunt = config.get(CONF_RESISTOR_SHUNT, DEFAULT_BL0940_RL) diff --git a/esphome/components/ble_client/text_sensor/__init__.py b/esphome/components/ble_client/text_sensor/__init__.py index a6b8956f93..0f53cccdad 100644 --- a/esphome/components/ble_client/text_sensor/__init__.py +++ b/esphome/components/ble_client/text_sensor/__init__.py @@ -88,7 +88,7 @@ async def to_code(config): ) cg.add(var.set_char_uuid128(uuid128)) - if descriptor_uuid := config: + if descriptor_uuid := config.get(CONF_DESCRIPTOR_UUID): if len(descriptor_uuid) == len(esp32_ble_tracker.bt_uuid16_format): cg.add(var.set_descr_uuid16(esp32_ble_tracker.as_hex(descriptor_uuid))) elif len(descriptor_uuid) == len(esp32_ble_tracker.bt_uuid32_format): diff --git a/esphome/components/canbus/__init__.py b/esphome/components/canbus/__init__.py index c94c8647a9..7d3bf78f49 100644 --- a/esphome/components/canbus/__init__.py +++ b/esphome/components/canbus/__init__.py @@ -161,7 +161,7 @@ async def canbus_action_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_CANBUS_ID]) - if can_id := config.get(CONF_CAN_ID): + if (can_id := config.get(CONF_CAN_ID)) is not None: can_id = await cg.templatable(can_id, args, cg.uint32) cg.add(var.set_can_id(can_id)) cg.add(var.set_use_extended_id(config[CONF_USE_EXTENDED_ID])) diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 8cf5fa9b0c..13dd7aa007 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -400,7 +400,7 @@ async def setup_climate_core_(var, config): ) ) is not None: cg.add( - mqtt_.set_custom_target_temperature_state_topic( + mqtt_.set_custom_target_temperature_low_state_topic( target_temperature_low_state_topic ) ) diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 0eb37e3029..846d3fd883 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -32,4 +32,6 @@ ICON_CURRENT_DC = "mdi:current-dc" ICON_SOLAR_PANEL = "mdi:solar-panel" ICON_SOLAR_POWER = "mdi:solar-power" +KEY_METADATA = "metadata" + UNIT_AMPERE_HOUR = "Ah" diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 4098fd3fb8..16329bb0fa 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -266,8 +266,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_WAKEUP_PIN): validate_wakeup_pin, cv.Optional(CONF_WAKEUP_PIN_MODE): cv.All( cv.only_on([PLATFORM_ESP32, PLATFORM_BK72XX]), - cv.enum(WAKEUP_PIN_MODES), - upper=True, + cv.enum(WAKEUP_PIN_MODES, upper=True), ), cv.Optional(CONF_ESP32_EXT1_WAKEUP): cv.All( cv.only_on_esp32, diff --git a/esphome/components/display/__init__.py b/esphome/components/display/__init__.py index 6367f88acc..67d76a59d9 100644 --- a/esphome/components/display/__init__.py +++ b/esphome/components/display/__init__.py @@ -1,6 +1,9 @@ +from dataclasses import dataclass + from esphome import automation, core from esphome.automation import maybe_simple_id import esphome.codegen as cg +from esphome.components.const import KEY_METADATA import esphome.config_validation as cv from esphome.const import ( CONF_AUTO_CLEAR_ENABLED, @@ -16,7 +19,9 @@ from esphome.const import ( SCHEDULER_DONT_RUN, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority +from esphome.cpp_generator import MockObj +DOMAIN = "display" IS_PLATFORM_COMPONENT = True display_ns = cg.esphome_ns.namespace("display") @@ -112,8 +117,9 @@ FULL_DISPLAY_SCHEMA.add_extra(_validate_test_card) async def setup_display_core_(var, config): - if CONF_ROTATION in config: - cg.add(var.set_rotation(DISPLAY_ROTATIONS[config[CONF_ROTATION]])) + if rotation := config.get(CONF_ROTATION, 0): + # Default initialised value for rotation is 0 + cg.add(var.set_rotation(DISPLAY_ROTATIONS[rotation])) if (auto_clear := config.get(CONF_AUTO_CLEAR_ENABLED)) is not None: # Default to true if pages or lambda is specified. Ideally this would be done during validation, but @@ -146,6 +152,39 @@ async def setup_display_core_(var, config): cg.add(var.show_test_card()) +# Storage of display metadata in a central location, accessible via the id + + +@dataclass(frozen=True) +class DisplayMetaData: + width: int = 0 + height: int = 0 + has_writer: bool = False + has_hardware_rotation: bool = False + + +def get_all_display_metadata() -> dict[str, DisplayMetaData]: + """Get all display metadata.""" + return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {}) + + +def get_display_metadata(display_id: str) -> DisplayMetaData | None: + """Get display metadata by ID for use by other components.""" + return get_all_display_metadata().get(display_id, DisplayMetaData()) + + +def add_metadata( + id: str | MockObj, + width: int, + height: int, + has_writer: bool, + has_hardware_rotation: bool = False, +): + get_all_display_metadata()[str(id)] = DisplayMetaData( + width, height, has_writer, has_hardware_rotation + ) + + async def register_display(var, config): await cg.register_component(var, config) await setup_display_core_(var, config) diff --git a/esphome/components/display/display.h b/esphome/components/display/display.h index e40f6ec963..6e38300d0e 100644 --- a/esphome/components/display/display.h +++ b/esphome/components/display/display.h @@ -704,7 +704,7 @@ class Display : public PollingComponent { void add_on_page_change_trigger(DisplayOnPageChangeTrigger *t) { this->on_page_change_triggers_.push_back(t); } /// Internal method to set the display rotation with. - void set_rotation(DisplayRotation rotation); + virtual void set_rotation(DisplayRotation rotation); // Internal method to set display auto clearing. void set_auto_clear(bool auto_clear_enabled) { this->auto_clear_enabled_ = auto_clear_enabled; } diff --git a/esphome/components/dsmr/__init__.py b/esphome/components/dsmr/__init__.py index 7d76856f28..b1ff9794a3 100644 --- a/esphome/components/dsmr/__init__.py +++ b/esphome/components/dsmr/__init__.py @@ -4,7 +4,7 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RECEIVE_TIMEOUT, CONF_UART_ID -CODEOWNERS = ["@glmnet", "@zuidwijk", "@PolarGoose"] +CODEOWNERS = ["@glmnet", "@PolarGoose"] MULTI_CONF = True @@ -16,6 +16,7 @@ CONF_DECRYPTION_KEY = "decryption_key" CONF_DSMR_ID = "dsmr_id" CONF_GAS_MBUS_ID = "gas_mbus_id" CONF_WATER_MBUS_ID = "water_mbus_id" +CONF_THERMAL_MBUS_ID = "thermal_mbus_id" CONF_MAX_TELEGRAM_LENGTH = "max_telegram_length" CONF_REQUEST_INTERVAL = "request_interval" CONF_REQUEST_PIN = "request_pin" @@ -35,6 +36,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_CRC_CHECK, default=True): cv.boolean, cv.Optional(CONF_GAS_MBUS_ID, default=1): cv.int_, cv.Optional(CONF_WATER_MBUS_ID, default=2): cv.int_, + cv.Optional(CONF_THERMAL_MBUS_ID, default=3): cv.int_, cv.Optional(CONF_MAX_TELEGRAM_LENGTH, default=1500): cv.int_, cv.Optional(CONF_REQUEST_PIN): pins.gpio_output_pin_schema, cv.Optional( @@ -64,6 +66,7 @@ async def to_code(config): cg.add_build_flag("-DDSMR_GAS_MBUS_ID=" + str(config[CONF_GAS_MBUS_ID])) cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID])) + cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID])) # DSMR Parser cg.add_library("esphome/dsmr_parser", "1.1.0") diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 2657071f45..658f9e2c4a 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -175,9 +175,7 @@ async def to_code(config): *model.get_constructor_args(config), ) - # Rotation is handled by setting the transform - display_config = {k: v for k, v in config.items() if k != CONF_ROTATION} - await display.register_display(var, display_config) + await display.register_display(var, config) await spi.register_spi_device(var, config, write_only=True) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) @@ -201,16 +199,6 @@ async def to_code(config): transform[CONF_SWAP_XY] = False else: transform = {x: model.get_default(x, False) for x in TRANSFORM_OPTIONS} - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - elif rotation == 270: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] transform_str = "|".join( { str(getattr(Transform, x.upper())) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index ae1923a916..a2ca311b30 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -97,6 +97,23 @@ bool EPaperBase::reset() { return true; } +void EPaperBase::update_effective_transform_() { + switch (this->rotation_) { + case DISPLAY_ROTATION_90_DEGREES: + this->effective_transform_ = this->transform_ ^ (SWAP_XY | MIRROR_X); + break; + case DISPLAY_ROTATION_180_DEGREES: + this->effective_transform_ = this->transform_ ^ (MIRROR_Y | MIRROR_X); + break; + case DISPLAY_ROTATION_270_DEGREES: + this->effective_transform_ = this->transform_ ^ (SWAP_XY | MIRROR_Y); + break; + default: + this->effective_transform_ = this->transform_; + break; + } +} + void EPaperBase::update() { if (this->state_ != EPaperState::IDLE) { ESP_LOGE(TAG, "Display already in state %s", epaper_state_to_string_()); @@ -280,11 +297,11 @@ bool EPaperBase::initialise(bool partial) { bool EPaperBase::rotate_coordinates_(int &x, int &y) { if (!this->get_clipping().inside(x, y)) return false; - if (this->transform_ & SWAP_XY) + if (this->effective_transform_ & SWAP_XY) std::swap(x, y); - if (this->transform_ & MIRROR_X) + if (this->effective_transform_ & MIRROR_X) x = this->width_ - x - 1; - if (this->transform_ & MIRROR_Y) + if (this->effective_transform_ & MIRROR_Y) y = this->height_ - y - 1; if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) return false; diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index a743985518..47b4f9f72d 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -1,6 +1,6 @@ #pragma once -#include "esphome/components/display/display_buffer.h" +#include "esphome/components/display/display.h" #include "esphome/components/spi/spi.h" #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" @@ -51,7 +51,14 @@ class EPaperBase : public Display, void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } - void set_transform(uint8_t transform) { this->transform_ = transform; } + void set_transform(uint8_t transform) { + this->transform_ = transform; + this->update_effective_transform_(); + } + void set_rotation(DisplayRotation rotation) override { + Display::set_rotation(rotation); + this->update_effective_transform_(); + } void set_full_update_every(uint8_t full_update_every) { this->full_update_every_ = full_update_every; } void dump_config() override; @@ -106,8 +113,8 @@ class EPaperBase : public Display, protected: int get_height_internal() override { return this->height_; }; int get_width_internal() override { return this->width_; }; - int get_width() override { return this->transform_ & SWAP_XY ? this->height_ : this->width_; } - int get_height() override { return this->transform_ & SWAP_XY ? this->width_ : this->height_; } + int get_width() override { return this->effective_transform_ & SWAP_XY ? this->height_ : this->width_; } + int get_height() override { return this->effective_transform_ & SWAP_XY ? this->width_ : this->height_; } void draw_pixel_at(int x, int y, Color color) override; void process_state_(); @@ -119,6 +126,7 @@ class EPaperBase : public Display, void send_init_sequence_(const uint8_t *sequence, size_t length); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); + void update_effective_transform_(); bool rotate_coordinates_(int &x, int &y); /** @@ -171,6 +179,7 @@ class EPaperBase : public Display, uint32_t delay_until_{}; // timestamp until which to delay processing uint16_t next_delay_{}; // milliseconds to delay before next state uint8_t transform_{}; + uint8_t effective_transform_{}; uint8_t update_count_{}; // these values represent the bounds of the updated buffer. Note that x_high and y_high // point to the pixel past the last one updated, i.e. may range up to width/height. diff --git a/esphome/components/espnow/__init__.py b/esphome/components/espnow/__init__.py index d1a85ae8fd..00703bc228 100644 --- a/esphome/components/espnow/__init__.py +++ b/esphome/components/espnow/__init__.py @@ -132,6 +132,7 @@ async def to_code(config): if wifi_channel := config.get(CONF_CHANNEL): cg.add(var.set_wifi_channel(wifi_channel)) + cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) cg.add(var.set_auto_add_peer(config[CONF_AUTO_ADD_PEER])) for peer in config.get(CONF_PEERS, []): diff --git a/esphome/components/ezo_pmp/__init__.py b/esphome/components/ezo_pmp/__init__.py index 1538e303f1..3de796dd25 100644 --- a/esphome/components/ezo_pmp/__init__.py +++ b/esphome/components/ezo_pmp/__init__.py @@ -286,7 +286,7 @@ async def ezo_pmp_change_i2c_address_to_code(config, action_id, template_arg, ar paren = await cg.get_variable(config[CONF_ID]) var = cg.new_Pvariable(action_id, template_arg, paren) - template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.double) + template_ = await cg.templatable(config[CONF_ADDRESS], args, cg.int_) cg.add(var.set_address(template_)) return var diff --git a/esphome/components/host/core.cpp b/esphome/components/host/core.cpp index a662e842ee..0ade4274fe 100644 --- a/esphome/components/host/core.cpp +++ b/esphome/components/host/core.cpp @@ -5,11 +5,17 @@ #include "esphome/core/helpers.h" #include "preferences.h" +#include #include #include #include #include +namespace { +volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +void signal_handler(int signal) { s_signal_received = signal; } +} // namespace + namespace esphome { void HOT yield() { ::sched_yield(); } @@ -72,11 +78,17 @@ uint32_t arch_get_cpu_freq_hz() { return 1000000000U; } void setup(); void loop(); int main() { + // Install signal handlers for graceful shutdown (flushes preferences to disk) + std::signal(SIGINT, signal_handler); + std::signal(SIGTERM, signal_handler); + esphome::host::setup_preferences(); setup(); - while (true) { + while (s_signal_received == 0) { loop(); } + esphome::App.run_safe_shutdown_hooks(); + return 0; } #endif // USE_HOST diff --git a/esphome/components/htu21d/sensor.py b/esphome/components/htu21d/sensor.py index 92c088a22f..ed4fb5968a 100644 --- a/esphome/components/htu21d/sensor.py +++ b/esphome/components/htu21d/sensor.py @@ -118,6 +118,6 @@ async def set_heater_level_to_code(config, action_id, template_arg, args): async def set_heater_to_code(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) - status_ = await cg.templatable(config[CONF_LEVEL], args, bool) + status_ = await cg.templatable(config[CONF_STATUS], args, bool) cg.add(var.set_status(status_)) return var diff --git a/esphome/components/ili9xxx/__init__.py b/esphome/components/ili9xxx/__init__.py index e69de29bb2..84888bbabc 100644 --- a/esphome/components/ili9xxx/__init__.py +++ b/esphome/components/ili9xxx/__init__.py @@ -0,0 +1,4 @@ +DEPRECATED_COMPONENT = """ +The 'ili9xxx' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/ili9xxx/display.py b/esphome/components/ili9xxx/display.py index bfb2300f4f..1f20b21a0e 100644 --- a/esphome/components/ili9xxx/display.py +++ b/esphome/components/ili9xxx/display.py @@ -210,8 +210,8 @@ def final_validate(config): ): LOGGER.info("Consider enabling PSRAM if available for the display buffer") - return spi.final_validate_device_schema( - "ili9xxx", require_miso=False, require_mosi=True + spi.final_validate_device_schema("ili9xxx", require_miso=False, require_mosi=True)( + config ) @@ -219,6 +219,9 @@ FINAL_VALIDATE_SCHEMA = final_validate async def to_code(config): + LOGGER.warning( + "The 'ili9xxx' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) rhs = MODELS[config[CONF_MODEL]].new() var = cg.Pvariable(config[CONF_ID], rhs) diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 6fb0e46d93..4a5fcc385e 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -12,7 +12,7 @@ from PIL import Image, UnidentifiedImageError from esphome import core, external_files import esphome.codegen as cg -from esphome.components.const import CONF_BYTE_ORDER +from esphome.components.const import CONF_BYTE_ORDER, KEY_METADATA import esphome.config_validation as cv from esphome.const import ( CONF_DEFAULTS, @@ -53,7 +53,6 @@ CONF_CHROMA_KEY = "chroma_key" CONF_ALPHA_CHANNEL = "alpha_channel" CONF_INVERT_ALPHA = "invert_alpha" CONF_IMAGES = "images" -KEY_METADATA = "metadata" TRANSPARENCY_TYPES = ( CONF_OPAQUE, diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index 7c936b51b7..a749cd7305 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -198,13 +198,13 @@ LightColorValues LightCall::validate_() { // Ensure there is always a color mode set if (!this->has_color_mode()) { - this->color_mode_ = this->compute_color_mode_(); + this->color_mode_ = this->compute_color_mode_(traits); this->set_flag_(FLAG_HAS_COLOR_MODE); } auto color_mode = this->color_mode_; // Transform calls that use non-native parameters for the current mode. - this->transform_parameters_(); + this->transform_parameters_(traits); // Business logic adjustments before validation // Flag whether an explicit turn off was requested, in which case we'll also stop the effect. @@ -366,9 +366,7 @@ LightColorValues LightCall::validate_() { return v; } -void LightCall::transform_parameters_() { - auto traits = this->parent_->get_traits(); - +void LightCall::transform_parameters_(const LightTraits &traits) { // Allow CWWW modes to be set with a white value and/or color temperature. // This is used in three cases in HA: // - CW/WW lights, which set the "brightness" and "color_temperature" @@ -407,8 +405,8 @@ void LightCall::transform_parameters_() { } } } -ColorMode LightCall::compute_color_mode_() { - auto supported_modes = this->parent_->get_traits().get_supported_color_modes(); +ColorMode LightCall::compute_color_mode_(const LightTraits &traits) { + auto supported_modes = traits.get_supported_color_modes(); int supported_count = supported_modes.size(); // Some lights don't support any color modes (e.g. monochromatic light), leave it at unknown. diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 0eb1785239..88d29bd349 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -10,6 +10,7 @@ struct LogString; namespace light { class LightState; +class LightTraits; /** This class represents a requested change in a light state. * @@ -188,11 +189,11 @@ class LightCall { LightColorValues validate_(); //// Compute the color mode that should be used for this call. - ColorMode compute_color_mode_(); + ColorMode compute_color_mode_(const LightTraits &traits); /// Get potential color modes bitmask for this light call. color_mode_bitmask_t get_suitable_color_modes_mask_(); /// Some color modes also can be set using non-native parameters, transform those calls. - void transform_parameters_(); + void transform_parameters_(const LightTraits &traits); // Bitfield flags - each flag indicates whether a corresponding value has been set. enum FieldFlags : uint16_t { diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index a2c2dbca46..fa286a3941 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -104,9 +104,10 @@ class LightColorValues { this->green_ = 1.0f; this->blue_ = 1.0f; } else { - this->red_ /= max_value; - this->green_ /= max_value; - this->blue_ /= max_value; + float inv = 1.0f / max_value; + this->red_ *= inv; + this->green_ *= inv; + this->blue_ *= inv; } } } diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 4f1473b652..2c57452a55 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -23,6 +23,7 @@ from esphome.core.config import StartupTrigger from . import defines as df, lv_validation as lvalid from .defines import ( + CONF_EXT_CLICK_AREA, CONF_SCROLL_DIR, CONF_SCROLL_SNAP_X, CONF_SCROLL_SNAP_Y, @@ -311,6 +312,7 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex cv.Optional(df.CONF_SCROLLBAR_MODE): df.LvConstant( "LV_SCROLLBAR_MODE_", "OFF", "ON", "ACTIVE", "AUTO" ).one_of, + cv.Optional(CONF_EXT_CLICK_AREA): lvalid.pixels, cv.Optional(CONF_SCROLL_DIR): df.SCROLL_DIRECTIONS.one_of, cv.Optional(CONF_SCROLL_SNAP_X): df.SNAP_DIRECTIONS.one_of, cv.Optional(CONF_SCROLL_SNAP_Y): df.SNAP_DIRECTIONS.one_of, @@ -318,6 +320,7 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex ) OBJ_PROPERTIES = { + CONF_EXT_CLICK_AREA, CONF_SCROLL_SNAP_X, CONF_SCROLL_SNAP_Y, CONF_SCROLL_DIR, @@ -433,7 +436,6 @@ def obj_schema(widget_type: WidgetType): return ( part_schema(widget_type.parts) .extend(ALIGN_TO_SCHEMA) - .extend({cv.Optional(df.CONF_EXT_CLICK_AREA): lvalid.pixels}) .extend(automation_schema(widget_type.w_type)) .extend( { diff --git a/esphome/components/lvgl/trigger.py b/esphome/components/lvgl/trigger.py index c52d213e15..54309cdf89 100644 --- a/esphome/components/lvgl/trigger.py +++ b/esphome/components/lvgl/trigger.py @@ -15,7 +15,6 @@ from .defines import ( CONF_ALIGN, CONF_ALIGN_TO, CONF_ALIGN_TO_LAMBDA_ID, - CONF_EXT_CLICK_AREA, DIRECTIONS, LV_EVENT_MAP, LV_EVENT_TRIGGERS, @@ -114,8 +113,6 @@ async def generate_align_tos(config: dict): x = align_to[CONF_X] y = align_to[CONF_Y] lv.obj_align_to(w.obj, target, align, x, y) - if ext_click_area := w.config.get(CONF_EXT_CLICK_AREA): - lv.obj_set_ext_click_area(w.obj, ext_click_area) action_id = config[CONF_ALIGN_TO_LAMBDA_ID] var = new_Pvariable(action_id, await context.get_lambda()) diff --git a/esphome/components/media_player/__init__.py b/esphome/components/media_player/__init__.py index 767916ad88..842f620dae 100644 --- a/esphome/components/media_player/__init__.py +++ b/esphome/components/media_player/__init__.py @@ -94,6 +94,9 @@ _STATE_CONDITIONS = [ PlayMediaAction = media_player_ns.class_( "PlayMediaAction", automation.Action, cg.Parented.template(MediaPlayer) ) +EnqueueMediaAction = media_player_ns.class_( + "EnqueueMediaAction", automation.Action, cg.Parented.template(MediaPlayer) +) VolumeSetAction = media_player_ns.class_( "VolumeSetAction", automation.Action, cg.Parented.template(MediaPlayer) ) @@ -168,20 +171,17 @@ MEDIA_PLAYER_CONDITION_SCHEMA = automation.maybe_simple_id( ) -@automation.register_action( - "media_player.play_media", - PlayMediaAction, - cv.maybe_simple_value( - { - cv.GenerateID(): cv.use_id(MediaPlayer), - cv.Required(CONF_MEDIA_URL): cv.templatable(cv.url), - cv.Optional(CONF_ANNOUNCEMENT, default=False): cv.templatable(cv.boolean), - }, - key=CONF_MEDIA_URL, - ), - synchronous=True, +_MEDIA_URL_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(MediaPlayer), + cv.Required(CONF_MEDIA_URL): cv.templatable(cv.url), + cv.Optional(CONF_ANNOUNCEMENT, default=False): cv.templatable(cv.boolean), + }, + key=CONF_MEDIA_URL, ) -async def media_player_play_media_action(config, action_id, template_arg, args): + + +async def _media_action_handler(config, action_id, template_arg, args): var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) media_url = await cg.templatable(config[CONF_MEDIA_URL], args, cg.std_string) @@ -191,6 +191,21 @@ async def media_player_play_media_action(config, action_id, template_arg, args): return var +automation.register_action( + "media_player.play_media", + PlayMediaAction, + _MEDIA_URL_ACTION_SCHEMA, + synchronous=True, +)(_media_action_handler) + +automation.register_action( + "media_player.enqueue", + EnqueueMediaAction, + _MEDIA_URL_ACTION_SCHEMA, + synchronous=True, +)(_media_action_handler) + + def _snake_to_camel(name): return "".join(word.capitalize() for word in name.split("_")) diff --git a/esphome/components/media_player/automation.h b/esphome/components/media_player/automation.h index 658381ef90..14ce3c6aed 100644 --- a/esphome/components/media_player/automation.h +++ b/esphome/components/media_player/automation.h @@ -55,17 +55,23 @@ using GroupJoinAction = MediaPlayerCommandAction using ClearPlaylistAction = MediaPlayerCommandAction; -template class PlayMediaAction : public Action, public Parented { +template +class MediaPlayerMediaAction : public Action, public Parented { TEMPLATABLE_VALUE(std::string, media_url) TEMPLATABLE_VALUE(bool, announcement) void play(const Ts &...x) override { - this->parent_->make_call() - .set_media_url(this->media_url_.value(x...)) - .set_announcement(this->announcement_.value(x...)) - .perform(); + auto call = this->parent_->make_call(); + if constexpr (Command != MediaPlayerCommand::MEDIA_PLAYER_COMMAND_PLAY) + call.set_command(Command); + call.set_media_url(this->media_url_.value(x...)).set_announcement(this->announcement_.value(x...)).perform(); } }; +template +using PlayMediaAction = MediaPlayerMediaAction; +template +using EnqueueMediaAction = MediaPlayerMediaAction; + template class VolumeSetAction : public Action, public Parented { TEMPLATABLE_VALUE(float, volume) void play(const Ts &...x) override { this->parent_->make_call().set_volume(this->volume_.value(x...)).perform(); } diff --git a/esphome/components/mipi/__init__.py b/esphome/components/mipi/__init__.py index 4dbc81caa2..ccd43c72cf 100644 --- a/esphome/components/mipi/__init__.py +++ b/esphome/components/mipi/__init__.py @@ -128,6 +128,8 @@ MADCTL_MH = 0x04 # Bit 2 LCD refresh right to left MADCTL_XFLIP = 0x02 # Mirror the display horizontally MADCTL_YFLIP = 0x01 # Mirror the display vertically +MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips + # Special constant for delays in command sequences DELAY_FLAG = 0xFFF # Special flag to indicate a delay @@ -329,7 +331,13 @@ class DriverChip: return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms - def get_dimensions(self, config) -> tuple[int, int, int, int]: + def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]: + """ + Return the dimensions of the current model. + :param config: The current configuration + :param swap: If width/height should be swapped when axes are swapped. + :return: + """ if CONF_DIMENSIONS in config: # Explicit dimensions, just use as is dimensions = config[CONF_DIMENSIONS] @@ -361,13 +369,12 @@ class DriverChip: ) offset_height = native_height - height - offset_height # Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer - if transform.get(CONF_SWAP_XY) is True: + if swap and transform.get(CONF_SWAP_XY) is True: width, height = height, width offset_height, offset_width = offset_width, offset_height return width, height, offset_width, offset_height - def get_transform(self, config) -> dict[str, bool]: - can_transform = self.rotation_as_transform(config) + def get_base_transform(self, config): transform = config.get( CONF_TRANSFORM, { @@ -376,14 +383,20 @@ class DriverChip: CONF_SWAP_XY: self.get_default(CONF_SWAP_XY), }, ) - if not isinstance(transform, dict): - # Presumably disabled - return { - CONF_MIRROR_X: False, - CONF_MIRROR_Y: False, - CONF_SWAP_XY: False, - CONF_TRANSFORM: False, - } + if isinstance(transform, dict): + return transform + + # Transform is disabled + return { + CONF_MIRROR_X: False, + CONF_MIRROR_Y: False, + CONF_SWAP_XY: False, + CONF_TRANSFORM: False, + } + + def get_transform(self, config) -> dict[str, bool]: + transform = self.get_base_transform(config) + can_transform = self.rotation_as_transform(config) # Can we use the MADCTL register to set the rotation? if can_transform and CONF_TRANSFORM not in config: rotation = config[CONF_ROTATION] @@ -411,11 +424,15 @@ class DriverChip: return {cv.Required(CONF_SWAP_XY): cv.boolean} return {cv.Optional(CONF_SWAP_XY, default=False): validator} - def add_madctl(self, sequence: list, config: dict): - # Add the MADCTL command to the sequence based on the configuration. - use_flip = config.get(CONF_USE_AXIS_FLIPS) - madctl = 0 - transform = self.get_transform(config) + def get_madctl(self, transform: dict, config: dict) -> int: + """ + Convert a transform to MADCTL bits + :param transform: The transform dict + :param use_flip: Whether to use axis flips + :return: MADCTL value + """ + use_flip = config.get(CONF_USE_AXIS_FLIPS, False) + madctl = MADCTL_FLIP_FLAG if use_flip else 0 if transform[CONF_MIRROR_X]: madctl |= MADCTL_XFLIP if use_flip else MADCTL_MX if transform[CONF_MIRROR_Y]: @@ -424,22 +441,28 @@ class DriverChip: madctl |= MADCTL_MV if config[CONF_COLOR_ORDER] == MODE_BGR: madctl |= MADCTL_BGR - sequence.append((MADCTL, madctl)) return madctl + def add_madctl(self, sequence: list, config: dict): + # Add the MADCTL command to the sequence based on the configuration. + # This takes into account rotation if it can be implemented in the transform + transform = self.get_transform(config) + madctl = self.get_madctl(transform, config) + sequence.append((MADCTL, madctl & 0xFF)) + def skip_command(self, command: str): """ Allow suppressing a standard command in the init sequence. """ return self.get_default(f"no_{command.lower()}", False) - def get_sequence(self, config) -> tuple[tuple[int, ...], int]: + def get_sequence(self, config, add_madctl=True) -> tuple[int, ...]: """ Create the init sequence for the display. Use the default sequence from the model, if any, and append any custom sequence provided in the config. Append SLPOUT (if not already in the sequence) and DISPON to the end of the sequence - Pixel format, color order, and orientation will be set. - Returns a tuple of the init sequence and the computed MADCTL value. + MADCTL will be set if add_madctl is True + Returns the init sequence """ sequence = list(self.initsequence or ()) custom_sequence = config.get(CONF_INIT_SEQUENCE, []) @@ -457,7 +480,8 @@ class DriverChip: if self.rotation_as_transform(config): LOGGER.info("Using hardware transform to implement rotation") - madctl = self.add_madctl(sequence, config) + if add_madctl: + self.add_madctl(sequence, config) if config[CONF_INVERT_COLORS]: sequence.append((INVON,)) else: @@ -471,7 +495,7 @@ class DriverChip: # Flatten the sequence into a list of bytes, with the length of each command # or the delay flag inserted where needed - return flatten_sequence(sequence), madctl + return flatten_sequence(sequence) def requires_buffer(config) -> bool: diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 85bfad7f1a..026c214569 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -192,10 +192,9 @@ async def to_code(config): width, height, _offset_width, _offset_height = model.get_dimensions(config) var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode) - sequence, madctl = model.get_sequence(config) + sequence = model.get_sequence(config) cg.add(var.set_model(config[CONF_MODEL])) cg.add(var.set_init_sequence(sequence)) - cg.add(var.set_madctl(madctl)) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) cg.add(var.set_hsync_pulse_width(config[CONF_HSYNC_PULSE_WIDTH])) cg.add(var.set_hsync_back_porch(config[CONF_HSYNC_BACK_PORCH])) diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index e8e9ca2bfb..fc59aeffe8 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -392,9 +392,6 @@ void MIPI_DSI::dump_config() { "\n Model: %s" "\n Width: %u" "\n Height: %u" - "\n Mirror X: %s" - "\n Mirror Y: %s" - "\n Swap X/Y: %s" "\n Rotation: %d degrees" "\n DSI Lanes: %u" "\n Lane Bit Rate: %.0fMbps" @@ -406,14 +403,11 @@ void MIPI_DSI::dump_config() { "\n VSync Front Porch: %u" "\n Buffer Color Depth: %d bit" "\n Display Pixel Mode: %d bit" - "\n Color Order: %s" "\n Invert Colors: %s" "\n Pixel Clock: %.1fMHz", - this->model_, this->width_, this->height_, YESNO(this->madctl_ & (MADCTL_XFLIP | MADCTL_MX)), - YESNO(this->madctl_ & (MADCTL_YFLIP | MADCTL_MY)), YESNO(this->madctl_ & MADCTL_MV), this->rotation_, - this->lanes_, this->lane_bit_rate_, this->hsync_pulse_width_, this->hsync_back_porch_, - this->hsync_front_porch_, this->vsync_pulse_width_, this->vsync_back_porch_, this->vsync_front_porch_, - (3 - this->color_depth_) * 8, this->pixel_mode_, this->madctl_ & MADCTL_BGR ? "BGR" : "RGB", + this->model_, this->width_, this->height_, this->rotation_, this->lanes_, this->lane_bit_rate_, + this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, + this->vsync_back_porch_, this->vsync_front_porch_, (3 - this->color_depth_) * 8, this->pixel_mode_, YESNO(this->invert_colors_), this->pclk_frequency_); LOG_PIN(" Reset Pin ", this->reset_pin_); } diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index 6e27912aa5..c27c9ccc6e 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -60,7 +60,6 @@ class MIPI_DSI : public display::Display { void set_model(const char *model) { this->model_ = model; } void set_lane_bit_rate(float lane_bit_rate) { this->lane_bit_rate_ = lane_bit_rate; } void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } - void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } void smark_failed(const LogString *message, esp_err_t err); @@ -86,7 +85,6 @@ class MIPI_DSI : public display::Display { std::vector enable_pins_{}; size_t width_{}; size_t height_{}; - uint8_t madctl_{}; uint16_t hsync_pulse_width_ = 10; uint16_t hsync_back_porch_ = 10; uint16_t hsync_front_porch_ = 20; diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 0aa8c56719..4952bda95f 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -265,9 +265,8 @@ async def to_code(config): if CONF_SPI_ID in config: await spi.register_spi_device(var, config, write_only=True) - sequence, madctl = model.get_sequence(config) + sequence = model.get_sequence(config) cg.add(var.set_init_sequence(sequence)) - cg.add(var.set_madctl(madctl)) cg.add(var.set_color_mode(COLOR_ORDERS[config[CONF_COLOR_ORDER]])) cg.add(var.set_invert_colors(config[CONF_INVERT_COLORS])) diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 0b0a5344e4..6f5e2f2490 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -118,15 +118,7 @@ void MipiRgbSpi::dump_config() { MipiRgb::dump_config(); LOG_PIN(" CS Pin: ", this->cs_); LOG_PIN(" DC Pin: ", this->dc_pin_); - ESP_LOGCONFIG(TAG, - " SPI Data rate: %uMHz" - "\n Mirror X: %s" - "\n Mirror Y: %s" - "\n Swap X/Y: %s" - "\n Color Order: %s", - (unsigned) (this->data_rate_ / 1000000), YESNO(this->madctl_ & (MADCTL_XFLIP | MADCTL_MX)), - YESNO(this->madctl_ & (MADCTL_YFLIP | MADCTL_MY | MADCTL_ML)), YESNO(this->madctl_ & MADCTL_MV), - this->madctl_ & MADCTL_BGR ? "BGR" : "RGB"); + ESP_LOGCONFIG(TAG, " SPI Data rate: %uMHz", (unsigned) (this->data_rate_ / 1000000)); } #endif // USE_SPI diff --git a/esphome/components/mipi_rgb/mipi_rgb.h b/esphome/components/mipi_rgb/mipi_rgb.h index 76b48bb249..accc251a18 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.h +++ b/esphome/components/mipi_rgb/mipi_rgb.h @@ -38,7 +38,6 @@ class MipiRgb : public display::Display { display::ColorOrder get_color_mode() { return this->color_mode_; } void set_color_mode(display::ColorOrder color_mode) { this->color_mode_ = color_mode; } void set_invert_colors(bool invert_colors) { this->invert_colors_ = invert_colors; } - void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } void add_data_pin(InternalGPIOPin *data_pin, size_t index) { this->data_pins_[index] = data_pin; }; void set_de_pin(InternalGPIOPin *de_pin) { this->de_pin_ = de_pin; } @@ -84,7 +83,6 @@ class MipiRgb : public display::Display { uint16_t vsync_front_porch_ = 10; uint32_t pclk_frequency_ = 16 * 1000 * 1000; bool pclk_inverted_{true}; - uint8_t madctl_{}; const char *model_{"Unknown"}; bool invert_colors_{}; display::ColorOrder color_mode_{display::COLOR_ORDER_BGR}; diff --git a/esphome/components/mipi_spi/display.py b/esphome/components/mipi_spi/display.py index 8dccfa3a92..6aa98e3f66 100644 --- a/esphome/components/mipi_spi/display.py +++ b/esphome/components/mipi_spi/display.py @@ -10,7 +10,7 @@ from esphome.components.const import ( CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING, ) -from esphome.components.display import CONF_SHOW_TEST_CARD, DISPLAY_ROTATIONS +from esphome.components.display import CONF_SHOW_TEST_CARD from esphome.components.mipi import ( CONF_PIXEL_MODE, CONF_USE_AXIS_FLIPS, @@ -47,12 +47,10 @@ from esphome.const import ( CONF_MIRROR_Y, CONF_MODEL, CONF_RESET_PIN, - CONF_ROTATION, CONF_SWAP_XY, CONF_TRANSFORM, CONF_WIDTH, ) -from esphome.core import CORE from esphome.cpp_generator import TemplateArguments from esphome.final_validate import full_config @@ -113,22 +111,21 @@ DISPLAY_PIXEL_MODES = { def denominator(config): """ Calculate the best denominator for a buffer size fraction. - The denominator must be a number between 2 and 16 that divides the display height evenly, + The denominator should be a number between 2 and 16 that divides the display height evenly, and the fraction represented by the denominator must be less than or equal to the given fraction. :config: The configuration dictionary containing the buffer size fraction and display dimensions :return: The denominator to use for the buffer size fraction """ model = MODELS[config[CONF_MODEL]] frac = config.get(CONF_BUFFER_SIZE) - if frac is None or frac > 0.75: + _width, height, _offset_width, _offset_height = model.get_dimensions(config) + if frac is None or frac > 0.75 or height < 32: return 1 - height, _width, _offset_width, _offset_height = model.get_dimensions(config) try: return next(x for x in range(2, 17) if frac >= 1 / x and height % x == 0) except StopIteration: - raise cv.Invalid( - f"Buffer size fraction {frac} is not compatible with display height {height}" - ) from StopIteration + # No exact divisor, just use the closest. + return next(x for x in range(2, 17) if frac >= 1 / x) def model_schema(config): @@ -287,30 +284,19 @@ def _final_validate(config): config[CONF_SHOW_TEST_CARD] = True if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config: - if not requires_buffer(config): - return config # No buffer needed, so no need to set a buffer size # If PSRAM is not enabled, choose a small buffer size by default if not requires_buffer(config): - # not our problem. - return config + return config # No buffer needed, so no need to set a buffer size color_depth = get_color_depth(config) frac = denominator(config) - height, width, _offset_width, _offset_height = model.get_dimensions(config) + width, height, _offset_width, _offset_height = model.get_dimensions(config) buffer_size = color_depth // 8 * width * height // frac - # Target a buffer size of 20kB - fraction = 20000.0 / buffer_size - try: - config[CONF_BUFFER_SIZE] = 1.0 / next( - x for x in range(2, 17) if fraction >= 1 / x and height % x == 0 - ) - except StopIteration: - # Either the screen is too big, or the height is not divisible by any of the fractions, so use 1.0 - # PSRAM will be needed. - if CORE.is_esp32: - raise cv.Invalid( - "PSRAM is required for this display" - ) from StopIteration + # Target a buffer size of 20kB, except for large displays, which shouldn't end up here + fraction = min(20000.0, buffer_size // 16) / buffer_size + config[CONF_BUFFER_SIZE] = 1.0 / next( + x for x in range(2, 17) if fraction >= 1 / x + ) return config @@ -318,39 +304,6 @@ def _final_validate(config): FINAL_VALIDATE_SCHEMA = _final_validate -def get_transform(config): - """ - Get the transformation configuration for the display. - :param config: - :return: - """ - model = MODELS[config[CONF_MODEL]] - can_transform = model.rotation_as_transform(config) - transform = config.get( - CONF_TRANSFORM, - { - CONF_MIRROR_X: model.get_default(CONF_MIRROR_X, False), - CONF_MIRROR_Y: model.get_default(CONF_MIRROR_Y, False), - CONF_SWAP_XY: model.get_default(CONF_SWAP_XY, False), - }, - ) - - # Can we use the MADCTL register to set the rotation? - if can_transform and CONF_TRANSFORM not in config: - rotation = config[CONF_ROTATION] - if rotation == 180: - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - elif rotation == 90: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] - else: - transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] - transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] - transform[CONF_TRANSFORM] = True - return transform - - def get_instance(config): """ Get the type of MipiSpi instance to create based on the configuration, @@ -359,7 +312,16 @@ def get_instance(config): :return: type, template arguments """ model = MODELS[config[CONF_MODEL]] - width, height, offset_width, offset_height = model.get_dimensions(config) + has_hardware_transform = config.get( + CONF_TRANSFORM + ) != CONF_DISABLED and model.transforms == { + CONF_MIRROR_X, + CONF_MIRROR_Y, + CONF_SWAP_XY, + } + width, height, offset_width, offset_height = model.get_dimensions( + config, not has_hardware_transform + ) color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit")) bufferpixels = COLOR_DEPTHS[color_depth] @@ -373,57 +335,43 @@ def get_instance(config): bus_type = BusTypes[bus_type] buffer_type = cg.uint8 if color_depth == 8 else cg.uint16 frac = denominator(config) - rotation = ( - 0 if model.rotation_as_transform(config) else config.get(CONF_ROTATION, 0) - ) + madctl = model.get_madctl(model.get_base_transform(config), config) + has_writer = requires_buffer(config) templateargs = [ buffer_type, bufferpixels, config[CONF_BYTE_ORDER] == "big_endian", display_pixel_mode, bus_type, + width, + height, + offset_width, + offset_height, + madctl, + has_hardware_transform, ] + display.add_metadata( + config[CONF_ID], width, height, has_writer, has_hardware_transform + ) # If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi if requires_buffer(config): templateargs.extend( [ - width, - height, - offset_width, - offset_height, - DISPLAY_ROTATIONS[rotation], frac, config[CONF_DRAW_ROUNDING], ] ) return MipiSpiBuffer, templateargs - # Swap height and width if the display is rotated 90 or 270 degrees in software - if rotation in (90, 270): - width, height = height, width - offset_width, offset_height = offset_height, offset_width - templateargs.extend( - [ - width, - height, - offset_width, - offset_height, - ] - ) return MipiSpi, templateargs async def to_code(config): model = MODELS[config[CONF_MODEL]] var_id = config[CONF_ID] + init_sequence = model.get_sequence(config, False) var_id.type, templateargs = get_instance(config) var = cg.new_Pvariable(var_id, TemplateArguments(*templateargs)) - init_sequence, _madctl = model.get_sequence(config) cg.add(var.set_init_sequence(init_sequence)) - if model.rotation_as_transform(config): - if CONF_TRANSFORM in config: - LOGGER.warning("Use of 'transform' with 'rotation' is not recommended") - else: - config[CONF_ROTATION] = 0 cg.add(var.set_model(config[CONF_MODEL])) if enable_pin := config.get(CONF_ENABLE_PIN): enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin] diff --git a/esphome/components/mipi_spi/mipi_spi.cpp b/esphome/components/mipi_spi/mipi_spi.cpp index 90f6324511..2eec3b12d1 100644 --- a/esphome/components/mipi_spi/mipi_spi.cpp +++ b/esphome/components/mipi_spi/mipi_spi.cpp @@ -5,7 +5,8 @@ namespace esphome::mipi_spi { void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl, bool invert_colors, int display_bits, bool is_big_endian, const optional &brightness, - GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width) { + GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, + bool has_hardware_rotation) { ESP_LOGCONFIG(TAG, "MIPI_SPI Display\n" " Model: %s\n" @@ -14,6 +15,7 @@ void internal_dump_config(const char *model, int width, int height, int offset_w " Swap X/Y: %s\n" " Mirror X: %s\n" " Mirror Y: %s\n" + " Hardware rotation: %s\n" " Invert colors: %s\n" " Color order: %s\n" " Display pixels: %d bits\n" @@ -22,9 +24,9 @@ void internal_dump_config(const char *model, int width, int height, int offset_w " SPI Data rate: %uMHz\n" " SPI Bus width: %d", model, width, height, YESNO(madctl & MADCTL_MV), YESNO(madctl & (MADCTL_MX | MADCTL_XFLIP)), - YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(invert_colors), (madctl & MADCTL_BGR) ? "BGR" : "RGB", - display_bits, is_big_endian ? "Big" : "Little", spi_mode, static_cast(data_rate / 1000000), - bus_width); + YESNO(madctl & (MADCTL_MY | MADCTL_YFLIP)), YESNO(has_hardware_rotation), YESNO(invert_colors), + (madctl & MADCTL_BGR) ? "BGR" : "RGB", display_bits, is_big_endian ? "Big" : "Little", spi_mode, + static_cast(data_rate / 1000000), bus_width); LOG_PIN(" CS Pin: ", cs); LOG_PIN(" Reset Pin: ", reset); LOG_PIN(" DC Pin: ", dc); diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 083ff9507f..423226b1d7 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -34,13 +34,14 @@ static constexpr uint8_t SWIRE1 = 0x5A; static constexpr uint8_t SWIRE2 = 0x5B; static constexpr uint8_t PAGESEL = 0xFE; -static constexpr uint8_t MADCTL_MY = 0x80; // Bit 7 Bottom to top -static constexpr uint8_t MADCTL_MX = 0x40; // Bit 6 Right to left -static constexpr uint8_t MADCTL_MV = 0x20; // Bit 5 Swap axes -static constexpr uint8_t MADCTL_RGB = 0x00; // Bit 3 Red-Green-Blue pixel order -static constexpr uint8_t MADCTL_BGR = 0x08; // Bit 3 Blue-Green-Red pixel order -static constexpr uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally -static constexpr uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically +static constexpr uint8_t MADCTL_MY = 0x80; // Bit 7 Bottom to top +static constexpr uint8_t MADCTL_MX = 0x40; // Bit 6 Right to left +static constexpr uint8_t MADCTL_MV = 0x20; // Bit 5 Swap axes +static constexpr uint8_t MADCTL_RGB = 0x00; // Bit 3 Red-Green-Blue pixel order +static constexpr uint8_t MADCTL_BGR = 0x08; // Bit 3 Blue-Green-Red pixel order +static constexpr uint8_t MADCTL_XFLIP = 0x02; // Mirror the display horizontally +static constexpr uint8_t MADCTL_YFLIP = 0x01; // Mirror the display vertically +static constexpr uint16_t MADCTL_FLIP_FLAG = 0x100; // controller uses axis flip bits static constexpr uint8_t DELAY_FLAG = 0xFF; // store a 16 bit value in a buffer, big endian. @@ -66,7 +67,8 @@ enum BusType { // Helper function for dump_config - defined in mipi_spi.cpp to allow use of LOG_PIN macro void internal_dump_config(const char *model, int width, int height, int offset_width, int offset_height, uint8_t madctl, bool invert_colors, int display_bits, bool is_big_endian, const optional &brightness, - GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width); + GPIOPin *cs, GPIOPin *reset, GPIOPin *dc, int spi_mode, uint32_t data_rate, int bus_width, + bool has_hardware_rotation); /** * Base class for MIPI SPI displays. @@ -83,7 +85,7 @@ void internal_dump_config(const char *model, int width, int height, int offset_w * buffer */ template + int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL, bool HAS_HARDWARE_ROTATION> class MipiSpi : public display::Display, public spi::SPIDevice { @@ -103,10 +105,39 @@ class MipiSpi : public display::Display, this->brightness_ = brightness; this->reset_params_(); } + void set_rotation(display::DisplayRotation rotation) override { + this->rotation_ = rotation; + if constexpr (HAS_HARDWARE_ROTATION) { + this->reset_params_(); + } + } display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } - int get_width_internal() override { return WIDTH; } - int get_height_internal() override { return HEIGHT; } + int get_width() override { + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) + return HEIGHT; + return WIDTH; + } + + int get_height() override { + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) + return WIDTH; + return HEIGHT; + } + + // If hardware rotation is in use, the actual display width/height changes with rotation + int get_width_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_width(); + return WIDTH; + } + int get_height_internal() override { + if constexpr (HAS_HARDWARE_ROTATION) + return get_height(); + return HEIGHT; + } void set_init_sequence(const std::vector &sequence) { this->init_sequence_ = sequence; } // reset the display, and write the init sequence @@ -166,9 +197,6 @@ class MipiSpi : public display::Display, case INVERT_ON: this->invert_colors_ = true; break; - case MADCTL_CMD: - this->madctl_ = arg_byte; - break; case BRIGHTNESS: this->brightness_ = arg_byte; break; @@ -177,13 +205,13 @@ class MipiSpi : public display::Display, break; } const auto *ptr = vec.data() + index; - esph_log_d(TAG, "Command %02X, length %d, byte %02X", cmd, num_args, arg_byte); this->write_command_(cmd, ptr, num_args); index += num_args; if (cmd == SLEEP_OUT) delay(10); } } + this->reset_params_(); // init sequence no longer needed this->init_sequence_.clear(); } @@ -206,9 +234,10 @@ class MipiSpi : public display::Display, } void dump_config() override { - internal_dump_config(this->model_, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, this->madctl_, this->invert_colors_, - DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, - this->mode_, this->data_rate_, BUS_TYPE); + internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, + this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_, this->cs_, + this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE, + HAS_HARDWARE_ROTATION); } protected: @@ -219,10 +248,13 @@ class MipiSpi : public display::Display, // Writes a command to the display, with the given bytes. void write_command_(uint8_t cmd, const uint8_t *bytes, size_t len) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char hex_buf[format_hex_pretty_size(MIPI_SPI_MAX_CMD_LOG_BYTES)]; - esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); -#endif + // Don't spam the log after setup + if (this->init_sequence_.empty()) { + esph_log_v(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); + } else { + esph_log_d(TAG, "Command %02X, length %d, bytes %s", cmd, len, format_hex_pretty_to(hex_buf, bytes, len)); + } if constexpr (BUS_TYPE == BUS_TYPE_QUAD) { this->enable(); this->write_cmd_addr_data(8, 0x02, 24, cmd << 8, bytes, len); @@ -271,16 +303,60 @@ class MipiSpi : public display::Display, this->write_command_(this->invert_colors_ ? INVERT_ON : INVERT_OFF); if (this->brightness_.has_value()) this->write_command_(BRIGHTNESS, this->brightness_.value()); + + // calculate new madctl value from base value adjusted for rotation + uint8_t madctl = MADCTL; // lower 8 bits only + constexpr bool use_flips = (MADCTL & MADCTL_FLIP_FLAG) != 0; + constexpr uint8_t x_mask = use_flips ? MADCTL_XFLIP : MADCTL_MX; + constexpr uint8_t y_mask = use_flips ? MADCTL_YFLIP : MADCTL_MY; + if constexpr (HAS_HARDWARE_ROTATION) { + switch (this->rotation_) { + default: + break; + case display::DISPLAY_ROTATION_90_DEGREES: + madctl ^= x_mask; // flip X axis + madctl ^= MADCTL_MV; // swap X and Y axes + break; + case display::DISPLAY_ROTATION_180_DEGREES: + madctl ^= x_mask; // flip X axis + madctl ^= y_mask; // flip Y axis + break; + case display::DISPLAY_ROTATION_270_DEGREES: + madctl ^= y_mask; // flip Y axis + madctl ^= MADCTL_MV; // swap X and Y axes + break; + } + } + esph_log_d(TAG, "Setting MADCTL for rotation %d, value %X", this->rotation_, madctl); + this->write_command_(MADCTL_CMD, madctl); + } + + uint16_t get_offset_width_() { + if constexpr (HAS_HARDWARE_ROTATION) { + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) + return OFFSET_HEIGHT; + } + return OFFSET_WIDTH; + } + + uint16_t get_offset_height_() { + if constexpr (HAS_HARDWARE_ROTATION) { + if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES || + this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) + return OFFSET_WIDTH; + } + return OFFSET_HEIGHT; } // set the address window for the next data write void set_addr_window_(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { esph_log_v(TAG, "Set addr %d/%d, %d/%d", x1, y1, x2, y2); uint8_t buf[4]; - x1 += OFFSET_WIDTH; - x2 += OFFSET_WIDTH; - y1 += OFFSET_HEIGHT; - y2 += OFFSET_HEIGHT; + x1 += get_offset_width_(); + x2 += get_offset_width_(); + y1 += get_offset_height_(); + y2 += get_offset_height_(); put16_be(buf, y1); put16_be(buf + 2, y2); this->write_command_(RASET, buf, sizeof buf); @@ -408,7 +484,6 @@ class MipiSpi : public display::Display, optional brightness_{}; const char *model_{"Unknown"}; std::vector init_sequence_{}; - uint8_t madctl_{}; }; /** @@ -427,22 +502,21 @@ class MipiSpi : public display::Display, * @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even) */ template + uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL, + bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING> class MipiSpiBuffer : public MipiSpi { + OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION> { public: // these values define the buffer size needed to write in accordance with the chip pixel alignment // requirements. If the required rounding does not divide the width and height, we round up to the next multiple and // ignore the extra columns and rows when drawing, but use them to write to the display. - static constexpr unsigned BUFFER_WIDTH = (WIDTH + ROUNDING - 1) / ROUNDING * ROUNDING; - static constexpr unsigned BUFFER_HEIGHT = (HEIGHT + ROUNDING - 1) / ROUNDING * ROUNDING; + static constexpr size_t round_buffer(size_t size) { return (size + ROUNDING - 1) / ROUNDING * ROUNDING; } - MipiSpiBuffer() { this->rotation_ = ROTATION; } + MipiSpiBuffer() = default; void dump_config() override { - MipiSpi::dump_config(); + MipiSpi::dump_config(); esph_log_config(TAG, " Rotation: %d°\n" " Buffer pixels: %d bits\n" @@ -450,14 +524,14 @@ class MipiSpiBuffer : public MipiSpirotation_, BUFFERPIXEL * 8, FRACTION, - sizeof(BUFFERTYPE) * BUFFER_WIDTH * BUFFER_HEIGHT / FRACTION, ROUNDING); + sizeof(BUFFERTYPE) * round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION, ROUNDING); } void setup() override { - MipiSpi::setup(); + MipiSpi::setup(); RAMAllocator allocator{}; - this->buffer_ = allocator.allocate(BUFFER_WIDTH * BUFFER_HEIGHT / FRACTION); + this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION); if (this->buffer_ == nullptr) { this->mark_failed(LOG_STR("Buffer allocation failed")); } @@ -472,11 +546,13 @@ class MipiSpiBuffer : public MipiSpistart_line_ = 0; this->start_line_ < HEIGHT; this->start_line_ += HEIGHT / FRACTION) { + for (this->start_line_ = 0; this->start_line_ < this->get_height_internal(); + this->start_line_ += this->get_height_internal() / FRACTION) { #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE auto lap = millis(); #endif - this->end_line_ = this->start_line_ + HEIGHT / FRACTION; + this->end_line_ = + clamp_at_most(this->start_line_ + this->get_height_internal() / FRACTION, this->get_height_internal()); if (this->auto_clear_enabled_) { this->clear(); } @@ -503,10 +579,10 @@ class MipiSpiBuffer : public MipiSpix_high_ - this->x_low_ + 1; int h = this->y_high_ - this->y_low_ + 1; this->write_to_display_(this->x_low_, this->y_low_, w, h, this->buffer_, this->x_low_, - this->y_low_ - this->start_line_, BUFFER_WIDTH - w); + this->y_low_ - this->start_line_, round_buffer(this->get_width_internal()) - w); // invalidate watermarks - this->x_low_ = WIDTH; - this->y_low_ = HEIGHT; + this->x_low_ = this->get_width_internal(); + this->y_low_ = this->get_height_internal(); this->x_high_ = 0; this->y_high_ = 0; #if ESPHOME_LOG_LEVEL == ESPHOME_LOG_LEVEL_VERBOSE @@ -523,10 +599,23 @@ class MipiSpiBuffer : public MipiSpiget_clipping().inside(x, y)) return; - rotate_coordinates(x, y); - if (x < 0 || x >= WIDTH || y < this->start_line_ || y >= this->end_line_) + if constexpr (not HAS_HARDWARE_ROTATION) { + if (this->rotation_ == display::DISPLAY_ROTATION_180_DEGREES) { + x = WIDTH - x - 1; + y = HEIGHT - y - 1; + } else if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES) { + auto tmp = x; + x = WIDTH - y - 1; + y = tmp; + } else if (this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES) { + auto tmp = y; + y = HEIGHT - x - 1; + x = tmp; + } + } + if (x < 0 || x >= this->get_width_internal() || y < this->start_line_ || y >= this->end_line_) return; - this->buffer_[(y - this->start_line_) * BUFFER_WIDTH + x] = convert_color(color); + this->buffer_[(y - this->start_line_) * round_buffer(this->get_width_internal()) + x] = convert_color(color); if (x < this->x_low_) { this->x_low_ = x; } @@ -551,39 +640,14 @@ class MipiSpiBuffer : public MipiSpix_low_ = 0; this->y_low_ = this->start_line_; - this->x_high_ = WIDTH - 1; + this->x_high_ = this->get_width_internal() - 1; this->y_high_ = this->end_line_ - 1; - std::fill_n(this->buffer_, HEIGHT * BUFFER_WIDTH / FRACTION, convert_color(color)); - } - - int get_width() override { - if constexpr (ROTATION == display::DISPLAY_ROTATION_90_DEGREES || ROTATION == display::DISPLAY_ROTATION_270_DEGREES) - return HEIGHT; - return WIDTH; - } - - int get_height() override { - if constexpr (ROTATION == display::DISPLAY_ROTATION_90_DEGREES || ROTATION == display::DISPLAY_ROTATION_270_DEGREES) - return WIDTH; - return HEIGHT; + std::fill_n(this->buffer_, (this->end_line_ - this->start_line_) * round_buffer(this->get_width_internal()), + convert_color(color)); } protected: // Rotate the coordinates to match the display orientation. - static void rotate_coordinates(int &x, int &y) { - if constexpr (ROTATION == display::DISPLAY_ROTATION_180_DEGREES) { - x = WIDTH - x - 1; - y = HEIGHT - y - 1; - } else if constexpr (ROTATION == display::DISPLAY_ROTATION_90_DEGREES) { - auto tmp = x; - x = WIDTH - y - 1; - y = tmp; - } else if constexpr (ROTATION == display::DISPLAY_ROTATION_270_DEGREES) { - auto tmp = y; - y = HEIGHT - x - 1; - x = tmp; - } - } // Convert a color to the buffer pixel format. static BUFFERTYPE convert_color(const Color &color) { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 35ab405b2d..e3923bb0b8 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -1,7 +1,195 @@ +#include +#include #include "mitsubishi_cn105.h" namespace esphome::mitsubishi_cn105 { static const char *const TAG = "mitsubishi_cn105.driver"; +static constexpr uint32_t WRITE_TIMEOUT_MS = 2000; + +static constexpr size_t HEADER_LEN = 5; +static constexpr uint8_t PREAMBLE = 0xFC; +static constexpr uint8_t HEADER_BYTE_1 = 0x01; +static constexpr uint8_t HEADER_BYTE_2 = 0x30; + +static constexpr uint8_t PACKET_TYPE_CONNECT_REQUEST = 0x5A; +static constexpr uint8_t PACKET_TYPE_CONNECT_RESPONSE = 0x7A; +static constexpr std::array CONNECT_REQUEST_PAYLOAD = {{0xCA, 0x01}}; + +static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { + return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); +} + +template +static constexpr auto make_packet(uint8_t type, const std::array &payload) { + const size_t full_len = PayloadSize + HEADER_LEN + 1; + std::array packet{PREAMBLE, type, HEADER_BYTE_1, HEADER_BYTE_2, static_cast(PayloadSize)}; + std::copy_n(payload.begin(), PayloadSize, packet.begin() + HEADER_LEN); + packet.back() = checksum(packet.data(), packet.size() - 1); + return packet; +} + +static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST, CONNECT_REQUEST_PAYLOAD); + +void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); } + +void MitsubishiCN105::update() { + if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) { + this->write_timeout_start_ms_.reset(); + this->read_pos_ = 0; + this->set_state_(State::READ_TIMEOUT); + return; + } + + this->read_incoming_bytes_(); +} + +void MitsubishiCN105::set_state_(State new_state) { + if (should_transition(this->state_, new_state)) { + ESP_LOGV(TAG, "Did transition: %s -> %s", LOG_STR_ARG(state_to_string(this->state_)), + LOG_STR_ARG(state_to_string(new_state))); + this->state_ = new_state; + this->did_transition_(new_state); + } else { + ESP_LOGV(TAG, "Ignoring unexpected transition %s -> %s", LOG_STR_ARG(state_to_string(this->state_)), + LOG_STR_ARG(state_to_string(new_state))); + } +} + +bool MitsubishiCN105::should_transition(State from, State to) { + switch (to) { + case State::CONNECTING: + return from == State::NOT_CONNECTED || from == State::READ_TIMEOUT; + + case State::CONNECTED: + case State::READ_TIMEOUT: + return from == State::CONNECTING; + + default: + return false; + } +} + +void MitsubishiCN105::did_transition_(State to) { + switch (to) { + case State::CONNECTING: + this->send_packet_(CONNECT_PACKET); + break; + + case State::CONNECTED: + this->write_timeout_start_ms_.reset(); + // TODO: read AC status after connected, next PR + break; + + case State::READ_TIMEOUT: + this->set_state_(State::CONNECTING); + break; + + default: + break; + } +} + +void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { + dump_buffer_vv("TX", packet, len); + this->device_.write_array(packet, len); + this->write_timeout_start_ms_ = get_loop_time_ms(); +} + +void MitsubishiCN105::read_incoming_bytes_() { + uint8_t watchdog = 64; + while (this->device_.available() > 0 && watchdog-- > 0) { + uint8_t &value = this->read_buffer_[this->read_pos_]; + if (!this->device_.read_byte(&value)) { + ESP_LOGW(TAG, "UART read failed while data available"); + return; + } + + switch (++this->read_pos_) { + case 1: + if (value != PREAMBLE) { + this->reset_read_position_and_dump_buffer_("RX ignoring preamble"); + } + continue; + + case 2: + continue; + + case 3: + if (value != HEADER_BYTE_1) { + this->reset_read_position_and_dump_buffer_("RX invalid: header 1 mismatch"); + } + continue; + + case 4: + if (value != HEADER_BYTE_2) { + this->reset_read_position_and_dump_buffer_("RX invalid: header 2 mismatch"); + } + continue; + + case HEADER_LEN: + static_assert(READ_BUFFER_SIZE > HEADER_LEN); + if (this->read_buffer_[HEADER_LEN - 1] >= READ_BUFFER_SIZE - HEADER_LEN) { + this->reset_read_position_and_dump_buffer_("RX invalid: payload too large"); + } + continue; + + default: + break; + } + + const size_t len_without_checksum = HEADER_LEN + static_cast(this->read_buffer_[HEADER_LEN - 1]); + if (this->read_pos_ <= len_without_checksum) { + continue; + } + + if (checksum(this->read_buffer_, len_without_checksum) != value) { + this->reset_read_position_and_dump_buffer_("RX invalid: checksum mismatch"); + continue; + } + + this->process_rx_packet_(this->read_buffer_[1], this->read_buffer_ + HEADER_LEN, len_without_checksum - HEADER_LEN); + this->reset_read_position_and_dump_buffer_("RX"); + } +} + +void MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) { + switch (type) { + case PACKET_TYPE_CONNECT_RESPONSE: + this->set_state_(State::CONNECTED); + break; + + default: + ESP_LOGVV(TAG, "RX unknown packet type 0x%02X", type); + break; + } +} + +void MitsubishiCN105::reset_read_position_and_dump_buffer_(const char *prefix) { + dump_buffer_vv(prefix, this->read_buffer_, this->read_pos_); + this->read_pos_ = 0; +} + +void MitsubishiCN105::dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len) { +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERY_VERBOSE + char buf[format_hex_pretty_size(READ_BUFFER_SIZE)]; + ESP_LOGVV(TAG, "%s (%zu): %s", prefix, len, format_hex_pretty_to(buf, data, len)); +#endif +} + +const LogString *MitsubishiCN105::state_to_string(State state) { + switch (state) { + case State::NOT_CONNECTED: + return LOG_STR("Not connected"); + case State::CONNECTING: + return LOG_STR("Connecting"); + case State::CONNECTED: + return LOG_STR("Connected"); + case State::READ_TIMEOUT: + return LOG_STR("ReadTimeout"); + } + return LOG_STR("Unknown"); +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 6018dddbff..fc09b3bed2 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -1,19 +1,45 @@ #pragma once +#include #include "esphome/components/uart/uart.h" namespace esphome::mitsubishi_cn105 { +uint32_t get_loop_time_ms(); + class MitsubishiCN105 { public: explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {} + void initialize(); + void update(); + uint32_t get_update_interval() const { return this->update_interval_ms_; } void set_update_interval(uint32_t interval_ms) { this->update_interval_ms_ = interval_ms; } protected: + enum class State : uint8_t { NOT_CONNECTED, CONNECTING, CONNECTED, READ_TIMEOUT }; + + void set_state_(State new_state); + void did_transition_(State to); + void read_incoming_bytes_(); + void process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); + void reset_read_position_and_dump_buffer_(const char *prefix); + void send_packet_(const uint8_t *packet, size_t len); + template void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); } + static bool should_transition(State from, State to); + static const LogString *state_to_string(State state); + static void dump_buffer_vv(const char *prefix, const uint8_t *data, size_t len); + uart::UARTDevice &device_; uint32_t update_interval_ms_{1000}; + std::optional write_timeout_start_ms_; + State state_{State::NOT_CONNECTED}; + + private: + static constexpr size_t READ_BUFFER_SIZE = 32; + uint8_t read_buffer_[READ_BUFFER_SIZE]; + uint8_t read_pos_{0}; }; } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 6d50296c8f..cce6bef5e4 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -1,3 +1,4 @@ +#include #include "mitsubishi_cn105_climate.h" #include "esphome/core/log.h" @@ -14,9 +15,9 @@ void MitsubishiCN105Climate::dump_config() { LOG_STR_ARG(parity_to_str(this->parent_->get_parity())), this->parent_->get_stop_bits()); } -void MitsubishiCN105Climate::setup() {} +void MitsubishiCN105Climate::setup() { this->hp_.initialize(); } -void MitsubishiCN105Climate::loop() {} +void MitsubishiCN105Climate::loop() { this->hp_.update(); } climate::ClimateTraits MitsubishiCN105Climate::traits() { climate::ClimateTraits traits; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp new file mode 100644 index 0000000000..55a0a2328f --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_time.cpp @@ -0,0 +1,7 @@ +#include "esphome/core/application.h" + +namespace esphome::mitsubishi_cn105 { + +uint32_t __attribute__((weak)) get_loop_time_ms() { return App.get_loop_component_start_time(); }; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mlx90393/sensor.py b/esphome/components/mlx90393/sensor.py index d93c379506..293a133c3d 100644 --- a/esphome/components/mlx90393/sensor.py +++ b/esphome/components/mlx90393/sensor.py @@ -130,9 +130,6 @@ async def to_code(config): await cg.register_component(var, config) await i2c.register_i2c_device(var, config) - if CONF_DRDY_PIN in config: - pin = await cg.gpio_pin_expression(config[CONF_DRDY_PIN]) - cg.add(var.set_drdy_pin(pin)) cg.add(var.set_gain(GAIN[config[CONF_GAIN]])) cg.add(var.set_oversampling(config[CONF_OVERSAMPLING])) cg.add(var.set_filter(config[CONF_FILTER])) diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 3c4ceaf62d..5c3b39c954 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -376,7 +376,7 @@ size_t ModbusController::create_register_ranges_() { while (ix != this->sensorset_.end()) { SensorItem *curr = *ix; - ESP_LOGV(TAG, "Register: 0x%X %d %d %d offset=%u skip=%u addr=%p", curr->start_address, curr->register_count, + ESP_LOGV(TAG, "Register: 0x%X %d %d %zu offset=%u skip=%u addr=%p", curr->start_address, curr->register_count, curr->offset, curr->get_register_size(), curr->offset, curr->skip_updates, curr); if (r.register_count == 0) { @@ -484,18 +484,18 @@ void ModbusController::dump_config() { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE ESP_LOGCONFIG(TAG, "sensormap"); for (auto &it : this->sensorset_) { - ESP_LOGCONFIG(TAG, " Sensor type=%zu start=0x%X offset=0x%X count=%d size=%d", + ESP_LOGCONFIG(TAG, " Sensor type=%u start=0x%X offset=0x%X count=%d size=%zu", static_cast(it->register_type), it->start_address, it->offset, it->register_count, it->get_register_size()); } ESP_LOGCONFIG(TAG, "ranges"); for (auto &it : this->register_ranges_) { - ESP_LOGCONFIG(TAG, " Range type=%zu start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), + ESP_LOGCONFIG(TAG, " Range type=%u start=0x%X count=%d skip_updates=%d", static_cast(it.register_type), it.start_address, it.register_count, it.skip_updates); } ESP_LOGCONFIG(TAG, "server registers"); for (auto &r : this->server_registers_) { - ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%zu register_count=%u", r->address, + ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, static_cast(r->value_type), r->register_count); } #endif @@ -524,7 +524,7 @@ void ModbusController::on_write_register_response(ModbusRegisterType register_ty void ModbusController::dump_sensors_() { ESP_LOGV(TAG, "sensors"); for (auto &it : this->sensorset_) { - ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%d offset=%d", it->start_address, it->register_count, + ESP_LOGV(TAG, " Sensor start=0x%X count=%d size=%zu offset=%d", it->start_address, it->register_count, it->get_register_size(), it->offset); } } diff --git a/esphome/components/nextion/automation.h b/esphome/components/nextion/automation.h index 9f52507d67..17f6c77e17 100644 --- a/esphome/components/nextion/automation.h +++ b/esphome/components/nextion/automation.h @@ -2,8 +2,7 @@ #include "esphome/core/automation.h" #include "nextion.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { template class NextionSetBrightnessAction : public Action { public: @@ -91,5 +90,4 @@ template class NextionPublishBoolAction : public Action { NextionComponent *component_; }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp index 3628ac2f63..08e7c58ef1 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/util.h" #include "esphome/core/log.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { static const char *const TAG = "nextion_binarysensor"; @@ -64,5 +63,4 @@ void NextionBinarySensor::set_state(bool state, bool publish, bool send_to_nexti ESP_LOGN(TAG, "Write: %s=%s", this->variable_name_.c_str(), ONOFF(this->state)); } -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h index baab47851c..7637957222 100644 --- a/esphome/components/nextion/binary_sensor/nextion_binarysensor.h +++ b/esphome/components/nextion/binary_sensor/nextion_binarysensor.h @@ -4,8 +4,8 @@ #include "../nextion_component.h" #include "../nextion_base.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + class NextionBinarySensor; class NextionBinarySensor : public NextionComponent, @@ -38,5 +38,4 @@ class NextionBinarySensor : public NextionComponent, protected: uint8_t page_id_; }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index d141ef7906..4a15cbe64f 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { static const char *const TAG = "nextion"; @@ -151,10 +150,12 @@ void Nextion::reset_(bool reset_nextion) { delete entry; // NOLINT(cppcoreguidelines-owning-memory) } this->nextion_queue_.clear(); +#ifdef USE_NEXTION_WAVEFORM for (auto *entry : this->waveform_queue_) { delete entry; // NOLINT(cppcoreguidelines-owning-memory) } this->waveform_queue_.clear(); +#endif // USE_NEXTION_WAVEFORM } void Nextion::dump_config() { @@ -497,20 +498,21 @@ void Nextion::process_nextion_commands_() { ESP_LOGW(TAG, "Invalid baud rate"); break; case 0x12: // invalid Waveform ID or Channel # was used +#ifdef USE_NEXTION_WAVEFORM if (this->waveform_queue_.empty()) { ESP_LOGW(TAG, "Waveform ID/ch used but no sensor queued"); } else { auto &nb = this->waveform_queue_.front(); NextionComponentBase *component = nb->component; - ESP_LOGW(TAG, "Invalid waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - ESP_LOGN(TAG, "Remove waveform ID %d/ch %d", component->get_component_id(), component->get_wave_channel_id()); - delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); } +#else // USE_NEXTION_WAVEFORM + ESP_LOGW(TAG, "Waveform ID/ch error but waveform not enabled"); +#endif // USE_NEXTION_WAVEFORM break; case 0x1A: // variable name invalid ESP_LOGW(TAG, "Invalid variable name"); @@ -813,29 +815,30 @@ void Nextion::process_nextion_commands_() { } case 0xFD: { // data transparent transmit finished ESP_LOGVV(TAG, "Data transmit done"); +#ifdef USE_NEXTION_WAVEFORM this->check_pending_waveform_(); +#endif // USE_NEXTION_WAVEFORM break; } case 0xFE: { // data transparent transmit ready ESP_LOGVV(TAG, "Ready for transmit"); +#ifdef USE_NEXTION_WAVEFORM if (this->waveform_queue_.empty()) { ESP_LOGE(TAG, "No waveforms queued"); break; } - auto &nb = this->waveform_queue_.front(); auto *component = nb->component; - size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() - : 255; // ADDT command can only send 255 - + size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() : 255; this->write_array(component->get_wave_buffer().data(), static_cast(buffer_to_send)); - ESP_LOGN(TAG, "Send waveform: component id %d, waveform id %d, size %zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); - component->clear_wave_buffer(buffer_to_send); delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); +#else // USE_NEXTION_WAVEFORM + ESP_LOGW(TAG, "Waveform transmit ready but waveform not enabled"); +#endif // USE_NEXTION_WAVEFORM break; } default: @@ -935,8 +938,13 @@ void Nextion::all_components_send_state_(bool force_update) { binarysensortype->send_state_to_nextion(); } for (auto *sensortype : this->sensortype_) { - if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_channel_id() == 0) +#ifdef USE_NEXTION_WAVEFORM + if ((force_update || sensortype->get_needs_to_send_update()) && sensortype->get_wave_channel_id() == UINT8_MAX) { +#else // USE_NEXTION_WAVEFORM + if (force_update || sensortype->get_needs_to_send_update()) { +#endif // USE_NEXTION_WAVEFORM sensortype->send_state_to_nextion(); + } } for (auto *switchtype : this->switchtype_) { if (force_update || switchtype->get_needs_to_send_update()) @@ -1240,13 +1248,11 @@ void Nextion::add_to_get_queue(NextionComponentBase *component) { } } +#ifdef USE_NEXTION_WAVEFORM /** - * @brief Add addt command to the queue + * @brief Add addt command to the waveform queue. * - * @param component_id The waveform component id - * @param wave_chan_id The waveform channel to send it to - * @param buffer_to_send The buffer size - * @param buffer_size The buffer data + * @param component Pointer to the Nextion component with waveform data to send. */ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { if ((!this->is_setup() && !this->connection_state_.ignore_is_setup_) || this->is_sleeping()) @@ -1263,7 +1269,11 @@ void Nextion::add_addt_command_to_queue(NextionComponentBase *component) { nextion_queue->component = component; nextion_queue->queue_time = App.get_loop_component_start_time(); - this->waveform_queue_.push_back(nextion_queue); + if (!this->waveform_queue_.push(nextion_queue)) { + ESP_LOGW(TAG, "Waveform queue full, drop"); + delete nextion_queue; // NOLINT(cppcoreguidelines-owning-memory) + return; + } if (this->waveform_queue_.size() == 1) this->check_pending_waveform_(); } @@ -1274,21 +1284,20 @@ void Nextion::check_pending_waveform_() { auto *nb = this->waveform_queue_.front(); auto *component = nb->component; - size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() - : 255; // ADDT command can only send 255 + size_t buffer_to_send = component->get_wave_buffer_size() < 255 ? component->get_wave_buffer_size() : 255; char command[24]; // "addt " + uint8 + "," + uint8 + "," + uint8 + null = max 17 chars buf_append_printf(command, sizeof(command), 0, "addt %u,%u,%zu", component->get_component_id(), component->get_wave_channel_id(), buffer_to_send); if (!this->send_command_(command)) { delete nb; // NOLINT(cppcoreguidelines-owning-memory) - this->waveform_queue_.pop_front(); + this->waveform_queue_.pop(); } } +#endif // USE_NEXTION_WAVEFORM void Nextion::set_writer(const nextion_writer_t &writer) { this->writer_ = writer; } bool Nextion::is_updating() { return this->connection_state_.is_updating_; } -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index b5aaecd667..d910389289 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -9,6 +9,10 @@ #include "esphome/core/defines.h" #include "esphome/core/time.h" +#ifdef USE_NEXTION_WAVEFORM +#include "esphome/core/helpers.h" +#endif // USE_NEXTION_WAVEFORM + #include "nextion_base.h" #include "nextion_component.h" @@ -21,8 +25,7 @@ #endif // USE_ESP32 vs USE_ESP8266 #endif // USE_NEXTION_TFT_UPLOAD -namespace esphome { -namespace nextion { +namespace esphome::nextion { class Nextion; class NextionComponentBase; @@ -603,6 +606,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ void disable_component_touch(const char *component); +#ifdef USE_NEXTION_WAVEFORM /** * Add waveform data to a waveform component * @param component_id The integer component id. @@ -612,6 +616,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value); void open_waveform_channel(uint8_t component_id, uint8_t channel_number, uint8_t value); +#endif // USE_NEXTION_WAVEFORM /** * Display a picture at coordinates. @@ -1206,7 +1211,9 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe void add_to_get_queue(NextionComponentBase *component) override; +#ifdef USE_NEXTION_WAVEFORM void add_addt_command_to_queue(NextionComponentBase *component) override; +#endif // USE_NEXTION_WAVEFORM void update_components_by_prefix(const std::string &prefix); @@ -1392,7 +1399,11 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe #endif // USE_NEXTION_COMMAND_SPACING std::list nextion_queue_; - std::list waveform_queue_; +#ifdef USE_NEXTION_WAVEFORM + /// Fixed-size ring buffer for waveform queue. Nextion supports at most 4 waveform + /// channels (IDs 0-3), so 4 entries is both the correct maximum and a safe default. + StaticRingBuffer waveform_queue_; +#endif // USE_NEXTION_WAVEFORM uint16_t recv_ret_string_(std::string &response, uint32_t timeout, bool recv_flag); void all_components_send_state_(bool force_update = false); uint32_t comok_sent_ = 0; @@ -1461,7 +1472,9 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe const std::string &variable_name_to_send, const std::string &state_value, bool is_sleep_safe = false); +#ifdef USE_NEXTION_WAVEFORM void check_pending_waveform_(); +#endif // USE_NEXTION_WAVEFORM #ifdef USE_NEXTION_TFT_UPLOAD #ifdef USE_ESP8266 @@ -1547,5 +1560,4 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe uint16_t max_q_age_ms_ = 8000; ///< Maximum age for queue items in ms }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/nextion_base.h b/esphome/components/nextion/nextion_base.h index d46cd9a185..4a2dc90d40 100644 --- a/esphome/components/nextion/nextion_base.h +++ b/esphome/components/nextion/nextion_base.h @@ -2,8 +2,8 @@ #include "esphome/core/defines.h" #include "esphome/core/color.h" #include "nextion_component_base.h" -namespace esphome { -namespace nextion { + +namespace esphome::nextion { #ifdef ESPHOME_LOG_HAS_VERY_VERBOSE #define NEXTION_PROTOCOL_LOG @@ -33,7 +33,9 @@ class NextionBase { const std::string &variable_name_to_send, const std::string &state_value) = 0; +#ifdef USE_NEXTION_WAVEFORM virtual void add_addt_command_to_queue(NextionComponentBase *component) = 0; +#endif // USE_NEXTION_WAVEFORM virtual void add_to_get_queue(NextionComponentBase *component) = 0; @@ -61,5 +63,4 @@ class NextionBase { bool is_detected_ = false; }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index 6c8e0f18bc..a332d342ee 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -3,8 +3,8 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace nextion { +namespace esphome::nextion { + static const char *const TAG = "nextion"; // Sleep safe commands @@ -217,6 +217,7 @@ void Nextion::set_component_value(const char *component, int32_t value) { this->add_no_result_to_queue_with_printf_(".val", "%s.val=%" PRId32, component, value); } +#ifdef USE_NEXTION_WAVEFORM void Nextion::add_waveform_data(uint8_t component_id, uint8_t channel_number, uint8_t value) { this->add_no_result_to_queue_with_printf_("add", "add %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); @@ -226,6 +227,7 @@ void Nextion::open_waveform_channel(uint8_t component_id, uint8_t channel_number this->add_no_result_to_queue_with_printf_("addt", "addt %" PRIu8 ",%" PRIu8 ",%" PRIu8, component_id, channel_number, value); } +#endif // USE_NEXTION_WAVEFORM void Nextion::set_component_coordinates(const char *component, uint16_t x, uint16_t y) { this->add_no_result_to_queue_with_printf_(".xcen", "%s.xcen=%" PRIu16, component, x); @@ -340,5 +342,4 @@ void Nextion::set_nextion_rtc_time(ESPTime time) { this->add_no_result_to_queue_with_printf_("rtc5", "rtc5=%u", time.second); } -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/nextion_component.cpp b/esphome/components/nextion/nextion_component.cpp index 30c8b80524..f457125616 100644 --- a/esphome/components/nextion/nextion_component.cpp +++ b/esphome/components/nextion/nextion_component.cpp @@ -1,7 +1,6 @@ #include "nextion_component.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { void NextionComponent::set_background_color(Color bco) { if (this->variable_name_ == this->variable_name_to_send_) { @@ -110,5 +109,4 @@ void NextionComponent::update_component_settings(bool force_update) { this->component_flags_.font_id_needs_update = false; } } -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/nextion_component.h b/esphome/components/nextion/nextion_component.h index add9e11cf1..068cf51361 100644 --- a/esphome/components/nextion/nextion_component.h +++ b/esphome/components/nextion/nextion_component.h @@ -3,8 +3,8 @@ #include "esphome/core/color.h" #include "nextion_base.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + class NextionComponent; class NextionComponent : public NextionComponentBase { @@ -80,5 +80,4 @@ class NextionComponent : public NextionComponentBase { uint16_t reserved : 3; } component_flags_; }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/nextion_component_base.h b/esphome/components/nextion/nextion_component_base.h index 4d5550d406..6676d01920 100644 --- a/esphome/components/nextion/nextion_component_base.h +++ b/esphome/components/nextion/nextion_component_base.h @@ -5,8 +5,7 @@ #include #include "esphome/core/defines.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { enum NextionQueueType { NO_RESULT = 0, @@ -65,6 +64,7 @@ class NextionComponentBase { uint8_t get_component_id() const { return this->component_id_; } void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } +#ifdef USE_NEXTION_WAVEFORM uint8_t get_wave_channel_id() const { return this->wave_chan_id_; } void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } @@ -77,6 +77,7 @@ class NextionComponentBase { this->wave_buffer_.erase(this->wave_buffer_.begin(), this->wave_buffer_.begin() + buffer_sent); } } +#endif // USE_NEXTION_WAVEFORM const std::string &get_variable_name() const { return this->variable_name_; } const std::string &get_variable_name_to_send() const { return this->variable_name_to_send_; } @@ -86,21 +87,24 @@ class NextionComponentBase { virtual void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion){}; virtual void send_state_to_nextion(){}; bool get_needs_to_send_update() const { return this->needs_to_send_update_; } +#ifdef USE_NEXTION_WAVEFORM // Remove before 2026.10.0 ESPDEPRECATED("Use get_wave_channel_id() instead. Will be removed in 2026.10.0", "2026.4.0") uint8_t get_wave_chan_id() const { return this->get_wave_channel_id(); } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } +#endif // USE_NEXTION_WAVEFORM protected: std::string variable_name_; std::string variable_name_to_send_; uint8_t component_id_ = 0; +#ifdef USE_NEXTION_WAVEFORM uint8_t wave_chan_id_ = UINT8_MAX; std::vector wave_buffer_; int wave_max_length_ = 255; +#endif // USE_NEXTION_WAVEFORM bool needs_to_send_update_; }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/nextion_upload.cpp b/esphome/components/nextion/nextion_upload.cpp index 7ddd7a2f08..a49e7f18d6 100644 --- a/esphome/components/nextion/nextion_upload.cpp +++ b/esphome/components/nextion/nextion_upload.cpp @@ -4,8 +4,8 @@ #include "esphome/core/application.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + static const char *const TAG = "nextion.upload"; bool Nextion::upload_end_(bool successful) { @@ -33,7 +33,6 @@ bool Nextion::upload_end_(bool successful) { return successful; } -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion #endif // USE_NEXTION_TFT_UPLOAD diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index f59b708002..c79c68552e 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -11,8 +11,8 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + static const char *const TAG = "nextion.upload.arduino"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; @@ -342,8 +342,7 @@ WiFiClient *Nextion::get_wifi_client_() { } #endif // USE_ESP8266 -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion #endif // NOT USE_ESP32 #endif // USE_NEXTION_TFT_UPLOAD diff --git a/esphome/components/nextion/nextion_upload_esp32.cpp b/esphome/components/nextion/nextion_upload_esp32.cpp index 166bbcc86a..40a284dc46 100644 --- a/esphome/components/nextion/nextion_upload_esp32.cpp +++ b/esphome/components/nextion/nextion_upload_esp32.cpp @@ -14,8 +14,8 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + static const char *const TAG = "nextion.upload.esp32"; static constexpr size_t NEXTION_MAX_RESPONSE_LOG_BYTES = 16; @@ -344,8 +344,7 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { return this->upload_end_(true); } -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion #endif // USE_ESP32 #endif // USE_NEXTION_TFT_UPLOAD diff --git a/esphome/components/nextion/sensor/__init__.py b/esphome/components/nextion/sensor/__init__.py index cab531f1db..7351d8f1d5 100644 --- a/esphome/components/nextion/sensor/__init__.py +++ b/esphome/components/nextion/sensor/__init__.py @@ -85,16 +85,16 @@ async def to_code(config): cg.add(var.set_component_id(config[CONF_COMPONENT_ID])) if CONF_WAVE_CHANNEL_ID in config: + cg.add_define("USE_NEXTION_WAVEFORM") cg.add(var.set_wave_channel_id(config[CONF_WAVE_CHANNEL_ID])) - - if CONF_WAVEFORM_SEND_LAST_VALUE in config: - cg.add(var.set_waveform_send_last_value(config[CONF_WAVEFORM_SEND_LAST_VALUE])) - - if CONF_WAVE_MAX_VALUE in config: - cg.add(var.set_wave_max_value(config[CONF_WAVE_MAX_VALUE])) - - if CONF_WAVE_MAX_LENGTH in config: - cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) + if CONF_WAVEFORM_SEND_LAST_VALUE in config: + cg.add( + var.set_waveform_send_last_value(config[CONF_WAVEFORM_SEND_LAST_VALUE]) + ) + if CONF_WAVE_MAX_VALUE in config: + cg.add(var.set_wave_max_value(config[CONF_WAVE_MAX_VALUE])) + if CONF_WAVE_MAX_LENGTH in config: + cg.add(var.set_wave_max_length(config[CONF_WAVE_MAX_LENGTH])) @automation.register_action( diff --git a/esphome/components/nextion/sensor/nextion_sensor.cpp b/esphome/components/nextion/sensor/nextion_sensor.cpp index 9ea12cf808..ca657522f9 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.cpp +++ b/esphome/components/nextion/sensor/nextion_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/util.h" #include "esphome/core/log.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { static const char *const TAG = "nextion_sensor"; @@ -11,37 +10,44 @@ void NextionSensor::process_sensor(const std::string &variable_name, int state) if (!this->nextion_->is_setup()) return; - if (this->wave_chan_id_ == UINT8_MAX && this->variable_name_ == variable_name) { +#ifdef USE_NEXTION_WAVEFORM + if (this->wave_chan_id_ == UINT8_MAX && this->variable_name_ == variable_name) +#else // USE_NEXTION_WAVEFORM + if (this->variable_name_ == variable_name) +#endif // USE_NEXTION_WAVEFORM + { this->publish_state(state); ESP_LOGD(TAG, "Sensor: %s=%d", variable_name.c_str(), state); } } +#ifdef USE_NEXTION_WAVEFORM void NextionSensor::add_to_wave_buffer(float state) { this->needs_to_send_update_ = true; - int wave_state = (int) ((state / (float) this->wave_maxvalue_) * 100); - - wave_buffer_.push_back(wave_state); - + this->wave_buffer_.push_back(wave_state); if (this->wave_buffer_.size() > (size_t) this->wave_max_length_) { this->wave_buffer_.erase(this->wave_buffer_.begin()); } } +#endif // USE_NEXTION_WAVEFORM void NextionSensor::update() { if (!this->nextion_->is_setup() || this->nextion_->is_updating()) return; +#ifdef USE_NEXTION_WAVEFORM if (this->wave_chan_id_ == UINT8_MAX) { this->nextion_->add_to_get_queue(this); } else { if (this->send_last_value_) { this->add_to_wave_buffer(this->last_value_); } - this->wave_update_(); } +#else // USE_NEXTION_WAVEFORM + this->nextion_->add_to_get_queue(this); +#endif // USE_NEXTION_WAVEFORM } void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) { @@ -51,62 +57,60 @@ void NextionSensor::set_state(float state, bool publish, bool send_to_nextion) { if (std::isnan(state)) return; - if (this->wave_chan_id_ == UINT8_MAX) { - if (send_to_nextion) { - if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { - this->needs_to_send_update_ = true; - } else { - this->needs_to_send_update_ = false; - - if (this->precision_ > 0) { - double to_multiply = pow(10, this->precision_); - int state_value = (int) (state * to_multiply); - - this->nextion_->add_no_result_to_queue_with_set(this, (int) state_value); - } else { - this->nextion_->add_no_result_to_queue_with_set(this, (int) state); - } - } - } - } else { +#ifdef USE_NEXTION_WAVEFORM + if (this->wave_chan_id_ != UINT8_MAX) { + // Waveform sensor — buffer the value, don't send directly. if (this->send_last_value_) { this->last_value_ = state; // Update will handle setting the buffer } else { this->add_to_wave_buffer(state); } + this->update_component_settings(); + return; + } +#endif // USE_NEXTION_WAVEFORM + + if (send_to_nextion) { + if (this->nextion_->is_sleeping() || !this->component_flags_.visible) { + this->needs_to_send_update_ = true; + } else { + this->needs_to_send_update_ = false; + if (this->precision_ > 0) { + double to_multiply = pow(10, this->precision_); + int state_value = (int) (state * to_multiply); + this->nextion_->add_no_result_to_queue_with_set(this, (int) state_value); + } else { + this->nextion_->add_no_result_to_queue_with_set(this, (int) state); + } + } } float published_state = state; - if (this->wave_chan_id_ == UINT8_MAX) { - if (publish) { - if (this->precision_ > 0) { - double to_multiply = pow(10, -this->precision_); - published_state = (float) (state * to_multiply); - } - - this->publish_state(published_state); + if (publish) { + if (this->precision_ > 0) { + double to_multiply = pow(10, -this->precision_); + published_state = (float) (state * to_multiply); } + this->publish_state(published_state); } this->update_component_settings(); ESP_LOGN(TAG, "Write: %s=%lf", this->variable_name_.c_str(), published_state); } +#ifdef USE_NEXTION_WAVEFORM void NextionSensor::wave_update_() { if (this->nextion_->is_sleeping() || this->wave_buffer_.empty()) { return; } - #ifdef NEXTION_PROTOCOL_LOG size_t buffer_to_send = this->wave_buffer_.size() < 255 ? this->wave_buffer_.size() : 255; // ADDT command can only send 255 - ESP_LOGN(TAG, "Wave update: %zu/%zu vals to comp %d ch %d", buffer_to_send, this->wave_buffer_.size(), this->component_id_, this->wave_chan_id_); -#endif - +#endif // NEXTION_PROTOCOL_LOG this->nextion_->add_addt_command_to_queue(this); } +#endif // USE_NEXTION_WAVEFORM -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/sensor/nextion_sensor.h b/esphome/components/nextion/sensor/nextion_sensor.h index b1902f9b1b..72e3982b3a 100644 --- a/esphome/components/nextion/sensor/nextion_sensor.h +++ b/esphome/components/nextion/sensor/nextion_sensor.h @@ -4,8 +4,8 @@ #include "../nextion_component.h" #include "../nextion_base.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + class NextionSensor; class NextionSensor : public NextionComponent, public sensor::Sensor, public PollingComponent { @@ -15,22 +15,30 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol void update_component() override { this->update(); } void update() override; - void add_to_wave_buffer(float state); void set_precision(uint8_t precision) { this->precision_ = precision; } void set_component_id(uint8_t component_id) { this->component_id_ = component_id; } - void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } - void set_wave_max_value(uint32_t wave_maxvalue) { this->wave_maxvalue_ = wave_maxvalue; } void process_sensor(const std::string &variable_name, int state) override; void set_state(float state) override { this->set_state(state, true, true); } void set_state(float state, bool publish) override { this->set_state(state, publish, true); } void set_state(float state, bool publish, bool send_to_nextion) override; + NextionQueueType get_queue_type() const override { +#ifdef USE_NEXTION_WAVEFORM + return this->wave_chan_id_ == UINT8_MAX ? NextionQueueType::SENSOR : NextionQueueType::WAVEFORM_SENSOR; +#else // USE_NEXTION_WAVEFORM + return NextionQueueType::SENSOR; +#endif // USE_NEXTION_WAVEFORM + } + +#ifdef USE_NEXTION_WAVEFORM + void add_to_wave_buffer(float state); + void set_wave_channel_id(uint8_t wave_chan_id) { this->wave_chan_id_ = wave_chan_id; } + void set_wave_max_value(uint32_t wave_maxvalue) { this->wave_maxvalue_ = wave_maxvalue; } void set_waveform_send_last_value(bool send_last_value) { this->send_last_value_ = send_last_value; } void set_wave_max_length(int wave_max_length) { this->wave_max_length_ = wave_max_length; } - NextionQueueType get_queue_type() const override { - return this->wave_chan_id_ == UINT8_MAX ? NextionQueueType::SENSOR : NextionQueueType::WAVEFORM_SENSOR; - } +#endif // USE_NEXTION_WAVEFORM + void set_state_from_string(const std::string &state_value, bool publish, bool send_to_nextion) override {} void set_state_from_int(int state_value, bool publish, bool send_to_nextion) override { this->set_state(state_value, publish, send_to_nextion); @@ -38,11 +46,11 @@ class NextionSensor : public NextionComponent, public sensor::Sensor, public Pol protected: uint8_t precision_ = 0; +#ifdef USE_NEXTION_WAVEFORM uint32_t wave_maxvalue_ = 255; - float last_value_ = 0; bool send_last_value_ = true; void wave_update_(); +#endif // USE_NEXTION_WAVEFORM }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/switch/nextion_switch.cpp b/esphome/components/nextion/switch/nextion_switch.cpp index 21636f2bfa..0018cff005 100644 --- a/esphome/components/nextion/switch/nextion_switch.cpp +++ b/esphome/components/nextion/switch/nextion_switch.cpp @@ -2,8 +2,7 @@ #include "esphome/core/util.h" #include "esphome/core/log.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { static const char *const TAG = "nextion_switch"; @@ -48,5 +47,4 @@ void NextionSwitch::set_state(bool state, bool publish, bool send_to_nextion) { void NextionSwitch::write_state(bool state) { this->set_state(state); } -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/switch/nextion_switch.h b/esphome/components/nextion/switch/nextion_switch.h index c371ea3fc6..7e0593d217 100644 --- a/esphome/components/nextion/switch/nextion_switch.h +++ b/esphome/components/nextion/switch/nextion_switch.h @@ -4,8 +4,8 @@ #include "../nextion_component.h" #include "../nextion_base.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + class NextionSwitch; class NextionSwitch : public NextionComponent, public switch_::Switch, public PollingComponent { @@ -30,5 +30,4 @@ class NextionSwitch : public NextionComponent, public switch_::Switch, public Po protected: void write_state(bool state) override; }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp index 9b6deeda87..45e4691423 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.cpp +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.cpp @@ -2,8 +2,8 @@ #include "esphome/core/util.h" #include "esphome/core/log.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + static const char *const TAG = "nextion_textsensor"; void NextionTextSensor::process_text(const std::string &variable_name, const std::string &text_value) { @@ -45,5 +45,4 @@ void NextionTextSensor::set_state(const std::string &state, bool publish, bool s ESP_LOGN(TAG, "Write: %s='%s'", this->variable_name_.c_str(), state.c_str()); } -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/nextion/text_sensor/nextion_textsensor.h b/esphome/components/nextion/text_sensor/nextion_textsensor.h index 7c08e47189..42cd5dcef4 100644 --- a/esphome/components/nextion/text_sensor/nextion_textsensor.h +++ b/esphome/components/nextion/text_sensor/nextion_textsensor.h @@ -4,8 +4,8 @@ #include "../nextion_component.h" #include "../nextion_base.h" -namespace esphome { -namespace nextion { +namespace esphome::nextion { + class NextionTextSensor; class NextionTextSensor : public NextionComponent, public text_sensor::TextSensor, public PollingComponent { @@ -28,5 +28,4 @@ class NextionTextSensor : public NextionComponent, public text_sensor::TextSenso this->set_state(state_value, publish, send_to_nextion); } }; -} // namespace nextion -} // namespace esphome +} // namespace esphome::nextion diff --git a/esphome/components/ota/automation.h b/esphome/components/ota/automation.h index 92c0050ba0..29a8878136 100644 --- a/esphome/components/ota/automation.h +++ b/esphome/components/ota/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace ota { +namespace esphome::ota { class OTAStateChangeTrigger final : public Trigger, public OTAStateListener { public: @@ -67,6 +66,5 @@ class OTAErrorTrigger final : public Trigger, public OTAStateListener { OTAComponent *parent_; }; -} // namespace ota -} // namespace esphome +} // namespace esphome::ota #endif diff --git a/esphome/components/ota/ota_backend.cpp b/esphome/components/ota/ota_backend.cpp index 01a18a58ef..17949de642 100644 --- a/esphome/components/ota/ota_backend.cpp +++ b/esphome/components/ota/ota_backend.cpp @@ -1,7 +1,6 @@ #include "ota_backend.h" -namespace esphome { -namespace ota { +namespace esphome::ota { #ifdef USE_OTA_STATE_LISTENER OTAGlobalCallback *global_ota_callback{nullptr}; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -34,5 +33,4 @@ void OTAComponent::notify_state_(OTAState state, float progress, uint8_t error) } #endif -} // namespace ota -} // namespace esphome +} // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend.h b/esphome/components/ota/ota_backend.h index ab0ec58e8a..db79370bb3 100644 --- a/esphome/components/ota/ota_backend.h +++ b/esphome/components/ota/ota_backend.h @@ -8,8 +8,7 @@ #include #endif -namespace esphome { -namespace ota { +namespace esphome::ota { enum OTAResponseTypes { OTA_RESPONSE_OK = 0x00, @@ -117,5 +116,4 @@ OTAGlobalCallback *get_global_ota_callback(); // - notify_state_deferred_() when in separate task (e.g., web_server OTA) // This ensures proper listener execution in all contexts. #endif -} // namespace ota -} // namespace esphome +} // namespace esphome::ota diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.cpp b/esphome/components/ota/ota_backend_arduino_libretiny.cpp index d364f75007..dcd71e92dd 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.cpp +++ b/esphome/components/ota/ota_backend_arduino_libretiny.cpp @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace ota { +namespace esphome::ota { static const char *const TAG = "ota.arduino_libretiny"; @@ -66,7 +65,5 @@ OTAResponseTypes ArduinoLibreTinyOTABackend::end() { void ArduinoLibreTinyOTABackend::abort() { Update.abort(); } -} // namespace ota -} // namespace esphome - +} // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_libretiny.h b/esphome/components/ota/ota_backend_arduino_libretiny.h index 4514bf84bd..3d426e6759 100644 --- a/esphome/components/ota/ota_backend_arduino_libretiny.h +++ b/esphome/components/ota/ota_backend_arduino_libretiny.h @@ -4,8 +4,7 @@ #include "esphome/core/defines.h" -namespace esphome { -namespace ota { +namespace esphome::ota { class ArduinoLibreTinyOTABackend final { public: @@ -22,7 +21,5 @@ class ArduinoLibreTinyOTABackend final { std::unique_ptr make_ota_backend(); -} // namespace ota -} // namespace esphome - +} // namespace esphome::ota #endif // USE_LIBRETINY diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.cpp b/esphome/components/ota/ota_backend_arduino_rp2040.cpp index e2a57ec665..bc8ef812e6 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.cpp +++ b/esphome/components/ota/ota_backend_arduino_rp2040.cpp @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace ota { +namespace esphome::ota { static const char *const TAG = "ota.arduino_rp2040"; @@ -75,8 +74,6 @@ void ArduinoRP2040OTABackend::abort() { rp2040::preferences_prevent_write(false); } -} // namespace ota -} // namespace esphome - +} // namespace esphome::ota #endif // USE_RP2040 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_rp2040.h b/esphome/components/ota/ota_backend_arduino_rp2040.h index 0956cb4b4b..05bd2f5cc4 100644 --- a/esphome/components/ota/ota_backend_arduino_rp2040.h +++ b/esphome/components/ota/ota_backend_arduino_rp2040.h @@ -6,8 +6,7 @@ #include "esphome/core/defines.h" #include "esphome/core/macros.h" -namespace esphome { -namespace ota { +namespace esphome::ota { class ArduinoRP2040OTABackend final { public: @@ -24,8 +23,6 @@ class ArduinoRP2040OTABackend final { std::unique_ptr make_ota_backend(); -} // namespace ota -} // namespace esphome - +} // namespace esphome::ota #endif // USE_RP2040 #endif // USE_ARDUINO diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 925bb39645..efaf810ca3 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -8,8 +8,7 @@ #include #include -namespace esphome { -namespace ota { +namespace esphome::ota { std::unique_ptr make_ota_backend() { return make_unique(); } @@ -112,6 +111,5 @@ void IDFOTABackend::abort() { this->update_handle_ = 0; } -} // namespace ota -} // namespace esphome +} // namespace esphome::ota #endif // USE_ESP32 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index a0f538afc0..d007bcd128 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -7,8 +7,7 @@ #include -namespace esphome { -namespace ota { +namespace esphome::ota { class IDFOTABackend final { public: @@ -29,6 +28,5 @@ class IDFOTABackend final { std::unique_ptr make_ota_backend(); -} // namespace ota -} // namespace esphome +} // namespace esphome::ota #endif // USE_ESP32 diff --git a/esphome/components/pipsolar/output/__init__.py b/esphome/components/pipsolar/output/__init__.py index 81e99e15a2..4ae8d9d487 100644 --- a/esphome/components/pipsolar/output/__init__.py +++ b/esphome/components/pipsolar/output/__init__.py @@ -94,7 +94,7 @@ async def to_code(config): SetOutputAction, cv.Schema( { - cv.Required(CONF_ID): cv.use_id(CONF_ID), + cv.Required(CONF_ID): cv.use_id(PipsolarOutput), cv.Required(CONF_VALUE): cv.templatable(cv.positive_float), } ), diff --git a/esphome/components/runtime_image/runtime_image.cpp b/esphome/components/runtime_image/runtime_image.cpp index 2ebe67c3a5..fa42b53496 100644 --- a/esphome/components/runtime_image/runtime_image.cpp +++ b/esphome/components/runtime_image/runtime_image.cpp @@ -248,6 +248,9 @@ void RuntimeImage::release_buffer_() { this->height_ = 0; this->buffer_width_ = 0; this->buffer_height_ = 0; +#ifdef USE_LVGL + memset(&this->dsc_, 0, sizeof(this->dsc_)); +#endif } } diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 9fe51121f1..ce35cf5bf1 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -25,7 +25,6 @@ from esphome.const import ( CONF_TEMPERATURE_COMPENSATION, CONF_TIME_CONSTANT, CONF_VOC, - CONF_VOC_BASELINE, DEVICE_CLASS_AQI, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PM1, @@ -165,7 +164,6 @@ CONFIG_SCHEMA = ( gain_factor=230, ), cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean, - cv.Optional(CONF_VOC_BASELINE): cv.hex_uint16_t, cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( unit_of_measurement=UNIT_CELSIUS, icon=ICON_THERMOMETER, diff --git a/esphome/components/st7735/__init__.py b/esphome/components/st7735/__init__.py index ba854bb0ae..62dfa6f413 100644 --- a/esphome/components/st7735/__init__.py +++ b/esphome/components/st7735/__init__.py @@ -1,3 +1,8 @@ import esphome.codegen as cg st7735_ns = cg.esphome_ns.namespace("st7735") + +DEPRECATED_COMPONENT = """ +The 'st7735' component is deprecated and no new models will be added to it. +New model PRs should target the newer and more performant 'mipi_spi' component. +""" diff --git a/esphome/components/st7735/display.py b/esphome/components/st7735/display.py index 9dc69f27ff..766370c21f 100644 --- a/esphome/components/st7735/display.py +++ b/esphome/components/st7735/display.py @@ -1,3 +1,5 @@ +import logging + from esphome import pins import esphome.codegen as cg from esphome.components import display, spi @@ -15,6 +17,7 @@ from esphome.const import ( from . import st7735_ns CODEOWNERS = ["@SenexCrenshaw"] +LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["spi"] @@ -87,6 +90,9 @@ async def setup_st7735(var, config): async def to_code(config): + LOGGER.warning( + "The 'st7735' component is deprecated, it is recommended to use 'mipi_spi' instead." + ) var = cg.new_Pvariable( config[CONF_ID], config[CONF_MODEL], diff --git a/esphome/components/template/cover/__init__.py b/esphome/components/template/cover/__init__.py index cfc19c00cd..ea4da4e73c 100644 --- a/esphome/components/template/cover/__init__.py +++ b/esphome/components/template/cover/__init__.py @@ -108,7 +108,6 @@ async def to_code(config): cg.add(var.set_optimistic(config[CONF_OPTIMISTIC])) cg.add(var.set_assumed_state(config[CONF_ASSUMED_STATE])) cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE])) - cg.add(var.set_has_position(config[CONF_HAS_POSITION])) @automation.register_action( diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index f7c1298d68..ec115296d7 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -503,7 +503,7 @@ def validate_thermostat(config): # If restoring default preset on boot is true then ensure we have a default preset if ( CONF_ON_BOOT_RESTORE_FROM in config - and config[CONF_ON_BOOT_RESTORE_FROM] is OnBootRestoreFrom.DEFAULT_PRESET + and config[CONF_ON_BOOT_RESTORE_FROM] == "DEFAULT_PRESET" and CONF_DEFAULT_PRESET not in config ): raise cv.Invalid( diff --git a/esphome/components/time/posix_tz.cpp b/esphome/components/time/posix_tz.cpp index f388267abd..c25248e457 100644 --- a/esphome/components/time/posix_tz.cpp +++ b/esphome/components/time/posix_tz.cpp @@ -34,25 +34,43 @@ bool is_leap_year(int year) { return (year % 4 == 0 && year % 100 != 0) || (year // Get days in year (avoids duplicate is_leap_year calls) static inline int days_in_year(int year) { return is_leap_year(year) ? 366 : 365; } -// Convert days since epoch to year, updating days to remainder -static int __attribute__((noinline)) days_to_year(int64_t &days) { - int year = 1970; - int diy; - while (days >= (diy = days_in_year(year)) && year < 2200) { - days -= diy; +// Count leap years in [1, year] (i.e. up to and including year) +static constexpr int count_leap_years_up_to(int year) { return year / 4 - year / 100 + year / 400; } + +constexpr int EPOCH_YEAR = 1970; +constexpr int LEAP_YEARS_BEFORE_EPOCH = count_leap_years_up_to(EPOCH_YEAR - 1); +constexpr int DAYS_PER_YEAR = 365; +constexpr int SECONDS_PER_DAY = 86400; + +// Days from epoch (Jan 1 1970) to Jan 1 of given year — O(1) +static inline int64_t days_to_year_start(int year) { + return static_cast(DAYS_PER_YEAR) * (year - EPOCH_YEAR) + + (count_leap_years_up_to(year - 1) - LEAP_YEARS_BEFORE_EPOCH); +} + +// Convert days since epoch to year, updating days to day-of-year remainder. +// The initial estimate from days/365 can overshoot by multiple years for +// far-future dates (e.g., year 5000+) due to accumulated leap days, +// so we use loops rather than single-step correction. +static int days_to_year(int64_t &days) { + int year = static_cast(EPOCH_YEAR + days / DAYS_PER_YEAR); + int64_t year_start = days_to_year_start(year); + while (days < year_start) { + year--; + year_start = days_to_year_start(year); + } + while (days >= year_start + days_in_year(year)) { + year_start += days_in_year(year); year++; } - while (days < 0 && year > 1900) { - year--; - days += days_in_year(year); - } + days -= year_start; return year; } -// Extract just the year from a UTC epoch +// Extract just the year from a UTC epoch — O(1) static int epoch_to_year(time_t epoch) { - int64_t days = epoch / 86400; - if (epoch < 0 && epoch % 86400 != 0) + int64_t days = epoch / SECONDS_PER_DAY; + if (epoch < 0 && epoch % SECONDS_PER_DAY != 0) days--; return days_to_year(days); } @@ -87,11 +105,11 @@ int __attribute__((noinline)) day_of_week(int year, int month, int day) { void __attribute__((noinline)) epoch_to_tm_utc(time_t epoch, struct tm *out_tm) { // Days since epoch - int64_t days = epoch / 86400; - int32_t remaining_secs = epoch % 86400; + int64_t days = epoch / SECONDS_PER_DAY; + int32_t remaining_secs = epoch % SECONDS_PER_DAY; if (remaining_secs < 0) { days--; - remaining_secs += 86400; + remaining_secs += SECONDS_PER_DAY; } out_tm->tm_sec = remaining_secs % 60; @@ -280,17 +298,6 @@ static int __attribute__((noinline)) days_from_year_start(int year, int month, i return days; } -// Calculate days from epoch to Jan 1 of given year (for DST transition calculations) -// Only supports years >= 1970. Timezone is either compiled in from YAML or set by -// Home Assistant, so pre-1970 dates are not a concern. -static int64_t __attribute__((noinline)) days_to_year_start(int year) { - int64_t days = 0; - for (int y = 1970; y < year; y++) { - days += days_in_year(y); - } - return days; -} - time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRule &rule, int32_t base_offset_seconds) { int month, day; @@ -339,7 +346,7 @@ time_t __attribute__((noinline)) calculate_dst_transition(int year, const DSTRul int64_t days = days_to_year_start(year) + days_from_year_start(year, month, day); // Convert to epoch and add transition time and base offset - return days * 86400 + rule.time_seconds + base_offset_seconds; + return days * SECONDS_PER_DAY + rule.time_seconds + base_offset_seconds; } } // namespace internal diff --git a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp index c89d23672e..7a56654804 100644 --- a/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +++ b/esphome/components/uptime/text_sensor/uptime_text_sensor.cpp @@ -70,7 +70,7 @@ void UptimeTextSensor::update() { if (show_seconds) append_unit(buf, sizeof(buf), pos, this->separator_, seconds, this->seconds_text_); - this->publish_state(buf); + this->publish_state(buf, pos); } float UptimeTextSensor::get_setup_priority() const { return setup_priority::HARDWARE; } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1dda6204fe..a57a8d26ff 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -286,10 +286,11 @@ void DeferredUpdateEventSource::try_send_nodefer(const char *message, const char this->send(message, event, id, reconnect); } -void DeferredUpdateEventSourceList::loop() { +bool DeferredUpdateEventSourceList::loop() { for (DeferredUpdateEventSource *dues : *this) { dues->loop(); } + return !this->empty(); } void DeferredUpdateEventSourceList::deferrable_send_state(void *source, const char *event_type, @@ -318,6 +319,7 @@ void DeferredUpdateEventSourceList::add_new_client(WebServer *ws, AsyncWebServer es->onDisconnect([this, es](AsyncEventSourceClient *client) { this->on_client_disconnect_(es); }); es->handleRequest(request); + ws->enable_loop_soon_any_context(); } void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource *source) { @@ -413,13 +415,24 @@ void WebServer::setup() { // doesn't need defer functionality - if the queue is full, the client JS knows it's alive because it's clearly // getting a lot of events this->set_interval(10000, [this]() { + if (this->events_.empty()) + return; char buf[32]; auto uptime = static_cast(millis_64() / 1000); buf_append_printf(buf, sizeof(buf), 0, "{\"uptime\":%" PRIu32 "}", uptime); this->events_.try_send_nodefer(buf, "ping", millis(), 30000); }); } -void WebServer::loop() { this->events_.loop(); } +void WebServer::loop() { + // No SSE clients connected; stop looping until a new client connects via + // enable_loop_soon_any_context(). This is safe because: + // - set_interval/set_timeout/defer run via the Scheduler, independent of loop() + // - deferrable_send_state early-outs when no clients are connected + // - try_send_nodefer (log, ping) iterates sessions which are empty + // - REST API handlers use defer() which runs via the Scheduler + if (!this->events_.loop()) + this->disable_loop(); +} #ifdef USE_LOGGER void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 6152dfbfd3..8e8b1de8c4 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -169,7 +169,8 @@ class DeferredUpdateEventSourceList final : public std::liston_connect_(rsp); } this->sessions_.push_back(rsp); + // Wake up WebServer::loop() to drain deferred event queues for this client. + // Safe from httpd task context via the pending_enable_loop_ flag. + this->web_server_->enable_loop_soon_any_context(); } -void AsyncEventSource::loop() { +bool AsyncEventSource::loop() { // Clean up dead sessions safely // This follows the ESP-IDF pattern where free_ctx marks resources as dead // and the main loop handles the actual cleanup to avoid race conditions @@ -504,6 +507,7 @@ void AsyncEventSource::loop() { ++i; } } + return !this->sessions_.empty(); } void AsyncEventSource::try_send_nodefer(const char *message, const char *event, uint32_t id, uint32_t reconnect) { diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 81683e8d85..f2931fb507 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -340,7 +340,8 @@ class AsyncEventSource : public AsyncWebHandler { void try_send_nodefer(const char *message, const char *event = nullptr, uint32_t id = 0, uint32_t reconnect = 0); void deferrable_send_state(void *source, const char *event_type, message_generator_t *message_generator); - void loop(); + /// Returns true if there are sessions remaining (including pending cleanup). + bool loop(); bool empty() { return this->count() == 0; } size_t count() const { return this->sessions_.size(); } diff --git a/esphome/components/zigbee/zigbee_zephyr.cpp b/esphome/components/zigbee/zigbee_zephyr.cpp index c103363b4a..047c30300e 100644 --- a/esphome/components/zigbee/zigbee_zephyr.cpp +++ b/esphome/components/zigbee/zigbee_zephyr.cpp @@ -255,26 +255,25 @@ void ZigbeeComponent::factory_reset() { ZB_SCHEDULE_APP_CALLBACK(zb_bdb_reset_via_local_action, 0); } +static void log_reporting_info(zb_zcl_reporting_info_t *rep_info) { + auto now = millis(); + ESP_LOGD(TAG, "Reporting: endpoint %d, cluster_id 0x%04X, attr_id 0x%04X, flags 0x%02X, report in %ums", rep_info->ep, + rep_info->cluster_id, rep_info->attr_id, rep_info->flags, + ZB_ZCL_GET_REPORTING_FLAG(rep_info, ZB_ZCL_REPORT_TIMER_STARTED) + ? ZB_TIME_BEACON_INTERVAL_TO_MSEC(rep_info->run_time) - now + : 0); + ESP_LOGD(TAG, " min_interval %ds, max_interval %ds, def_min_interval %ds, def_max_interval %ds", + rep_info->u.send_info.min_interval, rep_info->u.send_info.max_interval, + rep_info->u.send_info.def_min_interval, rep_info->u.send_info.def_max_interval); +} + void ZigbeeComponent::dump_reporting_() { #ifdef ESPHOME_LOG_HAS_VERBOSE - auto now = millis(); - bool first = true; for (zb_uint8_t j = 0; j < ZCL_CTX().device_ctx->ep_count; j++) { if (ZCL_CTX().device_ctx->ep_desc_list[j]->reporting_info) { zb_zcl_reporting_info_t *rep_info = ZCL_CTX().device_ctx->ep_desc_list[j]->reporting_info; for (zb_uint8_t i = 0; i < ZCL_CTX().device_ctx->ep_desc_list[j]->rep_info_count; i++) { - if (!first) { - ESP_LOGV(TAG, ""); - } - first = false; - ESP_LOGV(TAG, "Endpoint: %d, cluster_id %d, attr_id %d, flags %d, report in %ums", rep_info->ep, - rep_info->cluster_id, rep_info->attr_id, rep_info->flags, - ZB_ZCL_GET_REPORTING_FLAG(rep_info, ZB_ZCL_REPORT_TIMER_STARTED) - ? ZB_TIME_BEACON_INTERVAL_TO_MSEC(rep_info->run_time) - now - : 0); - ESP_LOGV(TAG, "Min_interval %ds, max_interval %ds, def_min_interval %ds, def_max_interval %ds", - rep_info->u.send_info.min_interval, rep_info->u.send_info.max_interval, - rep_info->u.send_info.def_min_interval, rep_info->u.send_info.def_max_interval); + log_reporting_info(rep_info); rep_info++; } } @@ -282,9 +281,38 @@ void ZigbeeComponent::dump_reporting_() { #endif } +void ZigbeeComponent::after_reporting_info(zb_zcl_configure_reporting_req_t *config_rep_req, + zb_zcl_attr_addr_info_t *attr_addr_info) { +#ifdef ESPHOME_LOG_HAS_DEBUG + auto *rep_info = + zb_zcl_find_reporting_info_manuf(attr_addr_info->src_ep, attr_addr_info->cluster_id, attr_addr_info->cluster_role, + config_rep_req->attr_id, attr_addr_info->manuf_code); + if (rep_info == nullptr) { + ESP_LOGE(TAG, + "Failed to resolve reporting info (src_ep=%u cluster_id=0x%04x role=%u attr_id=0x%04x manuf_code=0x%04x)", + attr_addr_info->src_ep, attr_addr_info->cluster_id, attr_addr_info->cluster_role, config_rep_req->attr_id, + attr_addr_info->manuf_code); + return; + } + log_reporting_info(rep_info); +#endif +} + } // namespace esphome::zigbee -extern "C" void zboss_signal_handler(zb_uint8_t param) { - esphome::zigbee::global_zigbee->zboss_signal_handler_esphome(param); +extern "C" { +void zboss_signal_handler(zb_uint8_t param) { esphome::zigbee::global_zigbee->zboss_signal_handler_esphome(param); } + +// NOLINTBEGIN(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) +extern zb_ret_t __real_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_req_t *config_rep_req, + zb_zcl_attr_addr_info_t *attr_addr_info); + +zb_ret_t __wrap_zb_zcl_put_reporting_info_from_req(zb_zcl_configure_reporting_req_t *config_rep_req, + zb_zcl_attr_addr_info_t *attr_addr_info) { + zb_ret_t ret = __real_zb_zcl_put_reporting_info_from_req(config_rep_req, attr_addr_info); + esphome::zigbee::global_zigbee->after_reporting_info(config_rep_req, attr_addr_info); + return ret; +} +// NOLINTEND(readability-identifier-naming,bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp) } #endif diff --git a/esphome/components/zigbee/zigbee_zephyr.h b/esphome/components/zigbee/zigbee_zephyr.h index 3fa5818ec5..eeb142eff1 100644 --- a/esphome/components/zigbee/zigbee_zephyr.h +++ b/esphome/components/zigbee/zigbee_zephyr.h @@ -76,6 +76,7 @@ class ZigbeeComponent : public Component { } template void add_join_callback(F &&cb) { this->join_cb_.add(std::forward(cb)); } void zboss_signal_handler_esphome(zb_bufid_t bufid); + void after_reporting_info(zb_zcl_configure_reporting_req_t *config_rep_req, zb_zcl_attr_addr_info_t *attr_addr_info); void factory_reset(); Trigger<> *get_join_trigger() { return &this->join_trigger_; }; void force_report(); diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index a1e6ad3097..3288d92483 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -166,6 +166,8 @@ async def zephyr_to_code(config: ConfigType) -> None: zephyr_add_prj_conf("NET_IP_ADDR_CHECK", False) zephyr_add_prj_conf("NET_UDP", False) + cg.add_build_flag("-Wl,--wrap=zb_zcl_put_reporting_info_from_req") + if CONF_IEEE802154_VENDOR_OUI in config: zephyr_add_prj_conf("IEEE802154_VENDOR_OUI_ENABLE", True) random_number = config[CONF_IEEE802154_VENDOR_OUI] diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 45d2cd8117..09f460f46b 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -244,6 +244,8 @@ RESERVED_IDS = [ "open", "setup", "loop", + "spi0", + "spi1", "uart0", "uart1", "uart2", diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index 2b5aba2a7b..0f68f0c8e0 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -294,12 +294,10 @@ void Component::disable_loop() { App.disable_component_loop_(this); } } -void Component::enable_loop() { - if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) { - ESP_LOGVV(TAG, "%s loop enabled", LOG_STR_ARG(this->get_component_log_str())); - this->set_component_state_(COMPONENT_STATE_LOOP); - App.enable_component_loop_(this); - } +void Component::enable_loop_slow_path_() { + ESP_LOGVV(TAG, "%s loop enabled", LOG_STR_ARG(this->get_component_log_str())); + this->set_component_state_(COMPONENT_STATE_LOOP); + App.enable_component_loop_(this); } void IRAM_ATTR HOT Component::enable_loop_soon_any_context() { // This method is thread and ISR-safe because: diff --git a/esphome/core/component.h b/esphome/core/component.h index f091f9434c..e2b7aa85d3 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -242,7 +242,10 @@ class Component { * @note Components should call this->enable_loop() on themselves, not on other components. * This ensures the component's state is properly updated along with the loop partition. */ - void enable_loop(); + void enable_loop() { + if ((this->component_state_ & COMPONENT_STATE_MASK) == COMPONENT_STATE_LOOP_DONE) + this->enable_loop_slow_path_(); + } /** Thread and ISR-safe version of enable_loop() that can be called from any context. * @@ -344,6 +347,8 @@ class Component { virtual void call_setup(); void call_dump_config_(); + void enable_loop_slow_path_(); + /// Helper to set component state (clears state bits and sets new state) inline void set_component_state_(uint8_t state) { this->component_state_ &= ~COMPONENT_STATE_MASK; diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 23e65f55bc..faa8c6d4b0 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -123,6 +123,7 @@ #define USE_NEXTION_MAX_COMMANDS_PER_LOOP #define USE_NEXTION_MAX_QUEUE_SIZE #define USE_NEXTION_TFT_UPLOAD +#define USE_NEXTION_WAVEFORM #define USE_NUMBER #define USE_OUTPUT #define USE_POWER_SUPPLY diff --git a/esphome/core/finite_set_mask.h b/esphome/core/finite_set_mask.h index f9cd0377c7..616c69353d 100644 --- a/esphome/core/finite_set_mask.h +++ b/esphome/core/finite_set_mask.h @@ -119,7 +119,7 @@ template(mask)); + } else if constexpr (sizeof(bitmask_t) <= sizeof(uint32_t)) { + bit = __builtin_ctzl(static_cast(mask)); + } else { + bit = __builtin_ctzll(static_cast(mask)); + } + return bit < BitPolicy::MAX_BITS ? bit : BitPolicy::MAX_BITS; +#else + int bit = 0; while (bit < BitPolicy::MAX_BITS && !(mask & (static_cast(1) << bit))) { ++bit; } return bit; +#endif } protected: diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 462af5d1e7..1e40fef2dc 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -3,6 +3,8 @@ dependencies: version: "7.4.2" esphome/esp-audio-libs: version: 2.0.4 + esphome/micro-flac: + version: 0.1.1 esphome/micro-opus: version: 0.3.6 espressif/esp-dsp: diff --git a/requirements.txt b/requirements.txt index dd20600097..9c63cdee27 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,9 +10,9 @@ tzdata>=2021.1 # from time pyserial==3.5 platformio==6.1.19 esptool==5.2.0 -click==8.3.1 +click==8.3.2 esphome-dashboard==20260210.0 -aioesphomeapi==44.8.1 +aioesphomeapi==44.9.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.19.1 # dashboard_import diff --git a/requirements_test.txt b/requirements_test.txt index 3b277e214d..a191378dd7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.5 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.15.8 # also change in .pre-commit-config.yaml when updating +ruff==0.15.9 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit diff --git a/script/run-in-env.py b/script/run-in-env.py index 886e65db27..9283ba9940 100755 --- a/script/run-in-env.py +++ b/script/run-in-env.py @@ -44,7 +44,8 @@ def find_and_activate_virtualenv(): def run_command(): # Execute the remaining arguments in the new environment if len(sys.argv) > 1: - subprocess.run(sys.argv[1:], check=False, close_fds=False) + result = subprocess.run(sys.argv[1:], check=False, close_fds=False) + sys.exit(result.returncode) else: print( "No command provided to run in the virtual environment.", diff --git a/tests/benchmarks/components/button/__init__.py b/tests/benchmarks/components/button/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/button/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/button/bench_button.cpp b/tests/benchmarks/components/button/bench_button.cpp new file mode 100644 index 0000000000..82f76961c9 --- /dev/null +++ b/tests/benchmarks/components/button/bench_button.cpp @@ -0,0 +1,55 @@ +#include + +#include "esphome/components/button/button.h" + +namespace esphome::button::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// Minimal Button for benchmarking — press_action() is a no-op. +class BenchButton : public Button { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + protected: + void press_action() override {} +}; + +// --- Button::press() --- +// Measures: ESP_LOGD + press_action() + callback dispatch. + +static void ButtonPress(benchmark::State &state) { + BenchButton button; + button.configure("test_button"); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + button.press(); + } + benchmark::DoNotOptimize(&button); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ButtonPress); + +// --- Button::press() with callback --- +// Measures callback dispatch overhead. + +static void ButtonPress_WithCallback(benchmark::State &state) { + BenchButton button; + button.configure("test_button"); + + uint64_t callback_count = 0; + button.add_on_press_callback([&callback_count]() { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + button.press(); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(ButtonPress_WithCallback); + +} // namespace esphome::button::benchmarks diff --git a/tests/benchmarks/components/button/benchmark.yaml b/tests/benchmarks/components/button/benchmark.yaml new file mode 100644 index 0000000000..75f089f793 --- /dev/null +++ b/tests/benchmarks/components/button/benchmark.yaml @@ -0,0 +1 @@ +button: diff --git a/tests/benchmarks/components/number/__init__.py b/tests/benchmarks/components/number/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/number/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/number/bench_number.cpp b/tests/benchmarks/components/number/bench_number.cpp new file mode 100644 index 0000000000..57a73930b2 --- /dev/null +++ b/tests/benchmarks/components/number/bench_number.cpp @@ -0,0 +1,121 @@ +#include + +#include "esphome/components/number/number.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Number for benchmarking — control() publishes the value back. +class BenchNumber : public number::Number { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + protected: + void control(float value) override { this->publish_state(value); } +}; + +// Helper to create a typical number entity for benchmarks. +static void setup_number(BenchNumber &number) { + number.configure("test_number"); + number.traits.set_min_value(0.0f); + number.traits.set_max_value(100.0f); + number.traits.set_step(1.0f); + number.traits.set_mode(number::NUMBER_MODE_SLIDER); +} + +// --- Number::publish_state() --- +// Measures the publish path: set_has_state, store value, callback dispatch. + +static void NumberPublish_State(benchmark::State &state) { + BenchNumber number; + setup_number(number); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + number.publish_state(static_cast(i % 100)); + } + benchmark::DoNotOptimize(number.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberPublish_State); + +// --- Number::publish_state() with callback --- +// Measures callback dispatch overhead. + +static void NumberPublish_WithCallback(benchmark::State &state) { + BenchNumber number; + setup_number(number); + + uint64_t callback_count = 0; + number.add_on_state_callback([&callback_count](float) { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + number.publish_state(static_cast(i % 100)); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberPublish_WithCallback); + +// --- NumberCall::perform() set value --- +// The most common number call — setting an absolute value. +// Exercises: validation against min/max, control() dispatch. + +static void NumberCall_SetValue(benchmark::State &state) { + BenchNumber number; + setup_number(number); + number.publish_state(50.0f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + float val = static_cast(i % 100); + number.make_call().set_value(val).perform(); + } + benchmark::DoNotOptimize(number.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberCall_SetValue); + +// --- NumberCall::perform() increment --- +// Exercises: state read, step arithmetic, max clamping. + +static void NumberCall_Increment(benchmark::State &state) { + BenchNumber number; + setup_number(number); + number.publish_state(0.0f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + number.make_call().number_increment(true).perform(); + } + benchmark::DoNotOptimize(number.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberCall_Increment); + +// --- NumberCall::perform() decrement --- +// Exercises: state read, step arithmetic, min clamping. + +static void NumberCall_Decrement(benchmark::State &state) { + BenchNumber number; + setup_number(number); + number.publish_state(100.0f); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + number.make_call().number_decrement(true).perform(); + } + benchmark::DoNotOptimize(number.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(NumberCall_Decrement); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/number/benchmark.yaml b/tests/benchmarks/components/number/benchmark.yaml new file mode 100644 index 0000000000..f435661270 --- /dev/null +++ b/tests/benchmarks/components/number/benchmark.yaml @@ -0,0 +1 @@ +number: diff --git a/tests/benchmarks/components/select/__init__.py b/tests/benchmarks/components/select/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/select/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/select/bench_select.cpp b/tests/benchmarks/components/select/bench_select.cpp new file mode 100644 index 0000000000..8e047d9151 --- /dev/null +++ b/tests/benchmarks/components/select/bench_select.cpp @@ -0,0 +1,157 @@ +#include + +#include "esphome/components/select/select.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Select for benchmarking — control() publishes directly by index. +class BenchSelect : public select::Select { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + protected: + void control(size_t index) override { this->publish_state(index); } +}; + +// Helper to create a select with the given options. +static void setup_select(BenchSelect &select, const char *name, std::initializer_list options) { + select.configure(name); + select.traits.set_options(options); + select.publish_state(size_t(0)); +} + +// --- Select::publish_state(size_t) --- +// The fast path: publish by index, no string lookup. + +static void SelectPublish_ByIndex(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.publish_state(static_cast(i % 4)); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectPublish_ByIndex); + +// --- Select::publish_state(const char *) --- +// The string path: requires index_of() lookup via strncmp. + +static void SelectPublish_ByString(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + const char *options[] = {"off", "still", "move", "still+move"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.publish_state(options[i % 4]); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectPublish_ByString); + +// --- Select::publish_state() with callback --- +// Measures callback dispatch overhead on the index path. + +static void SelectPublish_WithCallback(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + uint64_t callback_count = 0; + select.add_on_state_callback([&callback_count](size_t) { callback_count++; }); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.publish_state(static_cast(i % 4)); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectPublish_WithCallback); + +// --- SelectCall::perform() set by index --- +// The fast call path — no string matching needed. + +static void SelectCall_SetByIndex(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.make_call().set_index(i % 4).perform(); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectCall_SetByIndex); + +// --- SelectCall::perform() set by option string --- +// Exercises the string lookup path through index_of(). + +static void SelectCall_SetByOption(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + const char *options[] = {"off", "still", "move", "still+move"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.make_call().set_option(options[i % 4]).perform(); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectCall_SetByOption); + +// --- SelectCall::perform() next with cycling --- +// Exercises the navigation path through active_index_. + +static void SelectCall_NextCycle(benchmark::State &state) { + BenchSelect select; + setup_select(select, "test_select", {"off", "still", "move", "still+move"}); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.make_call().select_next(true).perform(); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectCall_NextCycle); + +// --- SelectCall with 10 options (string lookup) --- +// Worst-case string matching with more options. + +static void SelectCall_SetByOption_10Options(benchmark::State &state) { + BenchSelect select; + setup_select( + select, "test_select", + {"off", "still", "move", "still+move", "custom1", "custom2", "custom3", "custom4", "custom5", "custom6"}); + + // Pick options spread across the list to exercise different search depths + const char *picks[] = {"off", "custom3", "custom6", "move"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + select.make_call().set_option(picks[i % 4]).perform(); + } + benchmark::DoNotOptimize(select.active_index()); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SelectCall_SetByOption_10Options); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/select/benchmark.yaml b/tests/benchmarks/components/select/benchmark.yaml new file mode 100644 index 0000000000..d336a348a0 --- /dev/null +++ b/tests/benchmarks/components/select/benchmark.yaml @@ -0,0 +1 @@ +select: diff --git a/tests/benchmarks/components/switch/__init__.py b/tests/benchmarks/components/switch/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/switch/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/switch/bench_switch.cpp b/tests/benchmarks/components/switch/bench_switch.cpp new file mode 100644 index 0000000000..d948f080ad --- /dev/null +++ b/tests/benchmarks/components/switch/bench_switch.cpp @@ -0,0 +1,137 @@ +#include + +#include "esphome/components/switch/switch.h" + +namespace esphome::benchmarks { + +// Inner iteration count to amortize CodSpeed instrumentation overhead. +static constexpr int kInnerIterations = 2000; + +// Minimal Switch for benchmarking — write_state() publishes directly. +class BenchSwitch : public switch_::Switch { + public: + void configure(const char *name) { this->configure_entity_(name, 0x12345678, 0); } + + protected: + void write_state(bool state) override { this->publish_state(state); } +}; + +// --- Switch::publish_state() alternating --- +// Forces state change every call, exercising the full publish path. + +static void SwitchPublish_Alternating(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.publish_state(i % 2 == 0); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchPublish_Alternating); + +// --- Switch::publish_state() no change --- +// Tests the deduplication fast path in publish_dedup_. + +static void SwitchPublish_NoChange(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.publish_state(true); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.publish_state(true); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchPublish_NoChange); + +// --- Switch::publish_state() with callback --- +// Measures callback dispatch overhead on state changes. + +static void SwitchPublish_WithCallback(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + + uint64_t callback_count = 0; + sw.add_on_state_callback([&callback_count](bool) { callback_count++; }); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.publish_state(i % 2 == 0); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchPublish_WithCallback); + +// --- Switch::turn_on() / turn_off() --- +// The front-end call path: turn_on → write_state → publish_state. + +static void SwitchTurnOn(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.turn_on(); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchTurnOn); + +// --- Switch::toggle() alternating --- +// Exercises the toggle path which reads current state to determine target. + +static void SwitchToggle(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.toggle(); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchToggle); + +// --- Switch::publish_state() inverted --- +// Verifies the inversion path doesn't add significant overhead. + +static void SwitchPublish_Inverted(benchmark::State &state) { + BenchSwitch sw; + sw.configure("test_switch"); + sw.set_restore_mode(switch_::SWITCH_ALWAYS_OFF); + sw.set_inverted(true); + sw.publish_state(false); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sw.publish_state(i % 2 == 0); + } + benchmark::DoNotOptimize(sw.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(SwitchPublish_Inverted); + +} // namespace esphome::benchmarks diff --git a/tests/benchmarks/components/switch/benchmark.yaml b/tests/benchmarks/components/switch/benchmark.yaml new file mode 100644 index 0000000000..c637b3dc89 --- /dev/null +++ b/tests/benchmarks/components/switch/benchmark.yaml @@ -0,0 +1 @@ +switch: diff --git a/tests/benchmarks/components/text_sensor/__init__.py b/tests/benchmarks/components/text_sensor/__init__.py new file mode 100644 index 0000000000..b08f67a095 --- /dev/null +++ b/tests/benchmarks/components/text_sensor/__init__.py @@ -0,0 +1,5 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.enable_codegen() diff --git a/tests/benchmarks/components/text_sensor/bench_text_sensor.cpp b/tests/benchmarks/components/text_sensor/bench_text_sensor.cpp new file mode 100644 index 0000000000..0ac88f79c1 --- /dev/null +++ b/tests/benchmarks/components/text_sensor/bench_text_sensor.cpp @@ -0,0 +1,108 @@ +#include + +#include "esphome/components/text_sensor/text_sensor.h" + +namespace esphome::text_sensor::benchmarks { + +static constexpr int kInnerIterations = 2000; + +// --- publish_state(const char *) with short string, value changes each time --- +// Exercises: memcmp check (mismatch), string assign, callback dispatch. + +static void TextSensorPublish_Short_Changing(benchmark::State &state) { + TextSensor sensor; + + // Pre-populate with different short strings + const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state(values[i % 4]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_Short_Changing); + +// --- publish_state(const char *) with short string, same value (dedup path) --- +// Exercises: memcmp check (match), skips string assign. + +static void TextSensorPublish_Short_NoChange(benchmark::State &state) { + TextSensor sensor; + sensor.publish_state("192.168.1.100"); + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state("192.168.1.100"); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_Short_NoChange); + +// --- publish_state with longer string (firmware version, MAC address) --- +// Exercises: memcmp on longer strings, string assign with potential realloc. + +static void TextSensorPublish_Long_Changing(benchmark::State &state) { + TextSensor sensor; + + const char *values[] = { + "2025.12.0-dev (Jan 15 2025, 10:30:00)", + "2025.12.1-dev (Feb 20 2025, 14:45:00)", + "2025.12.2-dev (Mar 10 2025, 08:15:00)", + "2025.12.3-dev (Apr 5 2025, 16:00:00)", + }; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state(values[i % 4]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_Long_Changing); + +// --- publish_state with callback --- +// Measures callback dispatch overhead for text sensors. + +static void TextSensorPublish_WithCallback(benchmark::State &state) { + TextSensor sensor; + + uint64_t callback_count = 0; + sensor.add_on_state_callback([&callback_count](const std::string &) { callback_count++; }); + + const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state(values[i % 4]); + } + benchmark::DoNotOptimize(callback_count); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_WithCallback); + +// --- publish_state(const char *, size_t) direct --- +// The lowest-level overload, avoids strlen. + +static void TextSensorPublish_WithLen(benchmark::State &state) { + TextSensor sensor; + + static constexpr const char *values[] = {"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4"}; + static constexpr size_t lens[] = {11, 11, 11, 11}; + + for (auto _ : state) { + for (int i = 0; i < kInnerIterations; i++) { + sensor.publish_state(values[i % 4], lens[i % 4]); + } + benchmark::DoNotOptimize(sensor.state); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(TextSensorPublish_WithLen); + +} // namespace esphome::text_sensor::benchmarks diff --git a/tests/benchmarks/components/text_sensor/benchmark.yaml b/tests/benchmarks/components/text_sensor/benchmark.yaml new file mode 100644 index 0000000000..7bcb7de1c8 --- /dev/null +++ b/tests/benchmarks/components/text_sensor/benchmark.yaml @@ -0,0 +1 @@ +text_sensor: diff --git a/tests/benchmarks/core/bench_scheduler.cpp b/tests/benchmarks/core/bench_scheduler.cpp index 9357734cc8..214fe0e4b8 100644 --- a/tests/benchmarks/core/bench_scheduler.cpp +++ b/tests/benchmarks/core/bench_scheduler.cpp @@ -8,7 +8,24 @@ namespace esphome::benchmarks { // Inner iteration count to amortize CodSpeed instrumentation overhead. // Without this, the ~60ns per-iteration valgrind start/stop cost dominates // sub-microsecond benchmarks. -static constexpr int kInnerIterations = 2000; +// Must be divisible by all batch sizes used below (3, 10) to avoid +// pool imbalance at iteration boundaries that causes spurious malloc. +static constexpr int kInnerIterations = 2100; + +// Warm the scheduler pool by registering and replacing items twice. +// The first batch allocates fresh items; the second batch cancels them and +// populates the recycling pool with the cancelled items from the first batch. +static void warm_pool(Scheduler &scheduler, Component *component, int batch_size, uint32_t delay) { + uint32_t now = millis(); + for (int i = 0; i < batch_size; i++) { + scheduler.set_timeout(component, static_cast(i), delay, []() {}); + } + scheduler.call(++now); + for (int i = 0; i < batch_size; i++) { + scheduler.set_timeout(component, static_cast(i), delay, []() {}); + } + scheduler.call(++now); +} // --- Scheduler fast path: no work to do --- @@ -83,11 +100,21 @@ static void Scheduler_SetTimeout(benchmark::State &state) { Scheduler scheduler; Component dummy_component; + // Register 3 timeouts then call() — realistic worst case where multiple + // components schedule in the same loop iteration. Keeps item count within + // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % 5), 1000, []() {}); + scheduler.set_timeout(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } } - scheduler.process_to_add(); + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -99,22 +126,22 @@ BENCHMARK(Scheduler_SetTimeout); static void Scheduler_SetInterval(benchmark::State &state) { Scheduler scheduler; Component dummy_component; - // Number of distinct interval keys; controls how many unique timers exist - // simultaneously and the drain cadence for process_to_add(). - static constexpr int kKeyCount = 5; + // Register 3 intervals then call() — realistic worst case where multiple + // components schedule in the same loop iteration. Keeps item count within + // the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_interval(&dummy_component, static_cast(i % kKeyCount), 1000, []() {}); - // Drain to_add_ periodically to reflect production behavior where - // process_to_add() runs each main loop iteration. Without this, - // cancelled items accumulate in to_add_ causing O(n²) scan cost. - if ((i + 1) % kKeyCount == 0) { - scheduler.process_to_add(); + scheduler.set_interval(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); } } - // Final drain in case kInnerIterations is not a multiple of 5 - scheduler.process_to_add(); + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); @@ -128,16 +155,79 @@ static void Scheduler_Defer(benchmark::State &state) { Component dummy_component; // defer() is Component::defer which calls set_timeout(delay=0). - // Call set_timeout directly since defer() is protected. + // Component::defer(func) passes nullptr as the name, which skips + // cancel_item_locked_ entirely — matching production behavior where + // defers are anonymous fire-and-forget callbacks. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 0); for (auto _ : state) { + uint32_t now = millis(); for (int i = 0; i < kInnerIterations; i++) { - scheduler.set_timeout(&dummy_component, static_cast(i % 5), 0, []() {}); + scheduler.set_timeout(&dummy_component, static_cast(nullptr), 0, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } } - scheduler.process_to_add(); + scheduler.call(++now); benchmark::DoNotOptimize(scheduler); } state.SetItemsProcessed(state.iterations() * kInnerIterations); } BENCHMARK(Scheduler_Defer); +// --- Scheduler: defer with same ID (cancel-and-replace pattern) --- + +static void Scheduler_Defer_SameID(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // Measures defer with a fixed numeric ID — each call cancels the previous + // pending defer before adding the new one. This is the pattern used by + // components that defer work but want to coalesce rapid updates. + static constexpr int kBatchSize = 3; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 0); + for (auto _ : state) { + uint32_t now = millis(); + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, static_cast(0), 0, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } + } + scheduler.call(++now); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_Defer_SameID); + +// --- Scheduler: set_timeout with batch size exceeding pool (cliff test) --- + +static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) { + Scheduler scheduler; + Component dummy_component; + + // Register 10 timeouts then call() — exceeds MAX_POOL_SIZE=5 to measure + // the performance cliff when the recycling pool is exhausted and items + // must be malloc'd/freed. + static constexpr int kBatchSize = 10; + static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize"); + warm_pool(scheduler, &dummy_component, kBatchSize, 1000); + for (auto _ : state) { + uint32_t now = millis(); + for (int i = 0; i < kInnerIterations; i++) { + scheduler.set_timeout(&dummy_component, static_cast(i % kBatchSize), 1000, []() {}); + if ((i + 1) % kBatchSize == 0) { + scheduler.call(++now); + } + } + scheduler.call(++now); + benchmark::DoNotOptimize(scheduler); + } + state.SetItemsProcessed(state.iterations() * kInnerIterations); +} +BENCHMARK(Scheduler_SetTimeout_ExceedPool); + } // namespace esphome::benchmarks diff --git a/tests/component_tests/display/__init__.py b/tests/component_tests/display/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/display/test_display_metadata.py b/tests/component_tests/display/test_display_metadata.py new file mode 100644 index 0000000000..e569754494 --- /dev/null +++ b/tests/component_tests/display/test_display_metadata.py @@ -0,0 +1,81 @@ +"""Tests for display component metadata functions.""" + +from unittest.mock import patch + +from esphome.components.display import ( + DisplayMetaData, + add_metadata, + get_all_display_metadata, + get_display_metadata, +) +from esphome.cpp_generator import MockObj + + +def test_add_metadata_with_string_id(): + """Test adding metadata with a plain string ID.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata("my_display", 320, 240, True) + meta = get_display_metadata("my_display") + assert meta == DisplayMetaData( + width=320, height=240, has_writer=True, has_hardware_rotation=False + ) + + +def test_add_metadata_with_mockobj_id(): + """Test adding metadata with a MockObj ID (converted via str()).""" + with patch("esphome.components.display.CORE.data", {}): + mock_id = MockObj("my_display_obj") + add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True) + meta = get_display_metadata("my_display_obj") + assert meta == DisplayMetaData( + width=480, height=320, has_writer=False, has_hardware_rotation=True + ) + + +def test_add_metadata_hardware_rotation_default(): + """Test that has_hardware_rotation defaults to False.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata("disp", 128, 64, False) + meta = get_display_metadata("disp") + assert meta.has_hardware_rotation is False + + +def test_get_display_metadata_missing_returns_none(): + """Test that querying a non-existent ID returns None.""" + with patch("esphome.components.display.CORE.data", {}): + data = get_display_metadata("no_such_display") + assert data.width == 0 + assert data.height == 0 + assert data.has_writer is False + assert data.has_hardware_rotation is False + + +def test_add_multiple_displays(): + """Test adding metadata for multiple displays.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata("disp_a", 320, 240, True) + add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True) + + all_meta = get_all_display_metadata() + assert len(all_meta) == 2 + assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False) + assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True) + + +def test_add_metadata_overwrites_existing(): + """Test that adding metadata for the same ID overwrites the previous entry.""" + with patch("esphome.components.display.CORE.data", {}): + add_metadata("disp", 320, 240, True) + add_metadata("disp", 640, 480, False, has_hardware_rotation=True) + meta = get_display_metadata("disp") + assert meta == DisplayMetaData(640, 480, False, True) + + +def test_metadata_is_frozen(): + """Test that DisplayMetaData instances are immutable (frozen dataclass).""" + meta = DisplayMetaData(320, 240, True, False) + try: + meta.width = 640 + assert False, "Expected FrozenInstanceError" + except AttributeError: + pass diff --git a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py index 1ae8cc644e..119bbf7fea 100644 --- a/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py +++ b/tests/component_tests/mipi_dsi/test_mipi_dsi_config.py @@ -133,6 +133,6 @@ def test_code_generation( assert "set_init_sequence({224, 1, 0, 225, 1, 147, 226, 1," in main_cpp assert "p4_nano->set_lane_bit_rate(1500.0f);" in main_cpp assert "p4_nano->set_rotation(display::DISPLAY_ROTATION_90_DEGREES);" in main_cpp - assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" in main_cpp + assert "p4_86->set_rotation(display::DISPLAY_ROTATION_0_DEGREES);" not in main_cpp assert "custom_id->set_rotation(display::DISPLAY_ROTATION_180_DEGREES);" in main_cpp # assert "backlight_id = new light::LightState(mipi_dsi_dsibacklight_id);" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_display_metadata.py b/tests/component_tests/mipi_spi/test_display_metadata.py new file mode 100644 index 0000000000..ab42a75694 --- /dev/null +++ b/tests/component_tests/mipi_spi/test_display_metadata.py @@ -0,0 +1,200 @@ +"""Tests for display metadata created by mipi_spi component.""" + +from collections.abc import Callable +from pathlib import Path + +from esphome.components.display import ( + DisplayMetaData, + get_all_display_metadata, + get_display_metadata, +) +from esphome.components.esp32 import ( + KEY_BOARD, + KEY_VARIANT, + VARIANT_ESP32, + VARIANT_ESP32S3, +) +from esphome.components.mipi_spi.display import ( + CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA, + get_instance, +) +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + + +def validated_config(config): + """Run schema + final validation and return the validated config.""" + return FINAL_VALIDATE_SCHEMA(CONFIG_SCHEMA(config)) + + +def test_metadata_native_quad_default_test_card( + set_core_config: SetCoreConfigCallable, +) -> None: + """A quad-mode display with no explicit drawing gets a test card from final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + config = validated_config({"model": "JC3636W518"}) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.width == 360 + assert meta.height == 360 + # final validation auto-enables show_test_card when no drawing methods are configured + assert meta.has_writer is True + assert meta.has_hardware_rotation is True + + +def test_metadata_single_mode_with_dc_pin( + set_core_config: SetCoreConfigCallable, +) -> None: + """A single-mode display with no explicit drawing gets a test card from final validation.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = validated_config( + { + "model": "ST7735", + "dc_pin": 18, + } + ) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.width == 128 + assert meta.height == 160 + assert meta.has_writer is True + assert meta.has_hardware_rotation is True + + +def test_metadata_custom_dimensions( + set_core_config: SetCoreConfigCallable, +) -> None: + """A custom model picks up explicit dimensions.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 480, "height": 320}, + "init_sequence": [[0xA0, 0x01]], + } + ) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.width == 480 + assert meta.height == 320 + # final validation auto-enables show_test_card + assert meta.has_writer is True + assert meta.has_hardware_rotation is True + + +def test_metadata_with_test_card_has_writer( + set_core_config: SetCoreConfigCallable, +) -> None: + """When show_test_card is enabled, has_writer should be True.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config = validated_config( + { + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 240, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + "show_test_card": True, + } + ) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.has_writer is True + + +def test_metadata_no_swap_xy_not_full_hardware_rotation( + set_core_config: SetCoreConfigCallable, +) -> None: + """A model that disables swap_xy should report has_hardware_rotation=False.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, + ) + # JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only + config = validated_config({"model": "JC3248W535"}) + get_instance(config) + meta = get_display_metadata(str(config["id"])) + assert meta is not None + assert meta.has_hardware_rotation is False + + +def test_metadata_multiple_displays_independent( + set_core_config: SetCoreConfigCallable, +) -> None: + """Multiple displays each get their own metadata entry.""" + set_core_config( + PlatformFramework.ESP32_IDF, + platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}, + ) + config_a = validated_config( + { + "id": "disp_a", + "model": "custom", + "dc_pin": 18, + "dimensions": {"width": 320, "height": 240}, + "init_sequence": [[0xA0, 0x01]], + } + ) + config_b = validated_config( + { + "id": "disp_b", + "model": "custom", + "dc_pin": 19, + "dimensions": {"width": 128, "height": 64}, + "init_sequence": [[0xA0, 0x01]], + } + ) + get_instance(config_a) + get_instance(config_b) + + all_meta = get_all_display_metadata() + # final validation auto-enables show_test_card for both + assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True) + assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True) + + +def test_metadata_via_code_generation_native( + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], +) -> None: + """Full code generation for native.yaml should produce correct metadata.""" + generate_main(component_fixture_path("native.yaml")) + all_meta = get_all_display_metadata() + # native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + assert meta == DisplayMetaData( + width=360, height=360, has_writer=True, has_hardware_rotation=True + ) + + +def test_metadata_via_code_generation_lvgl( + generate_main: Callable[[str | Path], str], + component_fixture_path: Callable[[str], Path], +) -> None: + """Full code generation for lvgl.yaml should produce correct metadata.""" + generate_main(component_fixture_path("lvgl.yaml")) + all_meta = get_all_display_metadata() + # lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation + assert len(all_meta) == 1 + meta = next(iter(all_meta.values())) + assert meta == DisplayMetaData( + width=128, height=160, has_writer=False, has_hardware_rotation=True + ) diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index bae39d3879..4873892a8d 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -204,11 +204,6 @@ def test_transform_and_init_sequence_errors( r"extra keys not allowed @ data\['brightness'\]", id="brightness_not_supported", ), - pytest.param( - {"model": "T-DISPLAY-S3-PRO"}, - "PSRAM is required for this display", - id="psram_required", - ), ], ) def test_esp32s3_specific_errors( @@ -319,7 +314,7 @@ def test_native_generation( main_cpp = generate_main(component_fixture_path("native.yaml")) assert ( - "mipi_spi::MipiSpiBuffer()" + "mipi_spi::MipiSpiBuffer()" in main_cpp ) assert "set_init_sequence({240, 1, 8, 242" in main_cpp @@ -335,7 +330,7 @@ def test_lvgl_generation( main_cpp = generate_main(component_fixture_path("lvgl.yaml")) assert ( - "mipi_spi::MipiSpi();" + "mipi_spi::MipiSpi();" in main_cpp ) assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp diff --git a/tests/components/dsmr/common.yaml b/tests/components/dsmr/common.yaml index d11ce37d59..962800d7b0 100644 --- a/tests/components/dsmr/common.yaml +++ b/tests/components/dsmr/common.yaml @@ -13,3 +13,4 @@ dsmr: request_pin: ${request_pin} request_interval: 20s receive_timeout: 100ms + thermal_mbus_id: 3 diff --git a/tests/components/htu21d/common.yaml b/tests/components/htu21d/common.yaml index ad4b23d460..126360b775 100644 --- a/tests/components/htu21d/common.yaml +++ b/tests/components/htu21d/common.yaml @@ -1,5 +1,6 @@ sensor: - platform: htu21d + id: htu21d_sensor i2c_id: i2c_bus model: htu21d temperature: @@ -9,3 +10,14 @@ sensor: heater: name: Heater update_interval: 15s + +button: + - platform: template + name: "Test HTU21D Actions" + on_press: + - htu21d.set_heater: + id: htu21d_sensor + status: true + - htu21d.set_heater_level: + id: htu21d_sensor + level: 5 diff --git a/tests/components/media_player/common.yaml b/tests/components/media_player/common.yaml index 89600f70f6..88d04d0ff0 100644 --- a/tests/components/media_player/common.yaml +++ b/tests/components/media_player/common.yaml @@ -61,3 +61,8 @@ media_player: - media_player.volume_up: - media_player.volume_down: - media_player.volume_set: 50% + - media_player.enqueue: http://localhost/media.mp3 + - media_player.enqueue: !lambda 'return "http://localhost/media.mp3";' + - media_player.enqueue: + media_url: http://localhost/media.mp3 + announcement: true diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp new file mode 100644 index 0000000000..e01d9e69ff --- /dev/null +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -0,0 +1,173 @@ +#include "../common.h" + +namespace esphome::mitsubishi_cn105::testing { + +struct TestContext { + MockUARTComponent uart; + uart::UARTDevice device{&uart}; + TestableMitsubishiCN105 sut{device}; + + TestContext() { this->sut.set_current_time(0); } +}; + +TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { + auto ctx = TestContext{}; + + ctx.sut.set_current_time(123); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); + + ctx.sut.initialize(); + + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{123}); +} + +TEST(MitsubishiCN105Tests, SuccessfullyConnects) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.sut.write_timeout_start_ms_.has_value()); + + // Connect response + ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55}); + + ctx.sut.update(); + + // All bytes from UART should be consumed and state = CONNECTED + EXPECT_TRUE(ctx.uart.rx.empty()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTED); + EXPECT_FALSE(ctx.sut.write_timeout_start_ms_.has_value()); + + // Nothing should be send to UART + EXPECT_TRUE(ctx.uart.tx.empty()); +} + +TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // No response (no RX data), no retry yet + ctx.sut.update(); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + + // Still no response after 1999ms, no retry yet + ctx.sut.set_current_time(1999); + ctx.sut.update(); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{0}); + + // Stop waiting after 2s and retry connect + ctx.sut.set_current_time(2000); + ctx.sut.update(); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x5A, 0x01, 0x30, 0x02, 0xCA, 0x01, 0xA8)); + EXPECT_EQ(ctx.sut.write_timeout_start_ms_, std::optional{2000}); +} + +TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // RX noise/unexpected traffic + ctx.uart.push_rx({0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, + 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, + 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, + 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46}); + + // Make sure we have enough bytes in buffer. + ASSERT_GT(ctx.uart.rx.size(), 64); + + // No valid response, no state change expected + ctx.sut.update(); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + + // Watchdog interrupts reading (max. 64 bytes at once) so we do not spend the whole loop draining UART + EXPECT_FALSE(ctx.uart.rx.empty()); + + // Next update will read remaining bytes, no state change expected + ctx.sut.update(); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + EXPECT_TRUE(ctx.uart.rx.empty()); +} + +TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { + auto ctx = TestContext{}; + + ctx.sut.initialize(); + ctx.uart.tx.clear(); // Remove first connect packet bytes + + // Mixed RX stream with partial, malformed, and oversized frames to test parser robustness + ctx.uart.push_rx({// ───────────────────────────── + // Noise (no 0xFC) -> should be ignored via preamble reset + // ──────────────────────────── + 0x01, 0x02, 0x03, 0x04, 0x05, + + // ───────────────────────────── + // Partial frame (declares payload len=5, but we cut it short) + // Later bytes will eventually force checksum mismatch and reset + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x05, 0xAA, 0xBB, + + // ───────────────────────────── + // Invalid header (header byte 3 should be 0x01, header byte 4 should be 0x30) + // Should reset quickly on header mismatch + // ───────────────────────────── + 0xFC, 0x62, 0xFF, 0xFF, 0x02, 0x01, 0x02, 0x00, + + // ───────────────────────────── + // Oversized length field (rejected by payload-too-large check at HEADER_LEN) + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0xFE, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, + 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, + + // ───────────────────────────── + // Valid unknown-type frame (type=0x62), should be parsed successfully then ignored + // Frame: FC 62 01 30 02 AA BB 30 + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x02, 0xAA, 0xBB, 0x30, + + // ───────────────────────────── + // Invalid checksum (should be rejected at checksum check) + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x02, 0x10, 0x20, 0xFF, + + // ───────────────────────────── + // Back-to-back VALID frames (unknown type=0x62) to stress boundary handling. + // Frame A: FC 62 01 30 01 02 6C + // Frame B: FC 62 01 30 01 03 6B + // ───────────────────────────── + 0xFC, 0x62, 0x01, 0x30, 0x01, 0x02, 0x6C, 0xFC, 0x62, 0x01, 0x30, 0x01, 0x03, 0x6B, + + // ───────────────────────────── + // Trailing noise + // ───────────────────────────── + 0x55, 0x66, 0x77, 0x88}); + + // Drain RX - no valid response, no state change expected + int iterations = 0; + while (!ctx.uart.rx.empty() && iterations++ < 10) { + ctx.sut.update(); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::CONNECTING); + EXPECT_TRUE(ctx.uart.tx.empty()); + } + + EXPECT_TRUE(ctx.uart.rx.empty()); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.cpp b/tests/components/mitsubishi_cn105/common.cpp new file mode 100644 index 0000000000..ea13d7676c --- /dev/null +++ b/tests/components/mitsubishi_cn105/common.cpp @@ -0,0 +1,7 @@ +#include "common.h" + +namespace esphome::mitsubishi_cn105 { + +uint32_t get_loop_time_ms() { return testing::TestableMitsubishiCN105::test_loop_time_ms; }; + +} // namespace esphome::mitsubishi_cn105 diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h new file mode 100644 index 0000000000..c41268d723 --- /dev/null +++ b/tests/components/mitsubishi_cn105/common.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" +#include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(std::initializer_list data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + // UARTComponent + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) { + return false; + } + + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t * data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +}; + +class TestableMitsubishiCN105 : public MitsubishiCN105 { + public: + using MitsubishiCN105::MitsubishiCN105; + using MitsubishiCN105::State; + using MitsubishiCN105::state_; + using MitsubishiCN105::write_timeout_start_ms_; + + static inline uint32_t test_loop_time_ms = 0; + + void set_current_time(uint32_t ms) { test_loop_time_ms = ms; } +}; + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/test.esp32-idf.yaml b/tests/components/mitsubishi_cn105/test.esp32-idf.yaml index ac63cf987f..5ce1861902 100644 --- a/tests/components/mitsubishi_cn105/test.esp32-idf.yaml +++ b/tests/components/mitsubishi_cn105/test.esp32-idf.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml b/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml index 9f2f350b46..a3f8cf43d4 100644 --- a/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml +++ b/tests/components/mitsubishi_cn105/test.esp8266-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart_9600_even/esp8266-ard.yaml + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml b/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml index 4363d6eee8..7c1f4f41e2 100644 --- a/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml +++ b/tests/components/mitsubishi_cn105/test.rp2040-ard.yaml @@ -1,4 +1,4 @@ packages: - uart: !include ../../test_build_components/common/uart_9600_even/rp2040-ard.yaml + uart_9600_even: !include ../../test_build_components/common/uart_9600_even/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/time/posix_tz_parser.cpp b/tests/components/time/posix_tz_parser.cpp index b7cf2a4afa..440eea608d 100644 --- a/tests/components/time/posix_tz_parser.cpp +++ b/tests/components/time/posix_tz_parser.cpp @@ -758,6 +758,115 @@ TEST(PosixTzParser, EpochToLocalDstTransition) { EXPECT_EQ(local.tm_isdst, 1); } +// ============================================================================ +// Leap year edge cases for closed-form year arithmetic +// ============================================================================ + +TEST(PosixTzParser, EpochToLocalLeapYear2000) { + // 2000 is a leap year (divisible by 400) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + + // Feb 29, 2000 12:00:00 UTC + time_t epoch = make_utc(2000, 2, 29, 12); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 100); // 2000 + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 29); + EXPECT_EQ(local.tm_hour, 12); +} + +TEST(PosixTzParser, EpochToLocalNonLeapYear2100) { + // 2100 is NOT a leap year (divisible by 100 but not 400) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + + // Mar 1, 2100 00:00:00 UTC — the day after what would be Feb 29 + time_t epoch = make_utc(2100, 3, 1); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); // 2100 + EXPECT_EQ(local.tm_mon, 2); // March + EXPECT_EQ(local.tm_mday, 1); + + // Feb 28, 2100 23:59:59 UTC — last second of February (no Feb 29) + epoch = make_utc(2100, 2, 28, 23, 59, 59); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 28); +} + +TEST(PosixTzParser, EpochToLocalLeapYear2400) { + // 2400 is a leap year (divisible by 400) + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + + time_t epoch = make_utc(2400, 2, 29, 6); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 500); // 2400 + EXPECT_EQ(local.tm_mon, 1); // February + EXPECT_EQ(local.tm_mday, 29); + EXPECT_EQ(local.tm_hour, 6); +} + +TEST(PosixTzParser, EpochToLocalNewYearBoundaries) { + // Test year boundary — last second of 2099 and first second of 2100 + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + struct tm local; + + // Dec 31, 2099 23:59:59 UTC + time_t epoch = make_utc(2099, 12, 31, 23, 59, 59); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 199); // 2099 + EXPECT_EQ(local.tm_mon, 11); // December + EXPECT_EQ(local.tm_mday, 31); + + // Jan 1, 2100 00:00:00 UTC + epoch = make_utc(2100, 1, 1); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 200); // 2100 + EXPECT_EQ(local.tm_mon, 0); // January + EXPECT_EQ(local.tm_mday, 1); +} + +TEST(PosixTzParser, EpochToLocalDstAcrossCenturyBoundary) { + // DST transition in year 2100 (non-leap) with US Eastern rules + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("EST5EDT,M3.2.0/2,M11.1.0/2", tz)); + + // July 4, 2100 16:00 UTC = 12:00 EDT + time_t epoch = make_utc(2100, 7, 4, 16); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 12); + EXPECT_EQ(local.tm_isdst, 1); + + // Jan 15, 2100 10:00 UTC = 05:00 EST + epoch = make_utc(2100, 1, 15, 10); + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_hour, 5); + EXPECT_EQ(local.tm_isdst, 0); +} + +TEST(PosixTzParser, EpochToLocalFarFutureYear5000) { + // Year 5000 — days/365 estimate overshoots by ~2 years due to leap days, + // requiring multiple correction steps in days_to_year. + ParsedTimezone tz; + ASSERT_TRUE(parse_posix_tz("UTC0", tz)); + + time_t epoch = make_utc(5000, 6, 15, 12); + struct tm local; + ASSERT_TRUE(epoch_to_local_tm(epoch, tz, &local)); + EXPECT_EQ(local.tm_year, 3100); // 5000 + EXPECT_EQ(local.tm_mon, 5); // June + EXPECT_EQ(local.tm_mday, 15); + EXPECT_EQ(local.tm_hour, 12); +} + // ============================================================================ // Verification against libc // ============================================================================ diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index b652b4174c..7c85bf753c 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -198,6 +198,13 @@ async def yaml_config(request: pytest.FixtureRequest, unused_tcp_port: int) -> s ' - "-g" # Add debug symbols', ) + # Replace external component path placeholder if present + if "EXTERNAL_COMPONENT_PATH" in content: + external_components_path = str( + Path(__file__).parent / "fixtures" / "external_components" + ) + content = content.replace("EXTERNAL_COMPONENT_PATH", external_components_path) + return content diff --git a/tests/integration/fixtures/uart_mock_modbus.yaml b/tests/integration/fixtures/uart_mock_modbus.yaml index 3ff7ab01bd..da36da4de1 100644 --- a/tests/integration/fixtures/uart_mock_modbus.yaml +++ b/tests/integration/fixtures/uart_mock_modbus.yaml @@ -22,6 +22,8 @@ uart_mock: baud_rate: 9600 rx_full_threshold: 120 rx_timeout: 2 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: responses: @@ -46,7 +48,7 @@ modbus: modbus_controller: - address: 1 id: modbus_controller_ok - max_cmd_retries: 0 + max_cmd_retries: 2 update_interval: 1s - address: 2 id: modbus_controller_slow @@ -89,4 +91,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml index e3e8c8c8da..9bc4dc50e9 100644 --- a/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_no_threshold.yaml @@ -22,6 +22,8 @@ uart: uart_mock: - id: virtual_uart_dev baud_rate: 9600 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: on_tx: @@ -40,7 +42,8 @@ uart_mock: 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}; - uart_mock.inject_rx: # Second USB packet: rest of response (staged with 40ms latency) delay: 40ms - data: !lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + data: + !lambda return{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x42,0x6F,0xCC,0xCD,0x43,0x7C,0xB8,0x10,0x3D,0x38,0x51,0xEC, 0x43,0x81,0x1B,0xE7,0x3B,0x03,0x12,0x6F,0x50,0x1B}; @@ -61,4 +64,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server.yaml b/tests/integration/fixtures/uart_mock_modbus_server.yaml new file mode 100644 index 0000000000..b657a6fd21 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server.yaml @@ -0,0 +1,124 @@ +esphome: + name: uart-mock-modbus-server-test + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_dev + baud_rate: 9600 + rx_full_threshold: 120 + rx_timeout: 2 + auto_start: false + debug: + injections: + - delay: 100ms + inject_rx: [0x01, 0x03, 0x00, 0x03, 0x00, 0x01, 0x74, 0x0A] # Read holding register 3 on device 1 (basic_read) + - delay: 100ms + # Read holding register 7 on device 2 + # Reply from device 2 + # Read holding register 5 on device 1 (read_after_peer_response) + inject_rx: + [ + 0x02, + 0x03, + 0x00, + 0x07, + 0x00, + 0x01, + 0x35, + 0xF8, + 0x02, + 0x03, + 0x02, + 0x00, + 0xF0, + 0xFC, + 0x00, + 0x01, + 0x03, + 0x00, + 0x05, + 0x00, + 0x01, + 0x94, + 0x0B, + ] + - delay: 100ms + inject_rx: [0x02, 0x03, 0x00, 0x07, 0x00, 0x01, 0x35, 0xF8] # Read holding register 7 on device 2, with no response + - delay: 100ms + # Read holding register 7 on device 2, with no response + # Read holding register A on device 1 (read_after_peer_timeout) + inject_rx: + [ + 0x02, + 0x03, + 0x00, + 0x07, + 0x00, + 0x01, + 0x35, + 0xF8, + 0x01, + 0x03, + 0x00, + 0x0A, + 0x00, + 0x01, + 0xA4, + 0x08, + ] + +modbus: + uart_id: virtual_uart_dev + role: server + +modbus_controller: + - address: 1 + server_registers: + - address: 0x03 + value_type: U_WORD + read_lambda: |- + id(basic_read).publish_state(1); + return 1; + - address: 0x05 + value_type: U_WORD + read_lambda: |- + id(read_after_peer_response).publish_state(1); + return 1; + - address: 0x0A + value_type: U_WORD + read_lambda: |- + id(read_after_peer_timeout).publish_state(1); + return 1; + +sensor: + - platform: template + name: "basic_read" + id: basic_read + - platform: template + name: "read_after_peer_response" + id: read_after_peer_response + - platform: template + name: "read_after_peer_timeout" + id: read_after_peer_timeout + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml new file mode 100644 index 0000000000..f0f2c56a36 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller.yaml @@ -0,0 +1,180 @@ +esphome: + name: uart-mock-modbus-server-contro + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 1s + id: modbus_controller_1 + + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 99; + - address: 0x03 + value_type: S_WORD + read_lambda: return -99; + - address: 0x05 + value_type: U_DWORD + read_lambda: return 16909060; + - address: 0x08 + value_type: S_DWORD + read_lambda: return -16909060; + - address: 0x0B + value_type: U_DWORD_R + read_lambda: return 67305985; + - address: 0x0E + value_type: S_DWORD_R + read_lambda: return -67305985; + - address: 0x11 + value_type: U_QWORD + read_lambda: return 72623859790382856; + - address: 0x16 + value_type: S_QWORD + read_lambda: return -72623859790382856; + - address: 0x1B + value_type: U_QWORD_R + read_lambda: return 578437695752307201; + - address: 0x20 + value_type: S_QWORD_R + read_lambda: return -578437695752307201; + - address: 0x25 + value_type: FP32 + read_lambda: return 3.14; + - address: 0x28 + value_type: FP32_R + read_lambda: return 3.14; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml new file mode 100644 index 0000000000..7ec67b03db --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_multiple.yaml @@ -0,0 +1,118 @@ +esphome: + name: uart-mock-modbus-server-mult + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + - id: virtual_uart_server_2 + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + - uart_mock.inject_rx: + id: virtual_uart_server_2 + data: !lambda return data; + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_server_2 + id: virtual_modbus_server_2 + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + update_interval: 1s + id: modbus_controller_1 + - address: 2 + modbus_id: virtual_modbus_client + update_interval: 1s + id: modbus_controller_2 + + - address: 1 + modbus_id: virtual_modbus_server + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 2 + modbus_id: virtual_modbus_server_2 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 929; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_2 + name: "reg_u_word_2" + address: 0x01 + register_type: holding + value_type: U_WORD + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_server_2).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml new file mode 100644 index 0000000000..3edcc73f07 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_write.yaml @@ -0,0 +1,330 @@ +esphome: + name: uart-mock-modbus-srv-write + +host: +api: +logger: + level: VERBOSE + +external_components: + - source: + type: local + path: EXTERNAL_COMPONENT_PATH + +# Dummy uart entry to satisfy modbus's DEPENDENCIES = ["uart"] +# The actual UART bus used is the uart_mock component below +uart: + baud_rate: 115200 + port: /dev/null + +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + # auto_start must be true for loopback fixtures: the modbus controller + # polls on its update_interval immediately at boot, so the uart_mock + # forwarding must already be active or early requests are lost and + # generate modbus warnings. + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_controller + data: !lambda return data; + - id: virtual_uart_controller + baud_rate: 9600 + auto_start: true # See comment on virtual_uart_server above + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_u_word + type: uint16_t + initial_value: "11" + - id: stored_s_word + type: int16_t + initial_value: "-11" + - id: stored_u_dword + type: uint32_t + initial_value: "1001" + - id: stored_s_dword + type: int32_t + initial_value: "-1001" + - id: stored_u_dword_r + type: uint32_t + initial_value: "3003" + - id: stored_s_dword_r + type: int32_t + initial_value: "-3003" + - id: stored_u_qword + type: uint64_t + initial_value: "5005" + - id: stored_s_qword + type: int64_t + initial_value: "-5005" + - id: stored_u_qword_r + type: uint64_t + initial_value: "7007" + - id: stored_s_qword_r + type: int64_t + initial_value: "-7007" + - id: stored_fp32 + type: float + initial_value: "1.5" + - id: stored_fp32_r + type: float + initial_value: "2.5" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_controller + id: virtual_modbus_controller + role: client + turnaround_time: 10ms + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_controller + update_interval: 2s + id: modbus_controller_1 + + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + server_registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return id(stored_u_word); + write_lambda: id(stored_u_word) = x; return true; + - address: 0x03 + value_type: S_WORD + read_lambda: return id(stored_s_word); + write_lambda: id(stored_s_word) = x; return true; + - address: 0x05 + value_type: U_DWORD + read_lambda: return id(stored_u_dword); + write_lambda: id(stored_u_dword) = x; return true; + - address: 0x08 + value_type: S_DWORD + read_lambda: return id(stored_s_dword); + write_lambda: id(stored_s_dword) = x; return true; + - address: 0x0B + value_type: U_DWORD_R + read_lambda: return id(stored_u_dword_r); + write_lambda: id(stored_u_dword_r) = x; return true; + - address: 0x0E + value_type: S_DWORD_R + read_lambda: return id(stored_s_dword_r); + write_lambda: id(stored_s_dword_r) = x; return true; + - address: 0x11 + value_type: U_QWORD + read_lambda: return id(stored_u_qword); + write_lambda: id(stored_u_qword) = x; return true; + - address: 0x16 + value_type: S_QWORD + read_lambda: return id(stored_s_qword); + write_lambda: id(stored_s_qword) = x; return true; + - address: 0x1B + value_type: U_QWORD_R + read_lambda: return id(stored_u_qword_r); + write_lambda: id(stored_u_qword_r) = x; return true; + - address: 0x20 + value_type: S_QWORD_R + read_lambda: return id(stored_s_qword_r); + write_lambda: id(stored_s_qword_r) = x; return true; + - address: 0x25 + value_type: FP32 + read_lambda: return id(stored_fp32); + write_lambda: id(stored_fp32) = x; return true; + - address: 0x28 + value_type: FP32_R + read_lambda: return id(stored_fp32_r); + write_lambda: id(stored_fp32_r) = x; return true; + +sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + +number: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + min_value: 0 + max_value: 65535 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_word" + address: 0x03 + register_type: holding + value_type: S_WORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword" + address: 0x05 + register_type: holding + value_type: U_DWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword" + address: 0x08 + register_type: holding + value_type: S_DWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_dword_r" + address: 0x0B + register_type: holding + value_type: U_DWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_dword_r" + address: 0x0E + register_type: holding + value_type: S_DWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword" + address: 0x11 + register_type: holding + value_type: U_QWORD + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword" + address: 0x16 + register_type: holding + value_type: S_QWORD + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_u_qword_r" + address: 0x1B + register_type: holding + value_type: U_QWORD_R + min_value: 0 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_s_qword_r" + address: 0x20 + register_type: holding + value_type: S_QWORD_R + min_value: -16777215 + max_value: 16777215 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32" + address: 0x25 + register_type: holding + value_type: FP32 + min_value: -16777215 + max_value: 16777215 + step: 0.01 + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_fp32_r" + address: 0x28 + register_type: holding + value_type: FP32_R + min_value: -16777215 + max_value: 16777215 + step: 0.01 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: "id(virtual_uart_server).start_scenario();" + - lambda: "id(virtual_uart_controller).start_scenario();" diff --git a/tests/integration/fixtures/uart_mock_modbus_timing.yaml b/tests/integration/fixtures/uart_mock_modbus_timing.yaml index f4cf0bde37..c670864085 100644 --- a/tests/integration/fixtures/uart_mock_modbus_timing.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_timing.yaml @@ -22,6 +22,8 @@ uart_mock: baud_rate: 9600 rx_full_threshold: 120 rx_timeout: 2 + # auto_start must be false to avoid races: the test presses the + # "Start Scenario" button only after subscribing to states. auto_start: false debug: on_tx: @@ -61,4 +63,4 @@ button: name: "Start Scenario" id: start_scenario_btn on_press: - - lambda: 'id(virtual_uart_dev).start_scenario();' + - lambda: "id(virtual_uart_dev).start_scenario();" diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 5792a8e804..d42b50ecdb 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -346,3 +346,109 @@ class SensorStateCollector: else: self._waiters.append((condition, future)) return future + + +class SensorTracker: + """Data-driven sensor state tracker with expected-value futures. + + Tracks sensor state updates and resolves futures when sensors report + specific expected values. Eliminates per-sensor future boilerplate. + + Usage:: + + tracker = SensorTracker(["reg_u_word", "reg_s_word"]) + futures = tracker.expect_all({"reg_u_word": 99, "reg_s_word": -99}) + # ... subscribe_states with tracker.on_state, start scenario ... + await tracker.await_all(futures) + """ + + def __init__(self, sensor_names: list[str]) -> None: + self.sensor_states: dict[str, list[float]] = {name: [] for name in sensor_names} + self.key_to_sensor: dict[int, str] = {} + self._expectations: dict[str, list[tuple[object, asyncio.Future]]] = {} + + _ANY = object() # Sentinel: match any value + + def expect(self, name: str, value: object) -> asyncio.Future: + """Register an expected value for *name* and return a future for it.""" + future: asyncio.Future = asyncio.get_running_loop().create_future() + self._expectations.setdefault(name, []).append((value, future)) + return future + + def expect_any(self, name: str) -> asyncio.Future: + """Register a future that resolves on *any* state update for *name*.""" + return self.expect(name, self._ANY) + + def expect_all(self, expected: dict[str, object]) -> dict[str, asyncio.Future]: + """Call ``expect`` for every entry and return a dict of futures.""" + return {name: self.expect(name, value) for name, value in expected.items()} + + def on_state(self, state: EntityState) -> None: + """State callback suitable for ``subscribe_states``.""" + if not isinstance(state, SensorState) or state.missing_state: + return + sensor_name = self.key_to_sensor.get(state.key) + if not sensor_name or sensor_name not in self.sensor_states: + return + self.sensor_states[sensor_name].append(state.state) + for expected_value, future in self._expectations.get(sensor_name, []): + if not future.done() and ( + expected_value is self._ANY or state.state == expected_value + ): + future.set_result(True) + break + + async def await_change( + self, future: asyncio.Future, name: str, timeout: float = 2.0 + ) -> None: + """Wait for a sensor future to resolve; fail the test on timeout.""" + try: + await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + import pytest + + pytest.fail( + f"Timeout waiting for {name} change. Received sensor states:\n" + f" {name}: {self.sensor_states[name]}\n" + ) + + async def await_must_not_change( + self, future: asyncio.Future, name: str, timeout: float = 2.0 + ) -> None: + """Assert a sensor future does NOT resolve within the timeout.""" + try: + await asyncio.wait_for(future, timeout=timeout) + except TimeoutError: + return # Expected + import pytest + + pytest.fail( + f"{name} change should not have been triggered, but was. " + f"Received sensor states:\n {name}: {self.sensor_states[name]}\n" + ) + + async def await_all( + self, futures: dict[str, asyncio.Future], timeout: float = 2.0 + ) -> None: + """Await every future in *futures*, failing with per-sensor diagnostics.""" + for name, future in futures.items(): + await self.await_change(future, name, timeout=timeout) + + async def setup_and_start_scenario(self, client) -> list: + """Wire up subscriptions, wait for initial states, press Start Scenario.""" + entities, _ = await client.list_entities_services() + self.key_to_sensor.update( + build_key_to_entity_mapping(entities, list(self.sensor_states.keys())) + ) + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(self.on_state)) + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + import pytest + + pytest.fail("Timeout waiting for initial states") + start_btn = find_entity(entities, "start_scenario", ButtonInfo) + assert start_btn is not None, "Start Scenario button not found" + client.button_command(start_btn.key) + return entities diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index e341d86f53..e8dfa1b822 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -14,15 +14,67 @@ test_uart_mock_modbus_no_threshold : from __future__ import annotations import asyncio -from pathlib import Path +from collections.abc import Callable +from dataclasses import dataclass -from aioesphomeapi import ButtonInfo, EntityState, SensorState +from aioesphomeapi import NumberInfo import pytest -from .state_utils import InitialStateHelper, build_key_to_entity_mapping, find_entity +from .state_utils import SensorTracker, find_entity from .types import APIClientConnectedFactory, RunCompiledFunction +@dataclass +class RegisterTestCase: + """Test parameters for a single modbus register write/read round-trip.""" + + initial_value: object + write_number_name: str + write_value: float + post_write_value: object + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_modbus_line_callback() -> tuple[Callable[[str], None], list[str], list[str]]: + """Return a (callback, error_lines, warning_lines) tuple for tracking modbus log output. + + Only captures bus-level modbus messages ([modbus:]), not modbus_controller + scheduling noise (e.g. "Duplicate modbus command found"). + """ + error_log_lines: list[str] = [] + warning_log_lines: list[str] = [] + + def line_callback(line: str) -> None: + if "[E][modbus:" in line: + error_log_lines.append(line) + if "[W][modbus:" in line: + warning_log_lines.append(line) + + return line_callback, error_log_lines, warning_log_lines + + +def _assert_no_modbus_errors( + error_log_lines: list[str], warning_log_lines: list[str] +) -> None: + assert len(error_log_lines) == 0, ( + "Expect no errors logged by the modbus mock, but got:\n" + + "\n".join(error_log_lines) + ) + assert len(warning_log_lines) == 0, ( + "Expect no warnings logged by the modbus mock, but got:\n" + + "\n".join(warning_log_lines) + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + @pytest.mark.asyncio async def test_uart_mock_modbus( yaml_config: str, @@ -30,127 +82,41 @@ async def test_uart_mock_modbus( api_client_connected: APIClientConnectedFactory, ) -> None: """Test basic modbus data parsing.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" + + tracker = SensorTracker( + [ + "basic_register", + "delayed_response", + "late_response", + "no_response", + "exception_response", + ] ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "basic_register": [], - "delayed_response": [], - "late_response": [], - "no_response": [], - "exception_response": [], - } - - basic_register_changed = loop.create_future() - delayed_response_changed = loop.create_future() - late_response_changed = loop.create_future() - no_response_changed = loop.create_future() - exception_response_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - if ( - sensor_name == "basic_register" - and state.state == 259.0 - and not basic_register_changed.done() - ): - basic_register_changed.set_result(True) - elif ( - sensor_name == "delayed_response" - and state.state == 255.0 - and not delayed_response_changed.done() - ): - delayed_response_changed.set_result(True) - elif ( - sensor_name == "late_response" and not late_response_changed.done() - ): - late_response_changed.set_result(True) - elif sensor_name == "no_response" and not no_response_changed.done(): - no_response_changed.set_result(True) - elif ( - sensor_name == "exception_response" - and not exception_response_changed.done() - ): - exception_response_changed.set_result(True) + basic_register_changed = tracker.expect("basic_register", 259.0) + delayed_response_changed = tracker.expect("delayed_response", 255.0) + # late_response / no_response / exception_response: expect *any* value + # (these should never fire, so we use a permissive match via expect_any) + late_response_changed = tracker.expect_any("late_response") + no_response_changed = tracker.expect_any("no_response") + exception_response_changed = tracker.expect_any("exception_response") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() + await tracker.setup_and_start_scenario(client) - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") - - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) - - try: - await asyncio.wait_for(delayed_response_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for delayed_response change. Received sensor states:\n" - f" delayed_response: {sensor_states['delayed_response']}\n" - ) - - try: - await asyncio.wait_for(late_response_changed, timeout=2.0) - pytest.fail( - f"late_response change should not have been triggered, but was. Received sensor states:\n" - f" late_response: {sensor_states['late_response']}\n" - ) - except TimeoutError: - pass # Expected timeout since we never inject a response for late_response - - try: - await asyncio.wait_for(no_response_changed, timeout=2.0) - pytest.fail( - f"no_response change should not have been triggered, but was. Received sensor states:\n" - f" no_response: {sensor_states['no_response']}\n" - ) - except TimeoutError: - pass # Expected timeout since we never inject a response for no_response - - # Wait for basic register to be updated with successful parse - try: - await asyncio.wait_for(basic_register_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for Basic Register change. Received sensor states:\n" - f" basic_register: {sensor_states['basic_register']}\n" - ) - - try: - await asyncio.wait_for(exception_response_changed, timeout=2.0) - pytest.fail( - f"exception_response change should not have been triggered, but was. Received sensor states:\n" - f" exception_response: {sensor_states['exception_response']}\n" - ) - except TimeoutError: - pass + await tracker.await_change(delayed_response_changed, "delayed_response") + await tracker.await_change(basic_register_changed, "basic_register") + # Run all "must not change" checks concurrently — each waits the full + # timeout, so sequential execution would multiply the wall time. + await asyncio.gather( + tracker.await_must_not_change(late_response_changed, "late_response"), + tracker.await_must_not_change(no_response_changed, "no_response"), + tracker.await_must_not_change( + exception_response_changed, "exception_response" + ), + ) @pytest.mark.asyncio @@ -159,69 +125,17 @@ async def test_uart_mock_modbus_timing( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test basic modbus data parsing.""" - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) + """Test modbus timing with multi-register SDM meter response.""" - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "sdm_voltage": [], - } - - voltage_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - # Check if this is a good voltage reading (243V) - if ( - sensor_name == "sdm_voltage" - and state.state > 200.0 - and not voltage_changed.done() - ): - voltage_changed.set_result(True) + tracker = SensorTracker(["sdm_voltage"]) + voltage_changed = tracker.expect_any("sdm_voltage") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() - - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) - - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") - - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) - - # Wait for voltage to be updated with successful parse - try: - await asyncio.wait_for(voltage_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for SDM voltage change. Received sensor states:\n" - f" sdm_voltage: {sensor_states['sdm_voltage']}\n" - ) + await tracker.setup_and_start_scenario(client) + await tracker.await_change(voltage_changed, "sdm_voltage") @pytest.mark.asyncio @@ -234,66 +148,187 @@ async def test_uart_mock_modbus_no_threshold( Without the 50ms fallback timeout, the chunked response with a 40ms gap between USB packets would cause a false timeout and CRC failure cascade. + Bus-level warnings (CRC failures, buffer clears) are expected during + chunked reassembly — the test only verifies the final value arrives. """ - # Replace external component path placeholder - external_components_path = str( - Path(__file__).parent / "fixtures" / "external_components" - ) - yaml_config = yaml_config.replace( - "EXTERNAL_COMPONENT_PATH", external_components_path - ) - loop = asyncio.get_running_loop() - - # Track sensor state updates (after initial state is swallowed) - sensor_states: dict[str, list[float]] = { - "sdm_voltage": [], - } - - voltage_changed = loop.create_future() - - def on_state(state: EntityState) -> None: - if isinstance(state, SensorState) and not state.missing_state: - sensor_name = key_to_sensor.get(state.key) - if sensor_name and sensor_name in sensor_states: - sensor_states[sensor_name].append(state.state) - # Check if this is a good voltage reading (243V) - if ( - sensor_name == "sdm_voltage" - and state.state > 200.0 - and not voltage_changed.done() - ): - voltage_changed.set_result(True) + tracker = SensorTracker(["sdm_voltage"]) + voltage_changed = tracker.expect_any("sdm_voltage") async with ( run_compiled(yaml_config), api_client_connected() as client, ): - entities, _ = await client.list_entities_services() + await tracker.setup_and_start_scenario(client) + await tracker.await_change(voltage_changed, "sdm_voltage") - # Build key mappings for all sensor types - all_names = list(sensor_states.keys()) - key_to_sensor = build_key_to_entity_mapping(entities, all_names) - # Set up initial state helper - initial_state_helper = InitialStateHelper(entities) - client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) +@pytest.mark.asyncio +@pytest.mark.xfail( + reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", + strict=True, +) +async def test_uart_mock_modbus_server( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test modbus server parsing with peer traffic on a shared bus.""" - try: - await initial_state_helper.wait_for_initial_states() - except TimeoutError: - pytest.fail("Timeout waiting for initial states") + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() - # Start the UART mock scenario now that we're subscribed - start_btn = find_entity(entities, "start_scenario", ButtonInfo) - assert start_btn is not None, "Start Scenario button not found" - client.button_command(start_btn.key) + tracker = SensorTracker( + ["basic_read", "read_after_peer_response", "read_after_peer_timeout"] + ) + futures = tracker.expect_all( + { + "basic_read": 1, + "read_after_peer_response": 1, + "read_after_peer_timeout": 1, + } + ) - # Wait for voltage to be updated with successful parse - try: - await asyncio.wait_for(voltage_changed, timeout=2.0) - except TimeoutError: - pytest.fail( - f"Timeout waiting for SDM voltage change. Received sensor states:\n" - f" sdm_voltage: {sensor_states['sdm_voltage']}\n" + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller functionality for all read register types.""" + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = { + "reg_u_word": 99, + "reg_s_word": -99, + "reg_u_dword": 16909060, + "reg_s_dword": -16909060, + "reg_u_dword_r": pytest.approx(67305985), + "reg_s_dword_r": pytest.approx(-67305985), + "reg_u_qword": pytest.approx(72623859790382856), + "reg_s_qword": pytest.approx(-72623859790382856), + "reg_u_qword_r": pytest.approx(578437695752307201), + "reg_s_qword_r": pytest.approx(-578437695752307201), + "reg_fp32": pytest.approx(3.14), + "reg_fp32_r": pytest.approx(3.14), + } + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller write functionality for all register value types. + + Verifies that writing to modbus server registers via the controller updates + the server's stored values, which are then read back correctly on the next poll. + All 12 value types are tested: U/S_WORD, U/S_DWORD(_R), U/S_QWORD(_R), FP32(_R). + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + register_test_cases: dict[str, RegisterTestCase] = { + "reg_u_word": RegisterTestCase(11, "write_u_word", 42, 42), + "reg_s_word": RegisterTestCase(-11, "write_s_word", -42, -42), + "reg_u_dword": RegisterTestCase(1001, "write_u_dword", 2002, 2002), + "reg_s_dword": RegisterTestCase(-1001, "write_s_dword", -2002, -2002), + "reg_u_dword_r": RegisterTestCase(3003, "write_u_dword_r", 4004, 4004), + "reg_s_dword_r": RegisterTestCase(-3003, "write_s_dword_r", -4004, -4004), + "reg_u_qword": RegisterTestCase(5005, "write_u_qword", 6006, 6006), + "reg_s_qword": RegisterTestCase(-5005, "write_s_qword", -6006, -6006), + "reg_u_qword_r": RegisterTestCase(7007, "write_u_qword_r", 8008, 8008), + "reg_s_qword_r": RegisterTestCase(-7007, "write_s_qword_r", -8008, -8008), + "reg_fp32": RegisterTestCase( + pytest.approx(1.5, abs=0.01), + "write_fp32", + 3.14, + pytest.approx(3.14, abs=0.01), + ), + "reg_fp32_r": RegisterTestCase( + pytest.approx(2.5, abs=0.01), + "write_fp32_r", + 6.28, + pytest.approx(6.28, abs=0.01), + ), + } + + tracker = SensorTracker(list(register_test_cases.keys())) + + # Phase 1: expect initial baseline values + initial_futures = tracker.expect_all( + {name: case.initial_value for name, case in register_test_cases.items()} + ) + # Phase 2: expect post-write values (registered now so on_state can match them) + written_futures = tracker.expect_all( + {name: case.post_write_value for name, case in register_test_cases.items()} + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + entities = await tracker.setup_and_start_scenario(client) + + # Wait for initial baseline values to confirm the controller <-> server + # connection is working before issuing writes + await tracker.await_all(initial_futures, timeout=4.0) + + # Issue write commands for all register types + for case in register_test_cases.values(): + entity = find_entity(entities, case.write_number_name, NumberInfo) + assert entity is not None, ( + f"{case.write_number_name} number entity not found" ) + client.number_command(entity.key, case.write_value) + + # Wait for sensors to reflect the written values (round-trip write+read) + await tracker.await_all(written_futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +@pytest.mark.xfail( + reason="Modbus parser cannot handle server responses from other devices on the bus. Fix tracked in PR #11969.", + strict=True, +) +async def test_uart_mock_modbus_server_controller_multiple( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test server/controller functionality with multiple servers.""" + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + expected_values = {"reg_u_word": 919, "reg_u_word_2": 929} + tracker = SensorTracker(list(expected_values.keys())) + futures = tracker.expect_all(expected_values) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + await tracker.setup_and_start_scenario(client) + await tracker.await_all(futures) + _assert_no_modbus_errors(error_log_lines, warning_log_lines)