diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 735ba73c99..7e695bb46b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -466,7 +466,7 @@ jobs: echo "binary=$BINARY" >> $GITHUB_OUTPUT - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2 + uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: run: | . venv/bin/activate diff --git a/CODEOWNERS b/CODEOWNERS index 9bcbe087c5..9ddbca5c71 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -238,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/hmac_sha256/* @dwmw2 +esphome/components/hoermann_hcp/* @zweckj esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 @@ -465,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik esphome/components/sen5x/* @martgras esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sendspin/* @kahrendt +esphome/components/sendspin/image/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/sensor/* @kahrendt diff --git a/Doxyfile b/Doxyfile index 3bb08e5b06..8f6048b4d8 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2026.8.0-dev +PROJECT_NUMBER = 2026.9.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/__main__.py b/esphome/__main__.py index cc1e12cb3a..0ac5898268 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -21,7 +21,6 @@ from esphome.const import ( ARGUMENT_HELP_DEVICE, BUNDLE_EXTENSION, CONF_API, - CONF_AUTH, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -29,6 +28,7 @@ from esphome.const import ( CONF_DISCOVER_IP, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -42,7 +42,7 @@ from esphome.const import ( CONF_PORT, CONF_SUBSTITUTIONS, CONF_TOPIC, - CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, ENV_NOGITIGNORE, @@ -273,8 +273,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str: if purpose == Purpose.LOGGING and not has_api(): return ( "Cannot view logs over the network: no 'api:' component is " - "configured. Network log streaming requires the native API; add " - "an 'api:' component, enable MQTT logging, or view logs over USB." + "configured. Add an 'api:' component, enable MQTT logging, add a " + "'web_server:' component, or view logs over USB." ) if purpose == Purpose.UPLOADING and not has_ota(): return ( @@ -314,9 +314,12 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": + # Logs can stream over a network transport via the native API + # or the web_server HTTP SSE feed. + network_logging = has_api() or has_web_server_logging() # ensure IP adresses are used first if is_ip_address(CORE.address) and ( - (purpose == Purpose.LOGGING and has_api()) + (purpose == Purpose.LOGGING and network_logging) or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.extend(_resolve_with_cache(CORE.address, purpose)) @@ -328,7 +331,11 @@ def choose_upload_log_host( if has_mqtt_logging(): resolved.append("MQTT") - if has_api() and has_non_ip_address() and has_resolvable_address(): + if ( + network_logging + and has_non_ip_address() + and has_resolvable_address() + ): resolved.extend(_ota_hostnames_for_default(purpose)) elif purpose == Purpose.UPLOADING: @@ -390,7 +397,7 @@ def choose_upload_log_host( mqtt_config = CORE.config[CONF_MQTT] options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) - if has_api(): + if has_api() or has_web_server_logging(): add_ota_options() elif purpose == Purpose.UPLOADING and has_ota(): @@ -483,6 +490,21 @@ def has_web_server_ota() -> bool: ) +def has_web_server_logging() -> bool: + """Check if logs can be streamed over the web_server HTTP SSE endpoint. + + The ``web_server`` component exposes a ``/events`` Server-Sent Events + stream that carries ``event: log`` frames. This requires version 2+ (the + v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default). + """ + web_conf = CORE.config.get(CONF_WEB_SERVER) + if web_conf is None: + return False + if web_conf.get(CONF_VERSION, 2) == 1: + return False + return web_conf.get(CONF_LOG, True) + + def has_mqtt_ip_lookup() -> bool: """Check if MQTT is available and IP lookup is supported.""" if CONF_MQTT not in CORE.config: @@ -1291,25 +1313,23 @@ def _upload_via_native_api( def _upload_via_web_server( config: ConfigType, network_devices: list[str], binary: Path ) -> tuple[int, str | None]: - web_conf = config.get(CONF_WEB_SERVER) - if not web_conf: - raise EsphomeError( - f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component " - f"is not configured." - ) - - remote_port = int(web_conf[CONF_PORT]) - auth = web_conf.get(CONF_AUTH) or {} - username = auth.get(CONF_USERNAME) - password = auth.get(CONF_PASSWORD) - from esphome import web_server_ota + from esphome.web_server_helpers import get_web_server_connection + remote_port, username, password = get_web_server_connection(config) return web_server_ota.run_ota( network_devices, remote_port, username, password, binary ) +def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int: + from esphome import web_server_logs + from esphome.web_server_helpers import get_web_server_connection + + port, username, password = get_web_server_connection(config) + return web_server_logs.run_logs(network_devices, port, username, password) + + # Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a # 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as # bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the @@ -1437,6 +1457,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int config, args.topic, args.username, args.password, args.client_id ) + # Fall back to the web_server HTTP SSE log stream for devices that have + # web_server: but no api: (the logging counterpart to web_server OTA). + if has_web_server_logging() and ( + network_devices := _resolve_network_devices(devices, config, args) + ): + return _show_logs_via_web_server(config, network_devices) + raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)") @@ -2509,6 +2536,49 @@ def parse_args(argv): return parser.parse_args(arguments) +def _warn_if_source_tree_mismatch() -> None: + """Warn when the checkout the user is standing in is not the one being run. + + An editable install records one absolute path, so a venv shared between git + worktrees (or reused after a checkout is copied or renamed) keeps importing + the tree it was installed from. Every command then silently runs, and + compiles, sources the user is not looking at. Only fires inside a checkout, + so ordinary installs never see it. + """ + try: + cwd = Path.cwd() + except OSError: + return # working directory is gone; a diagnostic must not break startup + for candidate in (cwd, *cwd.parents): + if (candidate / "esphome" / "__main__.py").is_file(): + standing_in = candidate.resolve() + break + else: + return # not inside a checkout; nothing to compare against + + running = Path(__file__).resolve().parent.parent + # Both sides are resolved, so on a case-sensitive filesystem this matches + # plain equality. samefile() compares device and inode, which additionally + # covers a case-insensitive filesystem (macOS) reaching one directory by + # differently cased paths. Falls back to equality if either path is gone. + try: + same = standing_in.samefile(running) + except OSError: + same = standing_in == running + if same: + return + + _LOGGER.warning( + "Running ESPHome from a different checkout than the one you are in:\n" + " running from: %s\n" + " you are in: %s\n" + "The installed esphome resolves to the first, so its sources are used.\n" + "Run 'python -m esphome' from the second to use that one instead.", + running, + standing_in, + ) + + def run_esphome(argv): from esphome.address_cache import AddressCache @@ -2527,6 +2597,7 @@ def run_esphome(argv): args.log_level = "CRITICAL" setup_log(log_level=args.log_level) + _warn_if_source_tree_mismatch() if args.command in PRE_CONFIG_ACTIONS: try: diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 555d511f6e..1c50b6b81b 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -231,6 +231,7 @@ def validate_adc_pin(value): return pins.internal_gpio_input_pin_schema(29) return cv.only_on([PLATFORM_ESP8266])("VCC") + # Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0 if str(value).upper() == "TEMPERATURE": return cv.only_on_rp2("TEMPERATURE") diff --git a/esphome/components/adc/adc_sensor_rp2.cpp b/esphome/components/adc/adc_sensor_rp2.cpp index 6cb9ef113f..8652a46029 100644 --- a/esphome/components/adc/adc_sensor_rp2.cpp +++ b/esphome/components/adc/adc_sensor_rp2.cpp @@ -19,6 +19,25 @@ namespace esphome::adc { static const char *const TAG = "adc.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif + void ADCSensor::setup() { static bool initialized = false; if (!initialized) { @@ -52,7 +71,7 @@ float ADCSensor::sample() { if (this->is_temperature_) { adc_set_temp_sensor_enabled(true); delay(1); - adc_select_input(4); + adc_select_input(TEMPERATURE_ADC_INPUT); for (uint8_t sample = 0; sample < this->sample_count_; sample++) { raw = adc_read(); diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index c5a4288c07..b2a4382a21 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -67,6 +67,13 @@ def validate_config(config): # Alter value here so `config` command prints the recommended change config[CONF_ATTENUATION] = _attenuation("12db") + # Remove before 2027.2.0 + if config[CONF_PIN] == "TEMPERATURE": + _LOGGER.warning( + "[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` " + "sensor platform instead. Will be removed in 2027.2.0" + ) + return config @@ -133,6 +140,7 @@ async def to_code(config): if config[CONF_PIN] == "VCC": cg.add_define("USE_ADC_SENSOR_VCC") elif config[CONF_PIN] == "TEMPERATURE": + # Remove before 2027.2.0 cg.add(var.set_is_temperature()) elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC: pin = await cg.gpio_pin_expression(config[CONF_PIN]) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 0df7c56313..6da5268432 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -13,8 +13,13 @@ import esphome.components.image as espImage import esphome.config_validation as cv +from . import image as animation_image from .image import ANIMATION_CONFIG_SCHEMA, setup_animation +# The deprecated top-level `animation:` shim gets the same batched +# downloads as the `image:` platform form. +PREFETCH_FILES = animation_image.PREFETCH_FILES + AUTO_LOAD = ["image", "file"] CODEOWNERS = ["@syndlex"] DEPENDENCIES = ["display"] diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py index 95875fe2b0..73d428bd20 100644 --- a/esphome/components/animation/image.py +++ b/esphome/components/animation/image.py @@ -1,6 +1,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components.const import CONF_LOOP +from esphome.components.file import image as file_image from esphome.components.file.image import image_schema, write_image from esphome.components.image import Image_, validate_settings import esphome.config_validation as cv @@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT from esphome.types import ConfigType CODEOWNERS = ["@syndlex"] + +# The animation platform shares the file platform's remote file handling, +# including its batch-download hook. +PREFETCH_FILES = file_image.PREFETCH_FILES AUTO_LOAD = ["file"] DEPENDENCIES = ["display"] diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 4b3df62ec4..f1bc9b003a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -19,6 +19,7 @@ service APIConnection { rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) { option (needs_authentication) = false; } + rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {} rpc list_entities (ListEntitiesRequest) returns (void) {} rpc subscribe_states (SubscribeStatesRequest) returns (void) {} rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} @@ -243,6 +244,12 @@ message SerialProxyInfo { // model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas) // project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH) // suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA) +// +// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They +// have moved to that message as of API 1.15, but are still sent here so that +// older clients keep working. Do NOT mark them (deprecated) until the removal +// release: in this repo (deprecated) makes the generator drop the field +// entirely, so the device would stop sending it. message DeviceInfoResponse { option (id) = 10; option (source) = SOURCE_SERVER; @@ -280,6 +287,8 @@ message DeviceInfoResponse { // Deprecated in API version 1.9 uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; + + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15. uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; string manufacturer = 12 [(max_data_length) = 20, (force) = true]; @@ -288,11 +297,14 @@ message DeviceInfoResponse { // Deprecated in API version 1.10 uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"]; + + // Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15. uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"]; // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15. string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"]; // Supports receiving and saving api encryption key @@ -305,10 +317,13 @@ message DeviceInfoResponse { AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"]; // Indicates if Z-Wave proxy support is available and features supported + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15. uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"]; // Serial proxy instance metadata + // Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15. repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; // Device is unprovisioned and accepts Noise handshakes with the well-known @@ -317,6 +332,63 @@ message DeviceInfoResponse { bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"]; } +// ==================== DEVICE CAPABILITIES ==================== + +// Asks the device which optional features it supports. +// +// This message exists so that DeviceInfoResponse does not have to keep growing +// a flat list of feature flags. DeviceInfoResponse is served before +// authentication, so it is limited to identity information. Capabilities are +// only served on an authenticated connection (encrypted as well, when +// encryption is configured). +// +// Clients that see api_version >= 1.15 should read these values from +// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields. +// Older clients keep reading DeviceInfoResponse, which still carries the same +// values, so this is not a breaking change. +message DeviceCapabilitiesRequest { + option (id) = 149; + option (source) = SOURCE_CLIENT; + // Empty +} + +// Each feature gets its own sub-message so that it can gain fields over time +// without crowding the top-level field numbering. +// +// Note: a sub-message whose fields are all at their default value is not sent +// at all, so the presence of a sub-message is not a reliable test for "this +// feature is compiled in". Clients should test a value inside it, for example +// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse. + +message BluetoothProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA" + string mac_address = 2 [(max_data_length) = 17, (force) = true]; +} + +message VoiceAssistantCapabilities { + // Bitmask of the features this voice assistant supports + uint32 feature_flags = 1; +} + +message ZWaveProxyCapabilities { + // Bitmask of the features this proxy supports + uint32 feature_flags = 1; + uint32 home_id = 2; +} + +message DeviceCapabilitiesResponse { + option (id) = 150; + option (source) = SOURCE_SERVER; + + BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"]; + VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"]; + ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"]; + repeated SerialProxyInfo serial_proxies = 4 + [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"]; +} + message ListEntitiesRequest { option (id) = 11; option (source) = SOURCE_CLIENT; @@ -1688,7 +1760,7 @@ enum BluetoothDeviceRequestType { message BluetoothDeviceRequest { option (id) = 68; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; BluetoothDeviceRequestType request_type = 2; @@ -1699,7 +1771,7 @@ message BluetoothDeviceRequest { message BluetoothDeviceConnectionResponse { option (id) = 69; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool connected = 2; @@ -1710,7 +1782,7 @@ message BluetoothDeviceConnectionResponse { message BluetoothGATTGetServicesRequest { option (id) = 70; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1754,7 +1826,7 @@ message BluetoothGATTService { message BluetoothGATTGetServicesResponse { option (id) = 71; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; repeated BluetoothGATTService services = 2; @@ -1763,7 +1835,7 @@ message BluetoothGATTGetServicesResponse { message BluetoothGATTGetServicesDoneResponse { option (id) = 72; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; } @@ -1771,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse { message BluetoothGATTReadRequest { option (id) = 73; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1780,7 +1852,7 @@ message BluetoothGATTReadRequest { message BluetoothGATTReadResponse { option (id) = 74; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1792,7 +1864,7 @@ message BluetoothGATTReadResponse { message BluetoothGATTWriteRequest { option (id) = 75; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1804,7 +1876,7 @@ message BluetoothGATTWriteRequest { message BluetoothGATTReadDescriptorRequest { option (id) = 76; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1813,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest { message BluetoothGATTWriteDescriptorRequest { option (id) = 77; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1824,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest { message BluetoothGATTNotifyRequest { option (id) = 78; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1834,7 +1906,7 @@ message BluetoothGATTNotifyRequest { message BluetoothGATTNotifyDataResponse { option (id) = 79; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1845,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse { message SubscribeBluetoothConnectionsFreeRequest { option (id) = 80; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; } message BluetoothConnectionsFreeResponse { option (id) = 81; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint32 free = 1; uint32 limit = 2; @@ -1864,7 +1936,7 @@ message BluetoothConnectionsFreeResponse { message BluetoothGATTErrorResponse { option (id) = 82; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1874,7 +1946,7 @@ message BluetoothGATTErrorResponse { message BluetoothGATTWriteResponse { option (id) = 83; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1883,7 +1955,7 @@ message BluetoothGATTWriteResponse { message BluetoothGATTNotifyResponse { option (id) = 84; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 handle = 2; @@ -1892,7 +1964,7 @@ message BluetoothGATTNotifyResponse { message BluetoothDevicePairingResponse { option (id) = 85; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool paired = 2; @@ -1902,7 +1974,7 @@ message BluetoothDevicePairingResponse { message BluetoothDeviceUnpairingResponse { option (id) = 86; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -1918,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest { message BluetoothDeviceClearCacheResponse { option (id) = 88; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; bool success = 2; @@ -2735,7 +2807,7 @@ message SerialProxyRequestResponse { message BluetoothSetConnectionParamsRequest { option (id) = 145; option (source) = SOURCE_CLIENT; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; uint32 min_interval = 2; // units of 1.25ms @@ -2747,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest { message BluetoothSetConnectionParamsResponse { option (id) = 146; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_BLUETOOTH_PROXY"; + option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS"; uint64 address = 1; int32 error = 2; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index cb57db9ce8..d05f98d03b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -23,6 +23,7 @@ #include "esphome/core/application.h" #include "esphome/core/entity_base.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/version.h" #ifdef USE_PROVISIONING @@ -88,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto"); static const char *const TAG = "api.connection"; + +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what) { + esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"), + LOG_STR_ARG(what)); +} +#endif #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; #endif @@ -1235,6 +1243,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request( void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() { bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this); } +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg); } @@ -1268,13 +1277,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() { } } +void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { + bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); +} +#endif + void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode( msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE); } -void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) { - bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg); -} #endif #ifdef USE_VOICE_ASSISTANT @@ -1532,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF #endif #if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY) -void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); } +void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { + if (!this->send_message(msg)) { + // V: fires per decoded frame with no subscription gate, so a warning + // would flood the congested link it reports on. + ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full"); + } +} #endif #ifdef USE_SERIAL_PROXY @@ -1574,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM SerialProxyGetModemPinsResponse resp{}; resp.instance = msg.instance; resp.line_states = proxies[msg.instance]->get_modem_pins(); - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } } void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { @@ -1606,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { resp.status = enums::SERIAL_PROXY_STATUS_ERROR; break; } - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Serial proxy response"); + } break; } default: @@ -1615,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) { } } -void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); } +void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { + if (!this->send_message(msg)) { + ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full"); + } +} #endif #ifdef USE_INFRARED @@ -1735,7 +1760,7 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; - resp.api_version_minor = 14; + resp.api_version_minor = 15; // Send only the version string - the client only logs this for debugging and doesn't use it otherwise resp.server_info = ESPHOME_VERSION_REF; resp.name = StringRef(App.get_name()); @@ -1746,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) { // Acknowledge the hello so the client can read the server name, then request // disconnect with the reason. Authentication is intentionally not completed. this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection")); - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Hello response"); + } DisconnectRequest req; req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED; return this->send_message(req); @@ -1771,9 +1798,8 @@ bool APIConnection::send_device_info_response_() { #ifdef USE_AREAS resp.suggested_area = StringRef(App.get_area()); #endif - // Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char mac_address[18]; - uint8_t mac[6]; + char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, mac_address); resp.mac_address = StringRef(mac_address); @@ -1849,8 +1875,7 @@ bool APIConnection::send_device_info_response_() { #endif #ifdef USE_BLUETOOTH_PROXY resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); - // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) - char bluetooth_mac[18]; + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); resp.bluetooth_mac_address = StringRef(bluetooth_mac); #endif @@ -1904,6 +1929,35 @@ bool APIConnection::send_device_info_response_() { return this->send_message(resp); } +bool APIConnection::send_device_capabilities_response_() { + // These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks + // below in sync with send_device_info_response_() until those copies are removed. + DeviceCapabilitiesResponse resp; +#ifdef USE_BLUETOOTH_PROXY + resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); + char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; + bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); + resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac); +#endif +#ifdef USE_VOICE_ASSISTANT + resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags(); +#endif +#ifdef USE_ZWAVE_PROXY + resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags(); + resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id(); +#endif +#ifdef USE_SERIAL_PROXY + size_t serial_proxy_index = 0; + for (auto const &proxy : App.get_serial_proxies()) { + if (serial_proxy_index >= SERIAL_PROXY_COUNT) + break; + auto &info = resp.serial_proxies[serial_proxy_index++]; + info.name = StringRef(proxy->get_name()); + info.port_type = proxy->get_port_type(); + } +#endif + return this->send_message(resp); +} void APIConnection::on_hello_request(const HelloRequest &msg) { if (!this->send_hello_response_(msg)) { this->on_fatal_error(); @@ -1925,6 +1979,11 @@ void APIConnection::on_device_info_request() { this->on_fatal_error(); } } +void APIConnection::on_device_capabilities_request() { + if (!this->send_device_capabilities_response_()) { + this->on_fatal_error(); + } +} #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { @@ -2003,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.call_id = call_id; resp.success = success; resp.error_message = error_message; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message, @@ -2014,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success resp.error_message = error_message; resp.response_data = response_data; resp.response_data_len = response_data_len; - this->send_message(resp); + if (!this->send_message(resp)) { + API_LOG_MSG_DROPPED(TAG, "Action response"); + } } #endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif +#ifdef USE_API_HOMEASSISTANT_SERVICES +bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) { + if (!this->flags_.service_call_subscription) + return false; + if (!this->send_message(call)) { + API_LOG_MSG_DROPPED(TAG, "Action request"); + } + return true; +} +#endif // USE_API_HOMEASSISTANT_SERVICES + +#ifdef USE_HOMEASSISTANT_TIME +void APIConnection::send_time_request() { + GetTimeRequest req; + if (!this->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Time request"); + } +} +#endif // USE_HOMEASSISTANT_TIME + #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) { #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON @@ -2094,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) { if (this->helper_->can_write_without_blocking()) return true; if (log_out_of_space) { - ESP_LOGV(TAG, "Cannot send message because of TCP buffer space"); + // VV: refusals are either reported by the sending call site (naming what + // was lost) or retried without loss (the deferred batch), so this generic + // line only duplicates them. + ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space"); } return false; } diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7df7ea1429..bb51a13000 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -25,6 +25,7 @@ #include "esphome/components/esp8266/crash_handler.h" #endif #include "esphome/core/entity_base.h" +#include "esphome/core/log.h" #include "esphome/core/string_ref.h" #include @@ -40,6 +41,16 @@ namespace esphome::api { // Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h. class APIServer; +// One shared flash string for every refused-frame warning: send_message() +// fails as soon as the TCP buffer is full, and each caller only pays for its +// short name. The guard drops the helper and its arguments below WARN. +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN +void log_dropped_message(const char *tag, int line, const LogString *what); +#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what)) +#else +#define API_LOG_MSG_DROPPED(tag, what) +#endif + // Keepalive timeout in milliseconds static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000; // Maximum number of entities to process in a single batch during initial state/info sending @@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase { // Returns whether this client has subscribed to Home Assistant actions; the message // is only handed to the send path when subscribed. A true return does not guarantee // delivery - it lets the caller warn when no connected client has the subscription. - bool send_homeassistant_action(const HomeassistantActionRequest &call) { - if (!this->flags_.service_call_subscription) - return false; - this->send_message(call); - return true; - } + bool send_homeassistant_action(const HomeassistantActionRequest &call); #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES void on_homeassistant_action_response(const HomeassistantActionResponse &msg); #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -183,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg); void on_unsubscribe_bluetooth_le_advertisements_request(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &msg); void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg); void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg); @@ -191,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase { void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg); void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg); void on_subscribe_bluetooth_connections_free_request(); - void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg); +#endif + void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg); #endif #ifdef USE_HOMEASSISTANT_TIME - void send_time_request() { - GetTimeRequest req; - this->send_message(req); - } + void send_time_request(); #endif #ifdef USE_VOICE_ASSISTANT @@ -266,6 +271,7 @@ class APIConnection final : public APIServerConnectionBase { void on_disconnect_request(const DisconnectRequest &msg); void on_ping_request(); void on_device_info_request(); + void on_device_capabilities_request(); void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); } void on_subscribe_states_request() { this->flags_.state_subscription = true; @@ -334,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase { // Function pointer type for type-erased size calculation using CalculateSizeFn = uint32_t (*)(const void *); - template bool send_message(const T &msg) { + /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we + /// have no silent failures: every caller must handle (or log) a refusal. + template [[nodiscard]] bool send_message(const T &msg) { if constexpr (T::ESTIMATED_SIZE == 0) { return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg); } else { @@ -385,10 +393,11 @@ class APIConnection final : public APIServerConnectionBase { bool send_disconnect_response_(); bool send_ping_response_(); bool send_device_info_response_(); + bool send_device_capabilities_response_(); #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg); #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool send_subscribe_bluetooth_connections_free_response_(); #endif #ifdef USE_VOICE_ASSISTANT diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 9cae6ba92e..9c49956bbd 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -149,7 +149,7 @@ class APIFrameHelper { // holding data too long waiting for Nagle's timer causes buffer exhaustion // and dropped messages. // - // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle + // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle // ESP8266 (2×MSS): 3 logs per cycle (tightest buffers) // // Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush) @@ -312,7 +312,7 @@ class APIFrameHelper { // Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch. // After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0. // ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching. - // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more. + // ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more. #ifdef USE_ESP8266 static constexpr uint8_t LOG_NAGLE_COUNT = 2; #else diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 190bd32425..1b8c6b05bd 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const { #endif return size; } +#ifdef USE_BLUETOOTH_PROXY +uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address); + return pos; +} +uint32_t BluetoothProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += 2 + this->mac_address.size(); + return size; +} +#endif +#ifdef USE_VOICE_ASSISTANT +uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + return pos; +} +uint32_t VoiceAssistantCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + return size; +} +#endif +#ifdef USE_ZWAVE_PROXY +uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags); + ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id); + return pos; +} +uint32_t ZWaveProxyCapabilities::calculate_size() const { + uint32_t size = 0; + size += ProtoSize::calc_uint32(1, this->feature_flags); + size += ProtoSize::calc_uint32(1, this->home_id); + return size; +} +#endif +uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { + uint8_t *__restrict__ pos = buffer.get_pos(); +#ifdef USE_BLUETOOTH_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy); +#endif +#ifdef USE_VOICE_ASSISTANT + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant); +#endif +#ifdef USE_ZWAVE_PROXY + ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it); + } +#endif + return pos; +} +uint32_t DeviceCapabilitiesResponse::calculate_size() const { + uint32_t size = 0; +#ifdef USE_BLUETOOTH_PROXY + size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size()); +#endif +#ifdef USE_VOICE_ASSISTANT + size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size()); +#endif +#ifdef USE_ZWAVE_PROXY + size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size()); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + size += ProtoSize::calc_message_force(1, it.calculate_size()); + } +#endif + return size; +} #ifdef USE_BINARY_SENSOR uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); @@ -2406,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const { } return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: @@ -2782,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const { size += ProtoSize::calc_int32(1, this->error); return size; } +#endif +#ifdef USE_BLUETOOTH_PROXY uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const { uint8_t *__restrict__ pos = buffer.get_pos(); ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state)); @@ -4145,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const { return size; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) { switch (field_id) { case 1: diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 4d5866da0b..8335dae1f2 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t { MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1, }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0, BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1, @@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t { BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5, BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6, }; +#endif +#ifdef USE_BLUETOOTH_PROXY enum BluetoothScannerState : uint32_t { BLUETOOTH_SCANNER_STATE_IDLE = 0, BLUETOOTH_SCANNER_STATE_STARTING = 1, @@ -600,6 +602,74 @@ class DeviceInfoResponse final : public ProtoMessage { protected: }; +#ifdef USE_BLUETOOTH_PROXY +class BluetoothProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + StringRef mac_address{}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_VOICE_ASSISTANT +class VoiceAssistantCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +#ifdef USE_ZWAVE_PROXY +class ZWaveProxyCapabilities final : public ProtoMessage { + public: + uint32_t feature_flags{0}; + uint32_t home_id{0}; + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; +#endif +class DeviceCapabilitiesResponse final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 150; + static constexpr uint8_t ESTIMATED_SIZE = 102; +#ifdef HAS_PROTO_MESSAGE_DUMP + const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); } +#endif +#ifdef USE_BLUETOOTH_PROXY + BluetoothProxyCapabilities bluetooth_proxy{}; +#endif +#ifdef USE_VOICE_ASSISTANT + VoiceAssistantCapabilities voice_assistant{}; +#endif +#ifdef USE_ZWAVE_PROXY + ZWaveProxyCapabilities zwave_proxy{}; +#endif +#ifdef USE_SERIAL_PROXY + std::array serial_proxies{}; +#endif + uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const; + uint32_t calculate_size() const; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *dump_to(DumpBuffer &out) const override; +#endif + + protected: +}; class ListEntitiesDoneResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 19; @@ -1931,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothDeviceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 68; @@ -2316,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage { protected: }; +#endif +#ifdef USE_BLUETOOTH_PROXY class BluetoothScannerStateResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 126; @@ -3290,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage { protected: }; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 145; diff --git a/esphome/components/api/api_pb2_defines.h b/esphome/components/api/api_pb2_defines.h index 8ebd60fb5d..3603fac6d7 100644 --- a/esphome/components/api/api_pb2_defines.h +++ b/esphome/components/api/api_pb2_defines.h @@ -3,7 +3,7 @@ #pragma once #include "esphome/core/defines.h" -#ifdef USE_BLUETOOTH_PROXY +#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS) #ifndef USE_API_VARINT64 #define USE_API_VARINT64 #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 09570b09e4..4d5829e45d 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string(enu } } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS template<> const char *proto_enum_to_string(enums::BluetoothDeviceRequestType value) { switch (value) { @@ -606,6 +606,8 @@ const char *proto_enum_to_string(enums::Bluet return ESPHOME_PSTR("UNKNOWN"); } } +#endif +#ifdef USE_BLUETOOTH_PROXY template<> const char *proto_enum_to_string(enums::BluetoothScannerState value) { switch (value) { case enums::BLUETOOTH_SCANNER_STATE_IDLE: @@ -988,6 +990,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const { #endif return out.c_str(); } +#ifdef USE_BLUETOOTH_PROXY +const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address); + return out.c_str(); +} +#endif +#ifdef USE_VOICE_ASSISTANT +const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + return out.c_str(); +} +#endif +#ifdef USE_ZWAVE_PROXY +const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities")); + dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags); + dump_field(out, ESPHOME_PSTR("home_id"), this->home_id); + return out.c_str(); +} +#endif +const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const { + MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse")); +#ifdef USE_BLUETOOTH_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": "); + this->bluetooth_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_VOICE_ASSISTANT + out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": "); + this->voice_assistant.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_ZWAVE_PROXY + out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": "); + this->zwave_proxy.dump_to(out); + out.append("\n"); +#endif +#ifdef USE_SERIAL_PROXY + for (const auto &it : this->serial_proxies) { + out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": "); + it.dump_to(out); + out.append("\n"); + } +#endif + return out.c_str(); +} const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const { out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}")); return out.c_str(); @@ -1953,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const } return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); @@ -2124,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const { dump_field(out, ESPHOME_PSTR("error"), this->error); return out.c_str(); } +#endif +#ifdef USE_BLUETOOTH_PROXY const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse")); dump_field(out, ESPHOME_PSTR("state"), static_cast(this->state)); @@ -2715,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const { return out.c_str(); } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const { MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest")); dump_field(out, ESPHOME_PSTR("address"), this->address); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 5c9df433dd..65c7b8858c 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothDeviceRequest::MESSAGE_TYPE: { BluetoothDeviceRequest msg; msg.decode(msg_data, msg_size); @@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: { BluetoothGATTGetServicesRequest msg; msg.decode(msg_data, msg_size); @@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadRequest::MESSAGE_TYPE: { BluetoothGATTReadRequest msg; msg.decode(msg_data, msg_size); @@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteRequest::MESSAGE_TYPE: { BluetoothGATTWriteRequest msg; msg.decode(msg_data, msg_size); @@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTReadDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: { BluetoothGATTWriteDescriptorRequest msg; msg.decode(msg_data, msg_size); @@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothGATTNotifyRequest::MESSAGE_TYPE: { BluetoothGATTNotifyRequest msg; msg.decode(msg_data, msg_size); @@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: { #ifdef HAS_PROTO_MESSAGE_DUMP this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request")); @@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: { BluetoothSetConnectionParamsRequest msg; msg.decode(msg_data, msg_size); @@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui break; } #endif + case 149 /* DeviceCapabilitiesRequest is empty */: { +#ifdef HAS_PROTO_MESSAGE_DUMP + this->log_receive_message_(LOG_STR("on_device_capabilities_request")); +#endif + this->on_device_capabilities_request(); + break; + } default: break; } diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index d1b51f4846..6abdf7093e 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -27,6 +27,8 @@ class APIServerConnectionBase { void on_ping_response(){}; void on_device_info_request(){}; + void on_device_capabilities_request(){}; + void on_list_entities_request(){}; void on_subscribe_states_request(){}; @@ -113,32 +115,32 @@ class APIServerConnectionBase { void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_device_request(const BluetoothDeviceRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_subscribe_bluetooth_connections_free_request(){}; #endif @@ -233,7 +235,7 @@ class APIServerConnectionBase { void on_serial_proxy_request(const SerialProxyRequest &value){}; #endif -#ifdef USE_BLUETOOTH_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){}; #endif }; diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 6e3448121c..ef5b43d7b1 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -123,7 +123,9 @@ void APIServer::setup() { // Best-effort: if the send buffer is full the reason is dropped, but the // client still learns the window is closed when it reconnects (rejected at // hello) or via the socket close. - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } @@ -394,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) { void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) { // We could add code to manage a second subscription type, but, since this message type is // very infrequent and small, we simply send it to all clients - for (auto &c : this->active_clients()) - c->send_message(msg); + for (auto &c : this->active_clients()) { + if (!c->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } + } } #endif @@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString ESP_LOGW(TAG, "Disconnecting all clients to reset PSK"); for (auto &c : this->active_clients()) { DisconnectRequest req; - c->send_message(req); + if (!c->send_message(req)) { + API_LOG_MSG_DROPPED(TAG, "Disconnect request"); + } } }); } diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py index d87f32fc36..1c522cbb5d 100644 --- a/esphome/components/audio/__init__.py +++ b/esphome/components/audio/__init__.py @@ -371,7 +371,7 @@ async def to_code(config): data.wav_support = True if data.micro_decoder_support: - add_idf_component(name="esphome/micro-decoder", ref="0.2.0") + add_idf_component(name="esphome/micro-decoder", ref="0.4.0") # All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash if not data.flac_support: @@ -380,6 +380,8 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False) if not data.opus_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False) + # Vorbis is unsupported in ESPHome, so always disable it + add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False) if not data.wav_support: add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False) diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.cpp b/esphome/components/bk72xx_ble/bk72xx_ble.cpp index 954cb9fe87..d40f08d111 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.cpp +++ b/esphome/components/bk72xx_ble/bk72xx_ble.cpp @@ -116,7 +116,7 @@ void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t add this->report_queue_.increment_dropped_count(); return; } - memcpy(report->mac, mac, 6); + memcpy(report->mac, mac, MAC_ADDRESS_SIZE); report->rssi = rssi; report->addr_type = addr_type; report->evt_type = evt_type; @@ -230,7 +230,7 @@ void BK72xxBLE::loop() { ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped); } -void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const { +void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { for (int i = 0; i < 6; i++) out[i] = this->ble_mac_[i]; } @@ -263,7 +263,7 @@ void BK72xxBLE::resolve_mac_() { } } if (nonzero) { - memcpy(this->ble_mac_, common_default_bdaddr.addr, 6); + memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE); return; } #endif @@ -275,10 +275,10 @@ void BK72xxBLE::resolve_mac_() { // (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it // matches on every device, including the last-byte == 0xFF edge that a 24-bit increment // would carry differently. - uint8_t wifi_mac[6]; + uint8_t wifi_mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(wifi_mac); // MSB-first - const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], - wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; + const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2], + wifi_mac[3], wifi_mac[4], static_cast(wifi_mac[5] + 1)}; // Store LSB-first to match recv_adv_t adv_addr ordering. for (int i = 0; i < 6; i++) this->ble_mac_[i] = ble[5 - i]; diff --git a/esphome/components/bk72xx_ble/bk72xx_ble.h b/esphome/components/bk72xx_ble/bk72xx_ble.h index 7646f17161..687fd396e4 100644 --- a/esphome/components/bk72xx_ble/bk72xx_ble.h +++ b/esphome/components/bk72xx_ble/bk72xx_ble.h @@ -40,8 +40,8 @@ struct ScanParams { /// One advertisement report from the controller. struct BLEScanReport { - uint8_t mac[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm uint8_t addr_type; // GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type // (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the @@ -83,7 +83,7 @@ class BK72xxBLE final : public Component { void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } /// Controller BLE address, least-significant octet first (BLE convention). - void get_mac_lsb_first(uint8_t out[6]) const; + void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; #ifdef BK72XX_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main task via loop()). @@ -135,13 +135,13 @@ class BK72xxBLE final : public Component { esphome::EventPool report_pool_; // Largest-to-smallest: padding only at the tail, absorbed by future byte fields. uint32_t last_advance_ms_{0}; - uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change - uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none - uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS - int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none - ScanParams requested_{}; // latched by scan_start() - ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts - uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention) + uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change + uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none + uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS + int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none + ScanParams requested_{}; // latched by scan_start() + ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention) uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX}; bool scan_wanted_{false}; // the latched request is to scan (vs stopped) bool release_warned_{false}; // gates the release WARN; widens the pump gate diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp index a312d2496f..1b4e6245ae 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.cpp @@ -364,7 +364,8 @@ bool BK72xxBLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // The controller reconciler restarts a running scan itself; the scan stays // logically running. An idle scanner picks the mode up on its next start. if (this->scan_running_) diff --git a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h index 59d17f9b84..2334cfe414 100644 --- a/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h +++ b/esphome/components/bk72xx_ble_tracker/bk72xx_ble_tracker.h @@ -116,8 +116,8 @@ class BK72xxBLETracker : public Component, bool request_scan_mode(bool active); // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) { - uint8_t mac[6]; + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; diff --git a/esphome/components/ble_device_base/ble_device.cpp b/esphome/components/ble_device_base/ble_device.cpp index fc5bf5c1e0..23ca6b1dbd 100644 --- a/esphome/components/ble_device_base/ble_device.cpp +++ b/esphome/components/ble_device_base/ble_device.cpp @@ -137,7 +137,7 @@ void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) { // BLEScanResult's bda is most-significant octet first; the neutral ingest // takes the BLE controller (LSB-first) order, so reverse — address_uint64()/ // address_str_to() then produce exactly the historical esp32 values. - uint8_t mac_lsb_first[6]; + uint8_t mac_lsb_first[MAC_ADDRESS_SIZE]; for (uint8_t i = 0; i < 6; i++) mac_lsb_first[i] = scan_result.bda[5 - i]; this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv, diff --git a/esphome/components/ble_device_base/ble_device.h b/esphome/components/ble_device_base/ble_device.h index b5f198375c..668f7e09f8 100644 --- a/esphome/components/ble_device_base/ble_device.h +++ b/esphome/components/ble_device_base/ble_device.h @@ -241,7 +241,7 @@ class ESPBTDevice { // the 2-byte element header); every in-tree tracker scans legacy PDUs only. static constexpr uint8_t MAX_ADV_NAME_LEN = 29; - uint8_t address_[6]{0}; + uint8_t address_[MAC_ADDRESS_SIZE]{0}; uint8_t address_type_{0}; int rssi_{0}; // Fixed buffer instead of std::string: no per-advertisement heap churn on diff --git a/esphome/components/ble_device_base/scan_response_merger.cpp b/esphome/components/ble_device_base/scan_response_merger.cpp index 2dd1fd6927..2c0d766683 100644 --- a/esphome/components/ble_device_base/scan_response_merger.cpp +++ b/esphome/components/ble_device_base/scan_response_merger.cpp @@ -2,6 +2,8 @@ #ifdef USE_BLE_SCAN_RESPONSE_MERGER +#include "esphome/core/helpers.h" + #include namespace esphome::ble_device_base { @@ -27,7 +29,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr free_slot = &p; continue; } - if (p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + if (p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { // Same device advertised again before its scan response arrived — deliver // the previous advertisement (its scan response is not coming) and reuse // the slot, so no frame is ever lost. @@ -47,7 +49,7 @@ void ScanResponseMerger::stash_adv(const uint8_t *mac, int8_t rssi, uint8_t addr } slot->used = true; this->pending_count_++; - memcpy(slot->mac, mac, 6); + memcpy(slot->mac, mac, MAC_ADDRESS_SIZE); slot->addr_type = addr_type; slot->rssi = rssi; slot->data_len = (data_len <= sizeof(slot->data)) ? data_len : sizeof(slot->data); @@ -61,7 +63,7 @@ void ScanResponseMerger::submit_scan_rsp(const uint8_t *mac, int8_t rssi, uint8_ // hottest caller. if (this->pending_count_ != 0) { for (auto &p : this->pending_adv_) { - if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, 6) == 0) { + if (p.used && p.addr_type == addr_type && memcmp(p.mac, mac, MAC_ADDRESS_SIZE) == 0) { // Append in place: the slot is released on delivery, so its 62-byte // buffer (legacy adv + scan response) holds the merged frame directly. const uint8_t room = sizeof(p.data) - p.data_len; diff --git a/esphome/components/ble_device_base/scan_response_merger.h b/esphome/components/ble_device_base/scan_response_merger.h index 9415664fcf..f28790f207 100644 --- a/esphome/components/ble_device_base/scan_response_merger.h +++ b/esphome/components/ble_device_base/scan_response_merger.h @@ -120,7 +120,7 @@ class ScanResponseMerger { // as ESP-IDF delivers on ESP32. struct PendingAdv { bool used{false}; - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; uint8_t addr_type; int8_t rssi; uint8_t data_len; // <= sizeof(data) diff --git a/esphome/components/bluetooth_connection/__init__.py b/esphome/components/bluetooth_connection/__init__.py index ce9b2acf0d..e6c8de0697 100644 --- a/esphome/components/bluetooth_connection/__init__.py +++ b/esphome/components/bluetooth_connection/__init__.py @@ -11,6 +11,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass, field import esphome.codegen as cg +from esphome.components import rp2040_ble from esphome.config_helpers import ( filter_source_files_from_platform, frameworks_for_platforms, @@ -39,11 +40,12 @@ CODEOWNERS = ["@bdraco", "@jesserockz"] bluetooth_connection_ns = cg.esphome_ns.namespace("bluetooth_connection") -DOMAIN = "bluetooth_connection" - -# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1; -# raising this needs an upstream change (the layer itself supports N). -RP2_MAX_CONNECTIONS = 1 +# arduino-pico's prebuilt BTstack is compiled with MAX_NR_GATT_CLIENTS 1 and +# MAX_NR_HCI_CONNECTIONS 2; for more than one backend, rp2040_ble's +# btstack_memory.cpp replaces those pools via linker --wrap (requested by +# _rp2_register), sized from ESPHOME_BLE_GATT_CLIENT_COUNT. The cap itself +# belongs to the platform stack that owns the pools. +RP2_MAX_CONNECTIONS = rp2040_ble.MAX_CONNECTIONS # Slot limits for the hub platforms running the connection-capable proxy; # the backend registry itself is _PLATFORM_BACKENDS below. @@ -58,6 +60,21 @@ BluedroidGattClient = bluetooth_connection_ns.class_( CONF_BACKEND_ID = "backend_id" +DOMAIN = "bluetooth_connection" + + +@dataclass +class _ConnectionData: + rp2_backend_count: int = 0 + # GATT connection slots claimed this run, for the platform cap check. + slot_consumers: list[str] = field(default_factory=list) + + +def _get_data() -> _ConnectionData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = _ConnectionData() + return CORE.data[DOMAIN] + def _esp32_schema_fragment() -> cv.Schema: from esphome.components import esp32_ble_tracker @@ -66,8 +83,6 @@ def _esp32_schema_fragment() -> cv.Schema: def _rp2_schema_fragment() -> cv.Schema: - from esphome.components import rp2040_ble - return cv.Schema( {cv.GenerateID(rp2040_ble.CONF_RP2040_BLE_ID): cv.use_id(rp2040_ble.RP2040BLE)} ) @@ -82,15 +97,29 @@ async def _esp32_register(backend: cg.MockObj, config: ConfigType) -> None: async def _rp2_register(backend: cg.MockObj, config: ConfigType) -> None: - from esphome.components import rp2040_ble + from esphome.components import ota + # The backend drops its link when an OTA starts (esp32 tracker parity). + ota.request_ota_state_listeners() + # More than one backend outgrows the prebuilt BTstack pools: swap them for + # the ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in rp2040_ble's + # btstack_memory.cpp. Keyed to backend registrations (the same event that + # grows the count that sizes the pools), so single-backend builds emit no + # flags and stay byte-identical to previous releases. + data = _get_data() + data.rp2_backend_count += 1 + if data.rp2_backend_count == 2: + rp2040_ble.add_btstack_pool_overrides() await cg.register_parented(backend, config[rp2040_ble.CONF_RP2040_BLE_ID]) @dataclass(frozen=True) class _PlatformBackend: - """One platform's backend: codegen class, extra schema keys (lazy so the - platform stack is only imported when targeted), and stack registration.""" + """One platform's backend: codegen class, extra schema keys, and stack + registration. The esp32 fragments import their stack lazily because those + imports register esp32-only automations as a side effect; rp2040_ble is + side-effect-free, so it is imported at module scope (the cap constant + needs it there anyway).""" backend_class: cg.MockObjClass schema_fragment: Callable[[], cv.Schema] @@ -144,33 +173,23 @@ def hub_connection_schema(platform: str | None = None) -> cv.Schema: ) -@dataclass -class _SlotLedger: - """GATT connection slots claimed this run, for the platform cap check.""" - - consumers: list[str] = field(default_factory=list) - - -def _ledger() -> _SlotLedger: - if DOMAIN not in CORE.data: - CORE.data[DOMAIN] = _SlotLedger() - return CORE.data[DOMAIN] - - def consume_gatt_slot( consumer: str, count: int = 1 ) -> Callable[[ConfigType], ConfigType]: """Validator claiming GATT connection slots - the one spelling for every claimant. The neutral ledger feeds the hub-platform cap check in - FINAL_VALIDATE_SCHEMA; esp32 additionally charges the controller's - connection budget (its cap lives there, not in HUB_MAX_CONNECTIONS).""" + FINAL_VALIDATE_SCHEMA; esp32 and rp2 additionally charge their platform + stack's connection budget (esp32's cap lives there, not in + HUB_MAX_CONNECTIONS).""" def validator(config: ConfigType) -> ConfigType: - _ledger().consumers.extend([consumer] * count) + _get_data().slot_consumers.extend([consumer] * count) if CORE.is_esp32: from esphome.components import esp32_ble esp32_ble.consume_connection_slots(count, consumer)(config) + elif CORE.target_platform == PLATFORM_RP2: + rp2040_ble.consume_connection_slots(count, consumer)(config) return config return validator @@ -196,7 +215,7 @@ def _validate_slot_totals(config: ConfigType) -> ConfigType: "in HUB_MAX_CONNECTIONS" ) return config - claimed = _ledger().consumers + claimed = _get_data().slot_consumers if len(claimed) > cap: raise cv.Invalid( f"{CORE.target_platform} supports at most {cap} GATT client " diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.cpp b/esphome/components/bluetooth_connection/bluetooth_connection.cpp index 29b75a945e..a001729083 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection.cpp @@ -5,7 +5,7 @@ #include #endif -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/api/api_pb2.h" #include "esphome/core/log.h" @@ -44,14 +44,13 @@ BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_SERVES_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) namespace esphome::bluetooth_connection { -// Address-scoped Bluedroid maintenance shared by every esp32 proxy build, -// including advertisement-only ones where no GATT backend (and none of the -// gated surface above) is compiled - so this block sits outside that gate. +// Address-scoped Bluedroid maintenance. Gated with the connection surface: +// the advertisement-only arm no longer dispatches these requests at all. conn_err_t unpair_device(uint64_t address) { esp_bd_addr_t bda; @@ -66,4 +65,4 @@ conn_err_t clear_gatt_cache(uint64_t address) { } } // namespace esphome::bluetooth_connection -#endif // USE_ESP32 +#endif // USE_ESP32 && USE_BLE_GATT_CLIENT diff --git a/esphome/components/bluetooth_connection/bluetooth_connection.h b/esphome/components/bluetooth_connection/bluetooth_connection.h index a69f2cf7db..b21d997b4f 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection.h @@ -16,18 +16,14 @@ #include #endif -// The proxy-serving surface is compiled: a proxy is present and a GATT -// backend is wired by codegen (one slot per connection). This is the single -// spelling of that predicate - the hub wrapper, the connection-aware API -// request handlers, and the Bluedroid in-place streamer all gate on it. -// Builds without a compiled backend get the clean-error handlers (a -// passive proxy alongside a backend consumer compiles the real ones); -// address-scoped maintenance (unpair, cache clear) still works there through -// the per-platform free functions below. Backend-only builds (a dedicated-backend -// consumer without bluetooth_proxy) compile none of this API surface. -#if defined(USE_BLE_GATT_CLIENT) && defined(USE_BLUETOOTH_PROXY) -#define BLUETOOTH_CONNECTION_SERVES_PROXY -#endif +// USE_BLUETOOTH_PROXY_CONNECTIONS is the single spelling of "this build has +// proxy connection slots": codegen emits it per configured slot, and each +// slot brings a GATT backend, so it also implies USE_BLE_GATT_CLIENT (not +// the converse: a backend can exist without proxy slots). The hub +// wrapper, the proxy's connection surface and the API's connection messages +// all gate on it. The address-scoped maintenance functions below are only +// reached from that gated surface; the #else stubs just keep this header +// parsing on arms without a backend. namespace esphome::api { class BluetoothGATTGetServicesResponse; @@ -69,12 +65,12 @@ static constexpr bool SUPPORTS_CACHE_CLEARING = false; #endif // Address-scoped (not connection-scoped) maintenance requests. -#if defined(USE_ESP32) || (defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT)) +#if (defined(USE_ESP32) || defined(USE_RP2040_BLE)) && defined(USE_BLE_GATT_CLIENT) conn_err_t unpair_device(uint64_t address); #else inline conn_err_t unpair_device(uint64_t) { return GATT_NOT_CONNECTED; } #endif -#ifdef USE_ESP32 +#if defined(USE_ESP32) && defined(USE_BLE_GATT_CLIENT) conn_err_t clear_gatt_cache(uint64_t address); #else inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } @@ -83,6 +79,20 @@ inline conn_err_t clear_gatt_cache(uint64_t) { return GATT_NOT_CONNECTED; } // send_service_ cursor states; >= 0 is the next service index to stream. static constexpr int DONE_SENDING_SERVICES = -2; static constexpr int INIT_SENDING_SERVICES = -3; +static constexpr int SERVICES_DONE_PENDING = -4; // all batches delivered, done-message still owed +// Every sentinel must stay below the >= 0 streaming gate and clear of +// GATT_NOT_CONNECTED (-1) so cursor and error values can never be confused. +static_assert(DONE_SENDING_SERVICES < 0 && INIT_SENDING_SERVICES < 0 && SERVICES_DONE_PENDING < 0); +static_assert(DONE_SENDING_SERVICES != GATT_NOT_CONNECTED && INIT_SENDING_SERVICES != GATT_NOT_CONNECTED && + SERVICES_DONE_PENDING != GATT_NOT_CONNECTED); +// Owed-done retries stop here (~3 s at the 100 ms drain cadence): a done +// delivered near the client's 30 s timeout could land on a fresh request's +// empty accumulator and cache as an empty database. +static constexpr uint8_t SERVICES_DONE_RETRY_LIMIT = 30; +// Owed-ack retries stop after ~25 s of subscribed drain time from the first +// refusal, keeping most of the client's 30 s GATT window for congestion to +// clear while still bounding how stale a delivered reply can be. +static constexpr uint16_t PENDING_ACK_RETRY_LIMIT = 250; // ---- Service-streaming size budget, shared by every platform's streamer ---- @@ -142,7 +152,7 @@ inline void fill_gatt_uuid(std::array &uuid_128, uint32_t &short_uu } } -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// Result of close_service_batch: keep filling the batch or send it now. /// An oversized service is packed alone; a failed (backpressured) send is /// retried from the batch start, so no service is silently skipped. @@ -154,6 +164,6 @@ enum class BatchClose : uint8_t { CONTINUE, SEND }; /// cannot drift. BatchClose close_service_batch(api::BluetoothGATTGetServicesResponse &resp, size_t ¤t_size, int16_t &send_service, uint8_t connection_index, const char *address_str); -#endif // BLUETOOTH_CONNECTION_SERVES_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS } // namespace esphome::bluetooth_connection diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp index 0054bb4894..35ce8164cf 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.cpp @@ -6,7 +6,7 @@ // The in-place streamer serves the proxy's service-discovery API; backend-only // builds compile without the proxy headers or the streamer. -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "bluetooth_connection_hub.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -600,12 +600,14 @@ void BluedroidGattClient::deliver_pending_search_() { this->listener_->on_service_discovery_done(this->search_status_); } -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // The wrapper's compile-time streamer detection must keep finding this // method; a signature drift would silently fall back to the table streamer, // which proxy builds compile without a materializer. static_assert(requires(BluedroidGattClient c, BluetoothConnection &conn) { c.stream_service_batch(conn); }); +// Bound by the SERVICE STREAMING HAZARD note at the top of +// bluetooth_connection_hub.cpp: never skip a batch, never send done early. void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { if (this->services_released_) { // Released under the stream: park without services-done so a partial @@ -616,20 +618,16 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { return; } if (conn.send_service_ >= this->service_total_) { - conn.send_service_ = DONE_SENDING_SERVICES; - conn.proxy_->send_gatt_services_done(conn.address_); this->release_services(); + conn.send_services_done_(); return; } - // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (a resubscribing client gets silence and its 30 s - // timeout, never an authoritative partial list). + // The subscriber vanished mid-stream. auto *api_conn = conn.proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", conn.connection_index_, conn.address_str_); - conn.send_service_ = DONE_SENDING_SERVICES; - this->release_services(); + conn.park_service_stream_(); return; } @@ -740,11 +738,13 @@ void BluedroidGattClient::stream_service_batch(BluetoothConnection &conn) { // On a failed send, rewind the cursor so the batch is retried instead of // silently skipped. if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", conn.connection_index_, conn.address_str_); + conn.note_batch_stalled_(); conn.send_service_ = batch_start; + return; } + conn.batch_stalled_ = false; } -#endif // BLUETOOTH_CONNECTION_SERVES_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS // ---- events ---- diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h index 3fd4dc67e7..7595574611 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_bluedroid.h @@ -22,7 +22,7 @@ namespace esphome::bluetooth_connection { -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS class BluetoothConnection; #endif @@ -86,7 +86,7 @@ class BluedroidGattClient final : public esp32_ble_tracker::ESPBTClient, public #endif void release_services(); -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS /// In-place service streamer (the proxy wrapper detects and prefers it): /// builds one api response batch directly from Bluedroid's cached database, /// so the streaming peak is the response itself - the old esp32 model. diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp index 173f1ca8f2..c8f97f207e 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.cpp @@ -1,7 +1,27 @@ // The proxy's per-slot connection wrapper, shared by every platform. +// +// SERVICE STREAMING HAZARD - read before touching the streaming code here or +// in the platform streamers (bluetooth_connection_bluedroid.cpp). +// +// A V3 client caches the service list it receives as the device's complete, +// permanent database. Nothing on the wire marks a list as partial, so a +// stream that is truncated, has a skipped batch, or is terminated early +// would be cached whole and poison every later session with the device. +// +// The rule: it is always better to send nothing and let the client time out +// than to let services-done follow an incomplete stream. Concretely: +// - a refused batch rewinds the cursor and is retried, never skipped; +// - services-done is sent only after every batch was accepted; +// - every interruption (subscriber lost or swapped, backend abort, +// bounds-check failure) parks or aborts WITHOUT services-done and drops +// any owed done; +// - a new GetServices supersedes an owed done, so a stale done can never +// land on a fresh request's empty accumulator and cache it as empty. +// The client only caches a list terminated by services-done within the same +// request; timeouts, disconnects and errors raise instead of caching. #include "bluetooth_connection_hub.h" -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/api/api_pb2.h" #include "esphome/components/bluetooth_proxy/bluetooth_proxy.h" @@ -16,12 +36,15 @@ static const char *const TAG = "bluetooth_connection"; void BluetoothConnection::set_address(uint64_t address) { // Keep the proxy's pre-allocated connections-free message in step this->proxy_->update_address_slot_(this->address_, address); + // Slot changing hands: anything owed belonged to the old address. The + // choke point for every reassignment, not just reset_connection_()'s path. + this->clear_owed_flags_(); this->address_ = address; if (address == 0) { this->address_str_[0] = '\0'; return; } - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; ble_device_base::uint64_to_mac_msb_first(address, mac); format_mac_addr_upper(mac, this->address_str_); } @@ -73,6 +96,8 @@ void BluetoothConnection::reset_connection_(conn_err_t reason) { this->state_ = ClientState::IDLE; this->services_discovered_ = false; this->paired_ = false; + // Link gone: the slot may hold a different device before the drain runs. + this->clear_owed_flags_(); this->backend_->release_services(); this->proxy_->reset_connection_slot_(this, reason); } @@ -103,11 +128,15 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int if (this->connection_type_ == ConnectionType::V3_WITH_CACHE) { // The API client has the services cached; never discover them. No // discovery phase needs the fast interval, so settle straight into the - // shared steady-state parameters. On esp32 the backend already set the - // same values as prefer-params before opening, so this request is - // usually redundant there - kept because rp2 has no prefer-params and - // the explicit update is its only path to the steady-state interval. + // shared steady-state parameters. Both backends already open cached + // connections with these values (esp32 prefer-params, rp2 initiating + // params), so this request is normally redundant - kept as a backstop + // in case the initial parameters were negotiated away. this->state_ = ClientState::ESTABLISHED; + // The one D-level line for a cached connect; the uncached path narrates + // through "Discovery finished" instead. + ESP_LOGD(TAG, "[%d] [%s] Connected with cached services, sending connected (mtu=%u)", this->connection_index_, + this->address_str_, mtu); int param_err = this->backend_->update_connection_params(ble_device_base::MEDIUM_MIN_CONN_INTERVAL, ble_device_base::MEDIUM_MAX_CONN_INTERVAL, 0, ble_device_base::MEDIUM_CONN_TIMEOUT); @@ -116,7 +145,7 @@ void BluetoothConnection::on_connection_state(bool connected, uint16_t mtu, int ESP_LOGW(TAG, "[%d] [%s] conn param update failed, err=%d", this->connection_index_, this->address_str_, param_err); } - this->proxy_->send_device_connection(this->address_, true, mtu); + this->send_connected_reply_(); this->proxy_->send_connections_free(); return; } @@ -154,22 +183,136 @@ void BluetoothConnection::on_service_discovery_done(int error) { this->mtu_); this->state_ = ClientState::ESTABLISHED; this->services_discovered_ = true; - this->proxy_->send_device_connection(this->address_, true, this->mtu_); + this->send_connected_reply_(); this->proxy_->send_connections_free(); } +void BluetoothConnection::flush_owed_replies_() { + // Connected first: the client should never see services-done or an ack for + // a link it has not been told is up. Structural, not size-dependent: a + // still-owed connected reply defers the smaller sends to the next tick. + if (this->connected_reply_owed_) { + this->send_connected_reply_(); + if (this->connected_reply_owed_) { + // The retry limits are wall-clock windows: age the deferred budgets so + // a reply cannot outlive the window it was sized for. + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->age_services_done_(); + } + if (this->has_pending_ack_()) { + this->age_pending_ack_(); + } + return; + } + } + if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_services_done_(); + } + if (this->has_pending_ack_()) { + this->flush_pending_ack_(); + } +} + +void BluetoothConnection::send_connected_reply_() { + if (this->proxy_->send_device_connection(this->address_, true, this->mtu_)) { + this->connected_reply_owed_ = false; + return; + } + // Warn on the leading edge only, as elsewhere: the drop must be visible but + // must not add traffic to the connection that just refused a frame. + if (!this->connected_reply_owed_) { + ESP_LOGW(TAG, "[%d] [%s] Connected reply deferred, TCP buffer full", this->connection_index_, this->address_str_); + this->connected_reply_owed_ = true; + } +} + void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, int status) { ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str_, operation, handle, status); } +void BluetoothConnection::note_batch_stalled_() { + if (this->batch_stalled_) + return; + this->batch_stalled_ = true; + ESP_LOGW(TAG, "[%d] [%s] Service batch deferred, TCP buffer full; retrying", this->connection_index_, + this->address_str_); +} + +/// Both payload-free acks are just (address, handle); only the type differs. +template +static bool send_handle_reply(api::APIConnection *api_connection, uint64_t address, uint16_t handle) { + Response resp; + resp.address = address; + resp.handle = handle; + return api_connection->send_message(resp); +} + +/// Sole construction site, so a re-offer cannot drift from the original. +bool BluetoothConnection::try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (kind == PendingAck::PENDING_ACK_ERROR) { + // Proxy owns the error reply and reports a refusal the same way. + return this->proxy_->send_gatt_error(this->address_, handle, error); + } + auto *api_connection = this->proxy_->get_api_connection(); + if (api_connection == nullptr) + return true; // Nobody subscribed: nothing is owed + switch (kind) { + case PendingAck::PENDING_ACK_WRITE: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NOTIFY: + return send_handle_reply(api_connection, this->address_, handle); + case PendingAck::PENDING_ACK_NONE: + case PendingAck::PENDING_ACK_ERROR: // returned above + return true; + } + // No default label above, so a new enumerator is a -Wswitch warning rather + // than a silent notify reply. This return only satisfies -Wreturn-type. + return true; +} + +void BluetoothConnection::send_ack_(PendingAck kind, uint16_t handle, conn_err_t error) { + if (this->try_send_ack_(kind, handle, error)) + return; + // Report a newly owed reply and a displaced one; displacing is the case + // that loses a reply. Re-refusing the same one stays quiet. + if (!this->has_pending_ack_()) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X deferred, TCP buffer full", this->connection_index_, + this->address_str_, handle); + } else if (this->pending_ack_handle_ != handle || this->pending_ack_ != kind) { + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X dropped for handle 0x%04X", this->connection_index_, + this->address_str_, this->pending_ack_handle_, handle); + } + this->latch_pending_ack_(kind, handle, error); +} + +void BluetoothConnection::flush_pending_ack_() { + if (!this->has_pending_ack_()) + return; + if (this->try_send_ack_(this->pending_ack_, this->pending_ack_handle_, this->pending_ack_error_)) { + this->clear_pending_ack_(); + return; + } + this->age_pending_ack_(); +} + +void BluetoothConnection::age_pending_ack_() { + if (++this->pending_ack_retries_ >= PENDING_ACK_RETRY_LIMIT) { + // Undeliverable: past here the client has given up and may have re-asked, + // and a late reply would answer the new request instead of this one. + ESP_LOGW(TAG, "[%d] [%s] GATT reply for handle 0x%04X undeliverable, abandoning", this->connection_index_, + this->address_str_, this->pending_ack_handle_); + this->clear_pending_ack_(); + } +} + void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, uint16_t len, int error) { // Late completion for a freed slot; nothing to report. if (this->address_ == 0) return; if (error != 0) { this->log_gatt_operation_error_("reading char/descriptor", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } auto *api_connection = this->proxy_->get_api_connection(); @@ -180,6 +323,8 @@ void BluetoothConnection::on_read_result(uint16_t handle, const uint8_t *data, u resp.handle = handle; resp.set_data(data, len); if (!api_connection->send_message(resp)) { + // Not latched: would mean holding the payload through the congestion + // that refused it. The client's read timeout arbitrates. ESP_LOGW(TAG, "[%d] [%s] Failed to send read response", this->connection_index_, this->address_str_); } } @@ -189,18 +334,10 @@ void BluetoothConnection::on_write_result(uint16_t handle, int error) { return; if (error != 0) { this->log_gatt_operation_error_("writing char/descriptor", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - return; - api::BluetoothGATTWriteResponse resp; - resp.address = this->address_; - resp.handle = handle; - if (!api_connection->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send write response", this->connection_index_, this->address_str_); - } + this->send_ack_(PendingAck::PENDING_ACK_WRITE, handle); } void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int error) { @@ -209,18 +346,10 @@ void BluetoothConnection::on_notify_state(uint16_t handle, bool enabled, int err if (error != 0) { this->log_gatt_operation_error_(enabled ? "registering notifications" : "unregistering notifications", handle, error); - this->proxy_->send_gatt_error(this->address_, handle, error); + this->send_gatt_error_(handle, error); return; } - auto *api_connection = this->proxy_->get_api_connection(); - if (api_connection == nullptr) - return; - api::BluetoothGATTNotifyResponse resp; - resp.address = this->address_; - resp.handle = handle; - if (!api_connection->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send notify state response", this->connection_index_, this->address_str_); - } + this->send_ack_(PendingAck::PENDING_ACK_NOTIFY, handle); } void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, uint16_t len) { @@ -235,6 +364,8 @@ void BluetoothConnection::on_notify_data(uint16_t handle, const uint8_t *data, u resp.handle = handle; resp.set_data(data, len); if (!api_connection->send_message(resp)) { + // Not latched, same reason as the read reply. Notify data is lossy: the + // peripheral will not resend it. ESP_LOGW(TAG, "[%d] [%s] Failed to send notify data response", this->connection_index_, this->address_str_); } } @@ -251,6 +382,7 @@ conn_err_t BluetoothConnection::check_connected_op_(const char *action, const ch } conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); if (conn_err_t err = this->check_connected_op_("read", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -259,6 +391,7 @@ conn_err_t BluetoothConnection::read_characteristic(uint16_t handle) { conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8_t *data, size_t length, bool response) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); if (conn_err_t err = this->check_connected_op_("write", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); @@ -266,6 +399,7 @@ conn_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint } conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NONE); if (conn_err_t err = this->check_connected_op_("read", "descriptor"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -276,6 +410,7 @@ conn_err_t BluetoothConnection::read_descriptor(uint16_t handle) { // the response flag is intentionally ignored (esp32 maps it to RSP/NO_RSP). conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t *data, size_t length, bool /*response*/) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_WRITE); if (conn_err_t err = this->check_connected_op_("write", "descriptor"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); @@ -283,6 +418,7 @@ conn_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t } conn_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enable) { + this->supersede_pending_ack_(handle, PendingAck::PENDING_ACK_NOTIFY); if (conn_err_t err = this->check_connected_op_("notify", "characteristic"); err != CONN_OK) return err; ESP_LOGV(TAG, "[%d] [%s] %s GATT characteristic notifications handle %d", this->connection_index_, this->address_str_, @@ -299,25 +435,45 @@ conn_err_t BluetoothConnection::update_connection_params(uint16_t min_interval, // ---- Service streaming ---- +void BluetoothConnection::send_services_done_() { + if (this->proxy_->send_gatt_services_done(this->address_)) { + // Sent, or subscriber gone (park silently; its timeout arbitrates). + this->send_service_ = DONE_SENDING_SERVICES; + return; + } + if (this->send_service_ != SERVICES_DONE_PENDING) { + // Warn on the transition only; retries stay silent. + ESP_LOGW(TAG, "[%d] [%s] Failed to send services done, retrying", this->connection_index_, this->address_str_); + this->services_done_retries_ = 0; + this->send_service_ = SERVICES_DONE_PENDING; + } else { + this->age_services_done_(); + } +} + +void BluetoothConnection::age_services_done_() { + if (++this->services_done_retries_ >= SERVICES_DONE_RETRY_LIMIT) { + // Undeliverable (see SERVICES_DONE_RETRY_LIMIT); silence arbitrates. + ESP_LOGW(TAG, "[%d] [%s] Services done undeliverable, abandoning", this->connection_index_, this->address_str_); + this->send_service_ = DONE_SENDING_SERVICES; + } +} + void BluetoothConnection::send_service_for_discovery_() { auto table = this->backend_->get_service_table(); if (this->send_service_ >= table.service_count) { - this->send_service_ = DONE_SENDING_SERVICES; - this->proxy_->send_gatt_services_done(this->address_); this->backend_->release_services(); + this->send_services_done_(); return; } - // The subscriber vanished mid-stream: park the cursor at done WITHOUT - // sending services-done (a resubscribing client gets silence and its 30 s - // timeout, never an authoritative partial list) and free the table; the - // api-gone sweep tears the connection down anyway. + // The subscriber vanished mid-stream; the api-gone sweep tears the + // connection down anyway. auto *api_conn = this->proxy_->get_api_connection(); if (api_conn == nullptr) { ESP_LOGW(TAG, "[%d] [%s] API connection lost while streaming services", this->connection_index_, this->address_str_); - this->send_service_ = DONE_SENDING_SERVICES; - this->backend_->release_services(); + this->park_service_stream_(); return; } @@ -399,11 +555,13 @@ void BluetoothConnection::send_service_for_discovery_() { // (bounded: a subscriber that stays gone ends streaming via the api-lost // rewind above). if (!api_conn->send_message(resp)) { - ESP_LOGW(TAG, "[%d] [%s] Failed to send service batch, retrying", this->connection_index_, this->address_str_); + this->note_batch_stalled_(); this->send_service_ = batch_start; + return; } + this->batch_stalled_ = false; } } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_SERVES_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h index ffa58ef9af..f87d545f7d 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_hub.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_hub.h @@ -10,7 +10,7 @@ // The wrapper exists to serve the proxy's API surface; direct consumers // drive the backend themselves, so backend-only builds compile this header // empty. -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS #include "esphome/components/ble_device_base/ble_client_state.h" #include "bluetooth_connection_gatt_backend.h" @@ -25,6 +25,16 @@ namespace esphome::bluetooth_connection { using ClientState = ble_device_base::ClientState; using ConnectionType = ble_device_base::ConnectionType; +/// A refused GATT reply owed to the current subscriber. Payload-free only: +/// these rebuild from address + handle + error, so a retry costs no buffered +/// data. Read and notify-data carry payloads and are deliberately absent. +enum class PendingAck : uint8_t { + PENDING_ACK_NONE = 0, + PENDING_ACK_WRITE, + PENDING_ACK_NOTIFY, + PENDING_ACK_ERROR, +}; + class BluetoothConnection final : public ble_device_base::GattClientListener { public: /// Wire the platform backend. Called from codegen before setup. @@ -77,8 +87,9 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { bool connected() const { return this->state_ == ClientState::ESTABLISHED; } void set_connection_type(ConnectionType ct) { this->connection_type_ = ct; - // The bluedroid backend branches on the type itself (prefer-params and - // the with-cache report at OPEN_EVT); the others ignore it. + // Both backends branch on the type before connecting (bluedroid picks + // prefer-params and the with-cache report at OPEN_EVT; rp2 picks the + // initiating parameters), so this must be set before the connect starts. this->backend_->set_connection_type(ct); } // Latched at discovery completion rather than read from the backend table: @@ -115,6 +126,58 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { this->pending_error_ = err; } } + + /// Latch a refused reply for the proxy drain. One slot per connection, + /// newest wins: a GATT client works one request at a time, and a discarded + /// reply falls back to the timeout it would have hit anyway. + void latch_pending_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0) { + this->pending_ack_retries_ = 0; + this->pending_ack_ = kind; + this->pending_ack_handle_ = handle; + this->pending_ack_error_ = error; + } + void clear_pending_ack_() { this->pending_ack_ = PendingAck::PENDING_ACK_NONE; } + /// Drop an owed reply this re-ask makes stale. Clients match futures on + /// response type as well as handle, so an owed error (which resolves any op + /// on the handle) is cleared by any re-ask, other kinds only by their own. + void supersede_pending_ack_(uint16_t handle, PendingAck kind) { + if (this->has_pending_ack_() && this->pending_ack_handle_ == handle && + (this->pending_ack_ == PendingAck::PENDING_ACK_ERROR || this->pending_ack_ == kind)) { + this->clear_pending_ack_(); + } + } + bool has_pending_ack_() const { return this->pending_ack_ != PendingAck::PENDING_ACK_NONE; } + /// Warn on the stall's leading edge only. The batch is never lost (the + /// caller rewinds the cursor), and a warning per attempt would add traffic + /// to the connection already refusing frames. Both streamers route here. + void note_batch_stalled_(); + /// Send the connected=true reply, latching it if the API refuses. Rebuilt + /// from address_ and mtu_, so the latch is one bit; a dropped confirmation + /// leaves the client timing out while this slot holds a live link. No retry + /// bound: the slot's lifetime is the bound (teardown clears the flag). + void send_connected_reply_(); + /// Re-offer everything this slot owes. One entry point so the proxy drain + /// does not have to know which latches exist. + void flush_owed_replies_(); + /// Drop everything this slot owes, in one write to the shared tail byte. + void clear_owed_flags_() { + this->pending_ack_ = PendingAck::PENDING_ACK_NONE; + this->batch_stalled_ = false; + this->connected_reply_owed_ = false; + } + /// Sole construction site for these replies, shared by send and retry. + bool try_send_ack_(PendingAck kind, uint16_t handle, conn_err_t error); + /// First attempt: send, and latch it for the drain if the API refuses. + void send_ack_(PendingAck kind, uint16_t handle, conn_err_t error = 0); + /// Report a rejected request. Latched like a completion reply, so a + /// refused frame does not strand the client for its whole timeout. + void send_gatt_error_(uint16_t handle, conn_err_t error) { + this->send_ack_(PendingAck::PENDING_ACK_ERROR, handle, error); + } + /// Re-offer the owed reply; clears on success, stays owed on a refusal. + void flush_pending_ack_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_pending_ack_(); // A backend providing its own streamer (see the contract doc) builds the // response in place from its stack cache; the rest use the table streamer. // Template so the discarded branch is not odr-checked against backends @@ -126,7 +189,26 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { this->send_service_for_discovery_(); } } + /// Park the stream without services-done and free any held table: an + /// interrupted stream must never be declared complete (the client's + /// timeout arbitrates), and an owed done is dropped with it. + void park_service_stream_() { + this->batch_stalled_ = false; + if (this->send_service_ >= 0) { + this->backend_->release_services(); + this->send_service_ = DONE_SENDING_SERVICES; + } else if (this->send_service_ == SERVICES_DONE_PENDING) { + this->send_service_ = DONE_SENDING_SERVICES; + } + } void send_service_for_discovery_(); + /// Send services-done and settle the cursor: DONE when it lands (or no + /// subscriber), SERVICES_DONE_PENDING on a refused frame (proxy drain + /// retries). Callers release the table first; the message needs only the + /// address. + void send_services_done_(); + /// Advance the retry budget and abandon at the limit, without sending. + void age_services_done_(); void reset_connection_(conn_err_t reason); conn_err_t check_connected_op_(const char *action, const char *type) const; void log_gatt_operation_error_(const char *operation, uint16_t handle, int status); @@ -136,28 +218,57 @@ class BluetoothConnection final : public ble_device_base::GattClientListener { bluetooth_proxy::BluetoothProxy *proxy_{nullptr}; ble_device_base::BLEGattConnection *backend_{nullptr}; - // Group 2: 2-byte types + // Group 2: 2-byte types. Exactly 4 bytes, so address_ below stays + // 8-aligned with no padding (the vptr makes Group 1 12 bytes, not 8). int16_t send_service_{INIT_SENDING_SERVICES}; uint16_t mtu_{ble_device_base::DEFAULT_ATT_MTU}; // Group 3: 8-byte and 4-byte types uint64_t address_{0}; conn_err_t pending_error_{0}; + // Full width: the GATT error domain is open-ended (ble_gatt_client.h) and + // forwarded untranslated, so narrowing would corrupt platform codes. + conn_err_t pending_ack_error_{0}; // Group 4: Arrays char address_str_[MAC_ADDRESS_PRETTY_BUFFER_SIZE]{}; + // Parked here rather than in Group 2: address_str_ ends 2-aligned, so this + // uses tail slack instead of pushing address_ out by 6 bytes of padding. + uint16_t pending_ack_handle_{0}; - // Group 5: bit-packed tail; within 2 bytes the 8-aligned object stays 48. + // Group 5: bit-packed tail. The first two bytes were already full, so the + // first added bit forced a third and took the 8-aligned object 48 -> 56; + // the handle, error and retry counter ride in that padding. Four bitfield + // bits left; another byte-sized member costs 8 per slot. static_assert(static_cast(ClientState::ESTABLISHED) < (1 << 3), "state_ bitfield too narrow"); static_assert(static_cast(ConnectionType::V3_WITHOUT_CACHE) < (1 << 2), "connection_type_ bitfield too narrow"); + // Ordered so neither byte's fields straddle a storage unit: 3+5 and + // 4+2+1+1 fill the first two tail bytes exactly. ClientState state_ : 3 {ClientState::IDLE}; - bool paired_ : 1 {false}; - ConnectionType connection_type_ : 2 {ConnectionType::V1}; + static_assert(SERVICES_DONE_RETRY_LIMIT < (1 << 5), "counter bitfield too narrow"); + uint8_t services_done_retries_ : 5 {0}; uint8_t connection_index_ : 4 {0}; + ConnectionType connection_type_ : 2 {ConnectionType::V1}; + bool paired_ : 1 {false}; bool services_discovered_ : 1 {false}; + static_assert(static_cast(PendingAck::PENDING_ACK_ERROR) < (1 << 2), "pending_ack_ bitfield too narrow"); + PendingAck pending_ack_ : 2 {PendingAck::PENDING_ACK_NONE}; + /// Set while a refused batch is retrying, so only the first one warns. + bool batch_stalled_ : 1 {false}; + /// An owed connected=true reply; the proxy's paced drain re-offers it. + bool connected_reply_owed_ : 1 {false}; + // Plain byte after the bitfields: takes the padding byte instead of + // straddling pending_ack_'s storage unit and growing the object. + static_assert(PENDING_ACK_RETRY_LIMIT <= 0xFF, "retry counter too narrow"); + uint8_t pending_ack_retries_{0}; }; +// Pins the grouping above: pending_ack_handle_ in Group 2 instead would pad +// address_ out and reach 64. 32-bit only; the host unit tests build 64-bit. +static_assert(sizeof(void *) != 4 || sizeof(BluetoothConnection) <= 56, + "BluetoothConnection layout regressed on a 32-bit target"); + } // namespace esphome::bluetooth_connection -#endif // BLUETOOTH_CONNECTION_SERVES_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp index dc77d448a5..855c895196 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.cpp @@ -5,6 +5,7 @@ #if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -25,6 +26,13 @@ using ble_device_base::GATT_ERR_NO_MEMORY; // and keeps the scan inhibited, so the engine cancels after 20 s. The // disconnect timeout mirrors the esp32 CLOSE_EVT safety net. static constexpr uint32_t CONNECT_TIMEOUT_MS = 20000; +// Budget after a cancel is in flight: its completion normally lands within +// tens of ms, and while the engine waits it pins the stack-wide connect slot, +// so a lost completion must cost seconds, not another full connect budget. +static constexpr uint32_t CONNECT_CANCEL_TIMEOUT_MS = 2000; +// Pending engines re-attempt gap_connect on this cadence instead of every +// loop pass: the DISALLOWED path (teardown overlap) takes BluetoothLock. +static constexpr uint32_t CONNECT_RETRY_INTERVAL_MS = 50; // Can-send windows normally open within a connection interval (tens of ms). static constexpr uint32_t WRITE_NO_RSP_TIMEOUT_MS = 500; @@ -53,6 +61,7 @@ RP2GattClient *RP2GattClient::instances[ESPHOME_BLE_GATT_CLIENT_COUNT] = {}; uint8_t RP2GattClient::instance_count = 0; btstack_packet_callback_registration_t RP2GattClient::hci_event_registration = {}; btstack_packet_callback_registration_t RP2GattClient::sm_event_registration = {}; +RP2GattClient *RP2GattClient::connect_owner = nullptr; // NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables) static ESPBTUUID uuid_from_btstack(uint16_t uuid16, const uint8_t uuid128[16]) { @@ -83,6 +92,7 @@ void RP2GattClient::setup() { // One locked section: the slot store lands before the count bump, and a // live HCI handler (N > 1 builds) cannot read a half-written registry. BluetoothLock lock; + this->engine_index_ = instance_count; instances[instance_count] = this; instance_count++; // One HCI event handler for all engine instances (BTstack supports @@ -95,9 +105,24 @@ void RP2GattClient::setup() { } } +#ifdef USE_OTA_STATE_LISTENER + ota::get_global_ota_callback()->add_global_state_listener(this); +#endif + this->disable_loop(); } +#ifdef USE_OTA_STATE_LISTENER +void RP2GattClient::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { + // esp32 parity (its tracker disconnects every client at OTA start): free + // the shared radio for the transfer. No restore needed; the client + // reconnects, and on success the device reboots anyway. + if (state == ota::OTA_STARTED && this->state_ != EngineState::IDLE) { + this->gatt_disconnect(); + } +} +#endif + float RP2GattClient::get_setup_priority() const { return setup_priority::AFTER_BLUETOOTH; } void RP2GattClient::dump_config() { ESP_LOGCONFIG(TAG, "RP2 GATT client (BTstack)"); } @@ -123,34 +148,56 @@ void RP2GattClient::hci_packet_handler(uint8_t type, uint16_t channel, uint8_t * if (hci_event_gap_meta_get_subevent_code(packet) != GAP_SUBEVENT_LE_CONNECTION_COMPLETE) { break; } - bd_addr_t peer; - gap_subevent_le_connection_complete_get_peer_address(packet, peer); uint8_t status = gap_subevent_le_connection_complete_get_status(packet); hci_con_handle_t con_handle = gap_subevent_le_connection_complete_get_connection_handle(packet); - // Route to the engine that is waiting for this peer. - for (uint8_t i = 0; i < instance_count; i++) { - RP2GattClient *inst = instances[i]; - if (inst->state_ == EngineState::CONNECTING && memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) == 0) { - inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); - break; + bd_addr_t peer; + gap_subevent_le_connection_complete_get_peer_address(packet, peer); + // Route by ownership, not address: gap_connect refuses a new + // create-connection until the previous completion is processed, so the + // event belongs to the owner by construction. Cancel completions carry + // a zeroed peer address on this controller, so an address match would + // drop them and pin the owner until its backstop. + RP2GattClient *inst = connect_owner; + static constexpr bd_addr_t ZERO_ADDR = {}; + if (inst != nullptr && memcmp(peer, ZERO_ADDR, sizeof(bd_addr_t)) != 0 && + memcmp(inst->peer_addr_, peer, sizeof(bd_addr_t)) != 0) { + // Addressed completion for a peer the owner is not connecting to: a + // success delayed past a cancel and an ownership handoff (the cancel + // idles the stack's request immediately) must not stamp the old + // procedure's link onto the new owner. Zero-address (cancel) + // completions need no such guard: BTstack only emits them while its + // request state is idle, and a new owner re-arms that state when it + // claims the token, so a stale cancel completion is swallowed by the + // stack, never re-attributed. A successful stale link still needs + // disposal (same hazard as the unowned branch below). + if (status == 0) { + gap_disconnect(con_handle); } + break; } + connect_owner = nullptr; + if (inst == nullptr) { + if (status == 0) { + // Nobody owns this late link (the owner escalated first): tear it + // down here or the hci_connection_t leaks and the peer answers + // DISALLOWED until reboot. + gap_disconnect(con_handle); + } + break; + } + if (status == 0) { + // Stamp the handle here in the BTstack context: a disconnection + // racing the queued CONNECTED event arrives in this same context + // and must route by handle (it carries no address). + inst->con_handle_ = con_handle; + } + inst->enqueue_event_irq_(RP2GattEvent::CONNECTED, status, con_handle); break; } case HCI_EVENT_DISCONNECTION_COMPLETE: { - hci_con_handle_t con_handle = hci_event_disconnection_complete_get_connection_handle(packet); - RP2GattClient *inst = instance_for_con_handle(con_handle); - if (inst == nullptr && instance_count == 1) { - // The main loop may not have recorded the handle yet (the CONNECTED - // event is still queued); with a single engine the connecting - // instance is unambiguous, so route there to close the - // accept-then-drop window. With multiple engines the event has no - // address to match on, so it must be dropped instead of guessed. - RP2GattClient *candidate = instances[0]; - if (candidate->con_handle_ == HCI_CON_HANDLE_INVALID && candidate->state_ != EngineState::IDLE) { - inst = candidate; - } - } + // Routable even against a still-queued CONNECTED event: the handle is + // stamped in this context at connection-complete time. + RP2GattClient *inst = instance_for_con_handle(hci_event_disconnection_complete_get_connection_handle(packet)); if (inst != nullptr) { inst->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, hci_event_disconnection_complete_get_reason(packet), 0); } @@ -392,44 +439,73 @@ void RP2GattClient::loop() { if (dropped > 0) { // Control events must not be lost; the connection state is no longer // trustworthy — recover with a forced teardown. - ESP_LOGE(TAG, "Dropped %u GATT control events, disconnecting", dropped); + ESP_LOGE(TAG, "[%u] Dropped %u GATT control events, disconnecting", this->engine_index_, dropped); this->gatt_disconnect(); } uint16_t notify_dropped = this->notify_queue_.get_and_reset_dropped_count(); if (notify_dropped > 0) { - ESP_LOGW(TAG, "Dropped %u GATT notifications (queue full)", notify_dropped); + ESP_LOGW(TAG, "[%u] Dropped %u GATT notifications (queue full)", this->engine_index_, notify_dropped); } - if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + if (this->state_ == EngineState::CONNECT_PENDING) { uint32_t now = millis(); if (now - this->connect_started_ > CONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "Connect timeout"); - if (this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID) { - if (!this->connect_cancel_attempted_) { - this->connect_cancel_attempted_ = true; - BluetoothLock lock; + // Never reached the radio; nothing stack-side to cancel. + ESP_LOGW(TAG, "[%u] Connect timeout (queued)", this->engine_index_); + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + } else if (now - this->connect_retry_ms_ >= CONNECT_RETRY_INTERVAL_MS) { + this->connect_retry_ms_ = now; + if (int err = this->try_gap_connect_(); err != 0) { + this->fail_connection_(static_cast(err)); + } + } + } else if (this->state_ == EngineState::CONNECTING || this->state_ == EngineState::MTU_EXCHANGE) { + uint32_t now = millis(); + bool cancel_in_flight = this->state_ == EngineState::CONNECTING && this->con_handle_ == HCI_CON_HANDLE_INVALID && + this->connect_cancel_attempted_; + uint32_t budget = cancel_in_flight ? CONNECT_CANCEL_TIMEOUT_MS : CONNECT_TIMEOUT_MS; + if (now - this->connect_started_ > budget) { + ESP_LOGW(TAG, "[%u] Connect timeout", this->engine_index_); + bool link_up = this->state_ != EngineState::CONNECTING; + bool cancel_sent = false; + if (!link_up) { + BluetoothLock lock; + // Handle check under the lock: a success completion can stamp it in + // the BTstack context right up to this point, and escalating past a + // live link would orphan it (the queued CONNECTED event is dropped + // by the state guard once fail_connection_ runs). + link_up = this->con_handle_ != HCI_CON_HANDLE_INVALID; + if (!link_up && connect_owner == this) { + // gap_connect_cancel is stack-global; only the engine whose + // create-connection is in flight may issue it. First timeout: + // cancel and give the completion a grace period. Second: the + // completion was lost, re-issue the cancel in case the procedure + // still runs (a no-op on an idle stack), then escalate. gap_connect_cancel(); - // The cancel produces a connection-complete event with a failure - // status, which drives the normal failure path; restart the timer - // so a lost event escalates below instead of wedging here. - this->connect_started_ = now; - } else { - // The cancel's completion never arrived: reclaim the slot and the - // scan rather than cancelling forever. - this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); + cancel_sent = !this->connect_cancel_attempted_; } - } else { - // The link is up (MTU exchange stalled): tear it down properly so the - // controller frees its side; the DISCONNECTING safety net below - // reclaims state if the disconnection event is lost. Dropping engine - // state without gap_disconnect would leak the live link and the - // single GATT slot for the rest of the boot. + this->connect_cancel_attempted_ = true; + } + if (link_up) { + // The link is up (stamped mid-timeout or MTU exchange stalled): tear + // it down properly so the controller frees its side; the + // DISCONNECTING safety net below reclaims state if the disconnection + // event is lost. Dropping engine state without gap_disconnect would + // leak the live link and this engine's GATT slot for the rest of the + // boot. this->gatt_disconnect(); + } else if (cancel_sent) { + // The cancel produces a connection-complete event with a failure + // status, which drives the normal failure path; restart the timer so + // a lost event escalates on the short cancel budget. + this->connect_started_ = now; + } else { + this->fail_connection_(HCI_REASON_CONNECTION_TIMEOUT); } } } else if (this->state_ == EngineState::DISCONNECTING) { if (millis() - this->disconnecting_started_ > ble_device_base::GATT_DISCONNECT_TIMEOUT_MS) { - ESP_LOGW(TAG, "Disconnect timeout, forcing idle"); + ESP_LOGW(TAG, "[%u] Disconnect timeout, forcing idle", this->engine_index_); this->handle_disconnected_(HCI_REASON_CONNECTION_TIMEOUT); } } else if (this->state_ == EngineState::READY && this->op_type_ == OpType::WRITE_CHAR_NO_RSP && @@ -445,7 +521,7 @@ void RP2GattClient::loop() { } } if (timed_out) { - ESP_LOGW(TAG, "Deferred write timeout, handle=0x%04x", this->op_handle_); + ESP_LOGW(TAG, "[%u] Deferred write timeout, handle=0x%04x", this->engine_index_, this->op_handle_); this->listener_->on_write_result(this->op_handle_, GATT_CLIENT_BUSY); } } else if (this->state_ == EngineState::IDLE || (this->state_ == EngineState::READY && !this->op_in_flight_() && @@ -466,7 +542,7 @@ void RP2GattClient::handle_event_(const RP2GattEvent &event) { case RP2GattEvent::MTU_EXCHANGED: if (this->state_ == EngineState::MTU_EXCHANGE) { this->mtu_ = event.value; - ESP_LOGD(TAG, "MTU %u", this->mtu_); + ESP_LOGV(TAG, "[%u] MTU %u", this->engine_index_, this->mtu_); this->state_ = EngineState::READY; // Scanning resumes and runs alongside the established connection. this->release_scan_inhibit_(); @@ -514,7 +590,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { return; } if (status != 0) { - ESP_LOGW(TAG, "Connect failed, status=0x%02x", status); + ESP_LOGW(TAG, "[%u] Connect failed, status=0x%02x", this->engine_index_, status); this->fail_connection_(status); return; } @@ -538,7 +614,7 @@ void RP2GattClient::handle_connected_(uint8_t status, uint16_t con_handle) { } this->con_handle_ = con_handle; this->state_ = EngineState::MTU_EXCHANGE; - ESP_LOGD(TAG, "Link up, handle=0x%04x, negotiating MTU", con_handle); + ESP_LOGV(TAG, "[%u] Link up, handle=0x%04x, negotiating MTU", this->engine_index_, con_handle); BluetoothLock lock; // One wildcard listener covers notifications/indications for every // characteristic on this connection; the CCCD writes come from the API @@ -563,6 +639,24 @@ void RP2GattClient::release_scan_inhibit_() { } void RP2GattClient::fail_connection_(uint8_t reason) { + { + // Timeout escalation can fire with the completion event lost; release the + // stack-wide connect slot so pending engines can proceed. Until the old + // completion is processed, gap_connect answers any peer with DISALLOWED + // (the request-level guard in hci.c); a cancel idles that request + // immediately, and a late addressed completion from the old procedure is + // then dropped by the owner-peer cross-check in the handler. + BluetoothLock lock; + if (connect_owner == this) { + connect_owner = nullptr; + } + if (this->state_ == EngineState::CONNECTING && this->con_handle_ != HCI_CON_HANDLE_INVALID) { + // A success completion stamped the handle between the escalation + // decision and this lock: tear the link down before cleanup wipes the + // handle, or it leaks its pool block for the rest of the boot. + gap_disconnect(this->con_handle_); + } + } this->cleanup_link_state_(); this->release_scan_inhibit_(); this->state_ = EngineState::IDLE; @@ -576,14 +670,19 @@ void RP2GattClient::cleanup_link_state_() { while ((stale = this->notify_queue_.pop()) != nullptr) { this->notify_pool_.release(stale); } - // The wildcard listener is registered on the normal connect path right - // after con_handle_ is recorded; the cancel branch tears down before - // registering, where stop_listening on an unregistered entry is a no-op. - if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + // con_handle_ may be stamped in the BTstack context before the main loop + // registers the listener, so a valid handle does not imply a registration; + // stop_listening on an unregistered entry is a benign no-op. One lock + // scope around check and reset so an IRQ stamp cannot land in between + // (unreachable today — ownership is released before cleanup — but the + // invariant lives three functions away). + { BluetoothLock lock; - gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { + gatt_client_stop_listening_for_characteristic_value_updates(&this->notification_registration_); + } + this->con_handle_ = HCI_CON_HANDLE_INVALID; } - this->con_handle_ = HCI_CON_HANDLE_INVALID; this->notify_subscription_count_ = 0; this->cancel_requested_ = false; this->op_type_ = OpType::NONE; @@ -595,7 +694,7 @@ void RP2GattClient::handle_disconnected_(uint8_t reason) { if (this->state_ == EngineState::IDLE) { return; } - ESP_LOGD(TAG, "Disconnected, reason=0x%02x", reason); + ESP_LOGV(TAG, "[%u] Disconnected, reason=0x%02x", this->engine_index_, reason); this->fail_connection_(reason); } @@ -653,7 +752,7 @@ int RP2GattClient::discover_services() { RAMAllocator allocator(RAMAllocator::ALLOC_INTERNAL); this->arena_ = allocator.allocate(1); if (this->arena_ == nullptr) { - ESP_LOGE(TAG, "Service table allocation failed"); + ESP_LOGE(TAG, "[%u] Service table allocation failed", this->engine_index_); return ble_device_base::GATT_ERR_NO_MEMORY; } new (this->arena_) ServiceArena(); @@ -759,8 +858,8 @@ void RP2GattClient::advance_discovery_(uint8_t att_status) { void RP2GattClient::finish_discovery_(int error) { this->discovery_phase_ = DiscoveryPhase::NONE; - ESP_LOGD(TAG, "Discovery done (err=%d): %u services, %u characteristics, %u descriptors", error, this->service_count_, - this->char_count_, this->desc_count_); + ESP_LOGV(TAG, "[%u] Discovery done (err=%d): %u services, %u characteristics, %u descriptors", this->engine_index_, + error, this->service_count_, this->char_count_, this->desc_count_); if (error == 0 && this->truncated_) { // A partial table must not stream: V3 clients cache the database // permanently, so an incomplete one would be wrong forever. @@ -838,22 +937,68 @@ int RP2GattClient::connect(uint64_t address, uint8_t addr_type) { this->parent_->inhibit_scan(); this->connect_cancel_attempted_ = false; this->cancel_requested_ = false; + // Bounds the queued wait; restarted when gap_connect is accepted so the + // radio attempt gets its full budget (HA's own ~20 s timeout arbitrates the + // sum via a disconnect request). + this->connect_started_ = millis(); + if (int err = this->try_gap_connect_(); err != 0) { + this->release_scan_inhibit_(); + return err; + } + this->enable_loop(); + return 0; +} + +// One outgoing LE create-connection exists stack-wide: issue it if no other +// engine owns it, otherwise park in CONNECT_PENDING for loop() to retry. +// Returns nonzero only for hard failures (state untouched; caller cleans up). +int RP2GattClient::try_gap_connect_() { + // Unlocked peek: single core, aligned pointer; a stale value costs one loop + // pass and the locked re-check below is authoritative. Keeps the per-loop + // pending retry from taking BluetoothLock just to find the radio busy. + if (connect_owner != nullptr) { + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } uint8_t status; { BluetoothLock lock; - gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, FAST_MIN_CONN_INTERVAL, FAST_MAX_CONN_INTERVAL, - 0, FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); - status = gap_connect(this->peer_addr_, this->peer_addr_type_); + if (connect_owner != nullptr) { + status = ERROR_CODE_COMMAND_DISALLOWED; + } else { + // esp32 parity: cached connections come up at MEDIUM already (nothing + // consumes the fast interval without a discovery phase), so there is no + // post-connect update procedure to race or silently lose; sustained + // FAST intervals also starve WiFi on the shared CYW43 radio. + // Without-cache runs FAST for discovery and steps down in + // finish_discovery_. + bool cached = this->connection_type_ == ble_device_base::ConnectionType::V3_WITH_CACHE; + gap_set_connection_parameters(CONN_SCAN_INTERVAL, CONN_SCAN_WINDOW, + cached ? MEDIUM_MIN_CONN_INTERVAL : FAST_MIN_CONN_INTERVAL, + cached ? MEDIUM_MAX_CONN_INTERVAL : FAST_MAX_CONN_INTERVAL, 0, + cached ? MEDIUM_CONN_TIMEOUT : FAST_CONN_TIMEOUT, CONN_CE_MIN, CONN_CE_MAX); + status = gap_connect(this->peer_addr_, this->peer_addr_type_); + if (status == 0) { + connect_owner = this; + // Still under the lock: a synthesized failure completion can fire in + // the BTstack context the instant it releases, and completion routing + // requires CONNECTING — set after the fact, the event is discarded + // and the engine burns its whole budget waiting for it. + this->state_ = EngineState::CONNECTING; + this->connect_started_ = millis(); + } + } } - if (status != 0) { - ESP_LOGW(TAG, "gap_connect failed, status=0x%02x", status); - this->release_scan_inhibit_(); - return status; + if (status == 0) { + return 0; } - this->state_ = EngineState::CONNECTING; - this->connect_started_ = millis(); - this->enable_loop(); - return 0; + if (status == ERROR_CODE_COMMAND_DISALLOWED) { + // Radio busy with another engine's connect; resolved from loop(). + this->state_ = EngineState::CONNECT_PENDING; + return 0; + } + ESP_LOGW(TAG, "[%u] gap_connect failed, status=0x%02x", this->engine_index_, status); + return status; } int RP2GattClient::gatt_disconnect() { @@ -862,6 +1007,10 @@ int RP2GattClient::gatt_disconnect() { return GATT_ERR_NOT_CONNECTED; case EngineState::DISCONNECTING: return 0; // already on its way down + case EngineState::CONNECT_PENDING: + // Nothing issued stack-side; the invalid handle takes the refused + // path below without touching the stack. + break; case EngineState::CONNECTING: { if (this->con_handle_ == HCI_CON_HANDLE_INVALID) { // The cancel can lose the race against a successful connection @@ -870,9 +1019,18 @@ int RP2GattClient::gatt_disconnect() { // attempt, so a lost completion escalates on the next timeout tick. this->cancel_requested_ = true; this->connect_cancel_attempted_ = true; + // Grace period for the cancel completion: the client's disconnect + // often lands right at the engine's own deadline, and without the + // restart the loop timeout fires first and reports before the + // completion can finish the teardown cleanly. + this->connect_started_ = millis(); BluetoothLock lock; - gap_connect_cancel(); - // Completion arrives as a failed connection-complete event. + // Owner: the cancel completes as a failed connection-complete. Not + // the owner (completion already resolved in the BTstack context): the + // queued event drives the same teardown, nothing to cancel. + if (connect_owner == this) { + gap_connect_cancel(); + } return 0; } break; @@ -880,20 +1038,23 @@ int RP2GattClient::gatt_disconnect() { default: break; } - uint8_t status; - { - BluetoothLock lock; - status = gap_disconnect(this->con_handle_); - } - if (status != 0) { - // Refused (handle already gone): complete via the event queue so the - // listener cannot re-enter disconnect() mid-call. BluetoothLock stops - // the IRQ producer, so this main-loop push is SPSC-safe. - ESP_LOGW(TAG, "gap_disconnect failed, status=0x%02x", status); + uint8_t status = ERROR_CODE_UNKNOWN_CONNECTION_IDENTIFIER; + if (this->con_handle_ != HCI_CON_HANDLE_INVALID) { { BluetoothLock lock; - this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); + status = gap_disconnect(this->con_handle_); } + if (status != 0) { + ESP_LOGW(TAG, "[%u] gap_disconnect failed, status=0x%02x", this->engine_index_, status); + } + } + if (status != 0) { + // Refused (handle already gone) or never issued (CONNECT_PENDING): + // complete via the event queue so the listener cannot re-enter + // disconnect mid-call. BluetoothLock stops the IRQ producer, so this + // main-loop push is SPSC-safe. + BluetoothLock lock; + this->enqueue_event_irq_(RP2GattEvent::DISCONNECTED, HCI_REASON_CONNECTION_TIMEOUT, 0); } this->state_ = EngineState::DISCONNECTING; this->disconnecting_started_ = millis(); @@ -1098,7 +1259,7 @@ int RP2GattClient::update_connection_params(uint16_t min_interval, uint16_t max_ } conn_err_t unpair_device(uint64_t address) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; ble_device_base::uint64_to_mac_msb_first(address, mac); bool found = false; BluetoothLock lock; diff --git a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h index df43ebd66d..4d407269b6 100644 --- a/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h +++ b/esphome/components/bluetooth_connection/bluetooth_connection_rp2.h @@ -19,6 +19,10 @@ #include "esphome/core/helpers.h" #include "esphome/core/lock_free_queue.h" +#ifdef USE_OTA_STATE_LISTENER +#include "esphome/components/ota/ota_backend.h" +#endif + #include #include @@ -71,7 +75,13 @@ static constexpr uint8_t RP2_GATT_EVENT_QUEUE_SIZE = 8; // full 512 B ATT payload, so depth buys burst tolerance at ~516 B per slot. static constexpr uint8_t RP2_GATT_NOTIFY_QUEUE_SIZE = 4; -class RP2GattClient final : public Component, public Parented { +class RP2GattClient final : public Component, + public Parented +#ifdef USE_OTA_STATE_LISTENER + , + public ota::OTAGlobalStateListener +#endif +{ public: void setup() override; void loop() override; @@ -95,18 +105,26 @@ class RP2GattClient final : public Component, public Parentedconnection_type_ = ct; } void release_services(); +#ifdef USE_OTA_STATE_LISTENER + // Drop the connection while an OTA runs (esp32 parity): an active link + // competes with the transfer for the shared radio. + void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override; +#endif + protected: // Link/engine state. Discovery and GATT ops have their own cursors below — // the link stays READY while they run. enum class EngineState : uint8_t { IDLE, - CONNECTING, // gap_connect issued, waiting for connection complete - MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU - READY, // on_connection_state(true) delivered + CONNECT_PENDING, // queued: another engine owns the stack-wide create-connection + CONNECTING, // gap_connect issued, waiting for connection complete + MTU_EXCHANGE, // link up, waiting for GATT_EVENT_MTU + READY, // on_connection_state(true) delivered DISCONNECTING, }; @@ -143,6 +161,7 @@ class RP2GattClient final : public Component, public Parented notify_subscriptions_{}; uint8_t notify_subscription_count_{0}; - bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects - bd_addr_type_t peer_addr_type_{BD_ADDR_TYPE_LE_PUBLIC}; + uint8_t engine_index_{0}; // position in instances[]; tags log lines per slot + bd_addr_t peer_addr_{}; // MSB-first, as gap_connect expects + ble_device_base::ConnectionType connection_type_{ble_device_base::ConnectionType::V3_WITHOUT_CACHE}; EngineState state_{EngineState::IDLE}; DiscoveryPhase discovery_phase_{DiscoveryPhase::NONE}; OpType op_type_{OpType::NONE}; @@ -214,6 +238,12 @@ class RP2GattClient final : public Component, public Parented ConfigType: @functools.cache def _rp2_config_schema() -> cv.All: """Full proxy on the rp2 BLE hub: active connections through the BTstack - GATT client backend in bluetooth_connection. The slot limit comes from the - prebuilt BTstack library (one connection today); the code is built for N.""" + GATT client backend in bluetooth_connection. Multi-slot builds replace the + prebuilt library's one-client BTstack pools via linker --wrap, owned by + rp2040_ble and requested when a second backend registers.""" connection_schema = bluetooth_connection.hub_connection_schema(PLATFORM_RP2) def populate_connections(config: ConfigType) -> ConfigType: @@ -166,14 +167,13 @@ def _rp2_config_schema() -> cv.All: # their ids exist for codegen (the esp32 arm's `connections` pattern). if not config[CONF_ACTIVE]: return config - bluetooth_connection.consume_gatt_slot( - "bluetooth_proxy", config[CONF_CONNECTION_SLOTS] - )(config) + connection_slots: int = config[CONF_CONNECTION_SLOTS] + bluetooth_connection.consume_gatt_slot("bluetooth_proxy", connection_slots)( + config + ) return { **config, - CONF_CONNECTIONS: [ - connection_schema({}) for _ in range(config[CONF_CONNECTION_SLOTS]) - ], + CONF_CONNECTIONS: [connection_schema({}) for _ in range(connection_slots)], } max_conn = bluetooth_connection.HUB_MAX_CONNECTIONS[PLATFORM_RP2] @@ -191,8 +191,8 @@ def _rp2_config_schema() -> cv.All: min=1, max=max_conn, msg=f"rp2 supports at most {max_conn} connection slot(s); " - "the framework's BTstack library is built with " - f"MAX_NR_GATT_CLIENTS {max_conn}", + "the BTstack pool overrides in rp2040_ble are sized " + f"for {max_conn}", ), ), } @@ -214,6 +214,11 @@ async def _connections_to_code(var: cg.MockObj, config: ConfigType) -> None: # this define whenever a proxy is present (zero on advertisement-only # hubs); sized here so it can never diverge from the loop below. cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", len(connections)) + if connections: + # Gates the connection and GATT half of the API surface. A proxy + # without slots omits FEATURE_ACTIVE_CONNECTIONS, so a client never + # sends those requests and their handlers and encoders are dead. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") for connection_conf in connections: backend = await bluetooth_connection.new_gatt_backend( connection_conf, service_table=False diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index e0b347c8c9..878d3cd44e 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -40,7 +40,7 @@ static_assert(static_cast(ble_device_base::ScannerState::STOPPED) == bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState state) { if (this->api_connection_ == nullptr) - return false; + return true; // Nobody subscribed: nothing owed api::BluetoothScannerStateResponse resp; resp.state = static_cast(state); resp.mode = this->hub_->scan_active() ? api::enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE @@ -51,7 +51,12 @@ bool BluetoothProxy::send_bluetooth_scanner_state_(ble_device_base::ScannerState return this->api_connection_->send_message(resp); } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK +void BluetoothProxy::send_scanner_state_(ble_device_base::ScannerState state) { + // False only on a refused frame, so the latch arms only when a retry is owed. + this->scanner_state_pending_ = !this->send_bluetooth_scanner_state_(state); +} +#else void BluetoothProxy::send_polled_scanner_state_() { // One read feeds both the frame and the change detector; the detector only // advances if the frame was accepted, so a dropped send (WOULD_BLOCK on a @@ -62,12 +67,13 @@ void BluetoothProxy::send_polled_scanner_state_() { this->last_scan_running_ = running; } } -#endif // !USE_BLE_SCANNER_STATE_CALLBACK +#endif // USE_BLE_SCANNER_STATE_CALLBACK void BluetoothProxy::setup() { - // BLUETOOTH_PROXY_MAX_CONNECTIONS is 0 on an advertisement-only proxy. +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS this->connections_free_response_.limit = BLUETOOTH_PROXY_MAX_CONNECTIONS; this->connections_free_response_.free = BLUETOOTH_PROXY_MAX_CONNECTIONS; +#endif // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->hub_->scan_active(); @@ -78,7 +84,7 @@ void BluetoothProxy::setup() { #ifdef USE_BLE_SCANNER_STATE_CALLBACK // Only push hubs compile the slot; elsewhere loop() polls scan_running(). this->hub_->set_scanner_state_callback({this, [](void *self, ble_device_base::ScannerState state) { - static_cast(self)->send_bluetooth_scanner_state_(state); + static_cast(self)->send_scanner_state_(state); }}); #endif } @@ -98,7 +104,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme this->response_.advertisements_len++; - ESP_LOGV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); + ESP_LOGVV(TAG, "Queuing raw packet from %012" PRIX64 ", length %d. RSSI: %d dB", raw.address, length, raw.rssi); // Flush if we have reached BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE if (this->response_.advertisements_len >= BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE) { @@ -106,7 +112,7 @@ void BluetoothProxy::on_raw_advertisement_(const ble_device_base::RawAdvertiseme } } -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), connection->address_str(), ble_device_base::client_state_to_string(state)); @@ -115,7 +121,20 @@ void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connec void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } -#endif // BLUETOOTH_CONNECTION_SERVES_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS + +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +void BluetoothProxy::log_reply_dropped_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_deferred_(const char *what, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " deferred, TCP buffer full", what, address); +} + +void BluetoothProxy::log_reply_displaced_(const char *what, uint64_t owed, uint64_t address) { + ESP_LOGW(TAG, "%s reply for %012" PRIX64 " dropped, displaced by %012" PRIX64, what, owed, address); +} void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { ESP_LOGW(TAG, "Cannot %s GATT %s, not connected", action, type); @@ -124,22 +143,32 @@ void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *typ void BluetoothProxy::handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type) { this->log_not_connected_gatt_(action, type); - this->send_gatt_error(address, handle, GATT_NOT_CONNECTED); + if (!this->send_gatt_error(address, handle, GATT_NOT_CONNECTED)) { + // No connection, so nothing to latch against; the client's timeout arbitrates. + this->log_reply_dropped_("Not-connected", address); + } } +#endif -void BluetoothProxy::log_advertisement_flush_() { - ESP_LOGV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); +void BluetoothProxy::log_advertisement_flush_(bool sent) { + if (sent) { + // VV: one line per flush drowns a verbose log in any busy environment. + ESP_LOGVV(TAG, "Sent batch of %u BLE advertisements", this->response_.advertisements_len); + } else { + // The rare congestion signal stays at V. + ESP_LOGV(TAG, "Batch of %u BLE advertisements dropped, TCP buffer full", this->response_.advertisements_len); + } } void BluetoothProxy::dump_config() { // Print configured facts. dump_config runs right after setup, before the // radio is up, so live scan state would always read "stopped" here — the // loop's BluetoothScannerStateResponse carries the changing value instead. - char mac_str[18]; + char mac_str[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; this->get_bluetooth_mac_address_pretty(mac_str); const char *mac_out = mac_str[0] != '\0' ? mac_str : "unavailable (adapter not up yet)"; const char *scan_mode = this->configured_scan_active_ ? "active" : "passive"; -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS ESP_LOGCONFIG(TAG, "Bluetooth Proxy:\n" " Active: %s\n" @@ -157,12 +186,9 @@ void BluetoothProxy::dump_config() { #endif } -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS -// maybe_unused: in a passive proxy (active: false) MAX is 0, the body is removed, and connection is unused. -void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *connection) { -// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0. -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 +void BluetoothProxy::register_connection(BluetoothConnection *connection) { if (this->connection_count_ >= BLUETOOTH_PROXY_MAX_CONNECTIONS) { // Cannot happen with codegen-sized registration; a silent drop would // surface later as a null proxy_ dereference, so refuse loudly. @@ -173,7 +199,6 @@ void BluetoothProxy::register_connection([[maybe_unused]] BluetoothConnection *c connection->connection_index_ = this->connection_count_; this->connections_[this->connection_count_++] = connection; connection->proxy_ = this; -#endif } void BluetoothProxy::log_slot_accounting_mismatch_() { ESP_LOGW(TAG, "Connection slot free-count mismatch, clamped"); } @@ -190,8 +215,68 @@ void BluetoothProxy::replace_allocated_slot_(uint64_t find_value, uint64_t set_v ESP_LOGW(TAG, "Connection slot accounting mismatch (find 0x%llx)", (unsigned long long) find_value); } +void BluetoothProxy::latch_pending_disconnection_(uint64_t address, conn_err_t error) { + // Match before free entry so one address never occupies two pool slots. + PendingReply *free_entry = nullptr; + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto &owed = this->pending_disconnections_[i]; + if (owed.matches(address)) { + owed.set(address, error); + return; + } + if (free_entry == nullptr && owed.empty()) { + free_entry = &owed; + } + } + if (free_entry != nullptr) { + this->log_reply_deferred_("Disconnect", address); + free_entry->set(address, error); + return; + } + // Every entry is owed: evict the first so the newest loss is not silent too. + this->log_reply_displaced_("Disconnect", this->pending_disconnections_[0].address(), address); + this->pending_disconnections_[0].set(address, error); +} + +void BluetoothProxy::clear_pending_disconnection_(uint64_t address) { + // A reconnect supersedes the owed disconnect; a late resend would shadow + // the new connection. + for (uint8_t i = 0; i < this->connection_count_; i++) { + if (this->pending_disconnections_[i].matches(address)) { + this->pending_disconnections_[i].clear(); + return; // latch_pending_disconnection_ keeps at most one entry per address + } + } +} + +void BluetoothProxy::answer_device_disconnected_(uint64_t address) { + if (this->send_device_connection(address, false)) { + // A landed answer satisfies any owed notification for the address; a + // drained duplicate would follow it otherwise. + this->clear_pending_disconnection_(address); + return; + } + // Not latched: the client's own request timeout arbitrates, and pooling + // these would let a request retry loop displace an unsolicited disconnect. + this->log_reply_dropped_("Disconnect", address); +} + +void BluetoothProxy::send_device_disconnected_(uint64_t address, conn_err_t error) { + if (this->send_device_connection(address, false, 0, error)) { + // A later disconnect landing for an address that still has one owed would + // otherwise have the drain repeat it. + this->clear_pending_disconnection_(address); + return; + } + // A dropped disconnect leaves the client believing the link is live, so + // every GATT operation on it times out until something else corrects it. + // latch_pending_disconnection_() reports the leading edge. + this->latch_pending_disconnection_(address, error); +} + void BluetoothProxy::reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason) { - this->send_device_connection(connection->get_address(), false, 0, reason); + // The client has no other way to learn of an unsolicited disconnect. + this->send_device_disconnected_(connection->get_address(), reason); connection->set_address(0); connection->send_service_ = INIT_SENDING_SERVICES; this->send_connections_free(); @@ -206,14 +291,20 @@ BluetoothConnection *BluetoothProxy::get_connection_(uint64_t address, bool rese auto *connection = this->connections_[i]; uint64_t conn_addr = connection->get_address(); - if (conn_addr == address) + if (conn_addr == address) { + // A connect request supersedes an owed disconnect. + if (reserve) { + this->clear_pending_disconnection_(address); + } return connection; + } if (free_slot == nullptr && conn_addr == 0) free_slot = connection; } if (!reserve || free_slot == nullptr) return nullptr; + this->clear_pending_disconnection_(address); free_slot->send_service_ = INIT_SENDING_SERVICES; free_slot->set_address(address); // All connections must start at INIT @@ -231,18 +322,18 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest auto *connection = this->get_connection_(msg.address, true); if (connection == nullptr) { ESP_LOGW(TAG, "No free connections available"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } if (!msg.has_address_type) { ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), connection->address_str()); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); return; } if (connection->state() == ClientState::CONNECTED || connection->state() == ClientState::ESTABLISHED) { this->log_connection_request_ignored_(connection, connection->state()); - this->send_device_connection(msg.address, true); + connection->send_connected_reply_(); this->send_connections_free(); return; } else if (connection->state() == ClientState::DISCONNECTING && connection->cancel_teardown()) { @@ -269,7 +360,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: { auto *connection = this->get_connection_(msg.address, false); if (connection == nullptr) { - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); return; } @@ -277,7 +368,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest connection->disconnect(); } else { connection->set_address(0); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); this->send_connections_free(); } break; @@ -321,7 +412,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: { ESP_LOGE(TAG, "V1 connections removed"); - this->send_device_connection(msg.address, false); + this->answer_device_disconnected_(msg.address); break; } } @@ -336,7 +427,7 @@ void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &ms auto err = connection->read_characteristic(msg.handle); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -349,7 +440,7 @@ void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest & auto err = connection->write_characteristic(msg.handle, msg.data, msg.data_len, msg.response); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -362,7 +453,7 @@ void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTRead auto err = connection->read_descriptor(msg.handle); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -375,7 +466,7 @@ void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWri auto err = connection->write_descriptor(msg.handle, msg.data, msg.data_len, true); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } @@ -387,7 +478,30 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer } if (!connection->has_gatt_services()) { ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->get_connection_index(), connection->address_str()); - this->send_gatt_services_done(msg.address); + // Through the retrying sender: a drop must not leave discovery hanging. + // Re-entry does not depend on the cursor - this branch is gated on + // has_gatt_services() alone, so no restore is needed. + connection->send_services_done_(); + return; + } + if (connection->send_service_ > 0) { + // A request mid-stream restarts from the top so the requester always + // gets the full list. No duplicate risk: the client accumulates batches + // per request, and a same-session re-request only happens after the + // previous request timed out and discarded its partial list. + ESP_LOGD(TAG, "[%d] [%s] GetServices mid-stream, restarting", connection->get_connection_index(), + connection->address_str()); + connection->send_service_ = 0; + return; + } + if (connection->send_service_ == SERVICES_DONE_PENDING) { + // A new request supersedes an owed done: the client accumulates batches + // per request, so its fresh, empty accumulator plus a bare done would + // cache as an empty database. The table is freed; the client's timeout + // arbitrates. + ESP_LOGW(TAG, "[%d] [%s] GetServices superseded an undelivered done; client timeout will retry", + connection->get_connection_index(), connection->address_str()); + connection->send_service_ = DONE_SENDING_SERVICES; return; } if (connection->send_service_ == INIT_SENDING_SERVICES) // Start sending services if not started yet @@ -403,14 +517,15 @@ void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest auto err = connection->notify_characteristic(msg.handle, msg.enable); if (err != CONN_OK) { - this->send_gatt_error(msg.address, msg.handle, err); + connection->send_gatt_error_(msg.handle, err); } } void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { if (this->api_connection_ == nullptr) return; - // Send results unchecked (esp32 parity): a drop resolves via the client timeout. + // Not latched (esp32 parity): the request is idempotent, so a drop resolves + // via the client timeout and a retry gives the same answer. Still reported. auto *connection = this->get_connection_(msg.address, false); api::BluetoothSetConnectionParamsResponse resp; @@ -421,7 +536,9 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn connection ? static_cast(connection->get_connection_index()) : -1, connection ? connection->address_str() : "unknown"); resp.error = GATT_NOT_CONNECTED; - this->api_connection_->send_message(resp); + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } return; } @@ -432,10 +549,12 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn static_cast(std::min(msg.max_interval, max_val)), static_cast(std::min(msg.latency, max_val)), static_cast(std::min(msg.timeout, max_val))); - this->api_connection_->send_message(resp); + if (!this->api_connection_->send_message(resp)) { + this->log_reply_dropped_("Connection-params", msg.address); + } } -#endif // BLUETOOTH_CONNECTION_SERVES_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS #ifdef USE_ESP32 @@ -478,7 +597,7 @@ void BluetoothProxy::bluetooth_scanner_set_mode(bool active) { #endif // USE_ESP32 void BluetoothProxy::loop() { -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Stream pending service-discovery batches every iteration; the streamer // handles a vanished API connection itself. for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -492,17 +611,19 @@ void BluetoothProxy::loop() { return; this->last_advertisement_flush_time_ = now; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS if (this->connections_free_pending_ && this->api_connection_ != nullptr) { // Resend a dropped slot-state update, paced by the 100 ms gate so the - // retry does not hammer the congestion it exists to survive; the - // advertisement-only arm answers DISCONNECT requests with this message - // too, so the drain compiles on every proxy build. + // retry does not hammer the congestion it exists to survive. Every build + // sends this at subscribe time (api_connection.cpp), so the drain + // compiles on every proxy build. this->connections_free_pending_ = false; this->send_connections_free(this->api_connection_); } +#endif if (!api::global_api_server->is_connected() || this->api_connection_ == nullptr) { -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // The API subscriber is gone: tear down any connections it left behind // (disconnect() on an already-disconnecting slot is a no-op). for (uint8_t i = 0; i < this->connection_count_; i++) { @@ -515,7 +636,37 @@ void BluetoothProxy::loop() { return; } -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + // Paced retries of owed per-slot notifications; subscriber swaps clear + // stale latches before this runs. + for (uint8_t i = 0; i < this->connection_count_; i++) { + this->connections_[i]->flush_owed_replies_(); + } + // Address-keyed, not slot-keyed, so it gets its own loop; bounded by + // connection_count_ like the latch and clear helpers. Not pre-cleared: + // the sender clears on success and re-latches on refusal, keeping the + // latch's leading-edge warn honest (same shape as the unpair drain). + for (uint8_t i = 0; i < this->connection_count_; i++) { + auto &owed = this->pending_disconnections_[i]; + if (owed.empty()) + continue; + this->send_device_disconnected_(owed.address(), owed.error()); + } + + // An owed unpair reply. Not pre-cleared: the sender clears on success and + // re-latches on refusal, keeping its leading-edge warn guard honest. + if (!this->pending_unpairing_.empty()) { + conn_err_t error = this->pending_unpairing_.error(); + this->send_device_unpairing(this->pending_unpairing_.address(), error == CONN_OK, error); + } +#endif + +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Resend a dropped scanner-state push (see scanner_state_pending_). + if (this->scanner_state_pending_) { + this->send_scanner_state_(this->hub_->get_scanner_state()); + } +#else // This hub doesn't push scanner-state transitions; poll and report on // change. A hub gaining push emits the define and drops this poll. if (this->hub_->scan_running() != this->last_scan_running_) { @@ -523,102 +674,79 @@ void BluetoothProxy::loop() { } #endif - this->flush_pending_advertisements_(); -} - -#ifndef BLUETOOTH_CONNECTION_SERVES_PROXY - -// Advertisement-only proxy. GATT client connections are excluded at compile -// time (no connection backend on this platform, or active: false), so every -// connection-oriented request is answered with a clean error instead of -// silence, and Home Assistant treats the proxy as passive. - -void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest &msg) { - switch (msg.request_type) { - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITH_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE: - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT: - ESP_LOGW(TAG, "Active connections are not supported on this platform"); - this->send_device_connection(msg.address, false, 0, GATT_NOT_CONNECTED); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT: - // Not an error: the device is already disconnected, which is the requested state. - this->send_device_connection(msg.address, false); - this->send_connections_free(); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_PAIR: - this->send_device_pairing(msg.address, false, GATT_NOT_CONNECTED); - break; - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_UNPAIR: { - // Address-scoped maintenance needs no connection slot: real on esp32 - // (Bluedroid bond table), the stub elsewhere keeps the old error reply. - conn_err_t ret = bluetooth_connection::unpair_device(msg.address); - this->send_device_unpairing(msg.address, ret == CONN_OK, ret); - break; - } - case api::enums::BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE: { - conn_err_t ret = bluetooth_connection::clear_gatt_cache(msg.address); - this->send_device_clear_cache(msg.address, ret == CONN_OK, ret); - break; +#ifdef USE_WIFI + // Wi-Fi (or a coexistence build that can fall back to it): every other + // non-empty 100 ms tick (~200 ms) gives partial batches time to fill + // toward BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE, so the air gets fewer, + // fuller frames. Full batches still ship immediately from the queueing + // path, and the owed-reply drains above keep the 100 ms cadence. + if (this->response_.advertisements_len != 0) { + if (this->adv_flush_toggle_) { + this->flush_pending_advertisements_(); } + this->adv_flush_toggle_ = !this->adv_flush_toggle_; + } else { + // Nothing pending (idle, or a full batch just shipped inline): arm so + // the next batch ships on the next tick. + this->adv_flush_toggle_ = true; } +#else + // No Wi-Fi in the build (ethernet): no airtime worth trading latency for, + // so partial batches flush every tick. + this->flush_pending_advertisements_(); +#endif } -void BluetoothProxy::bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "characteristic"); +void BluetoothProxy::reset_owed_replies_() { +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + this->connections_free_pending_ = false; +#endif +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // Owed on unsubscribe; on subscribe the trailing send_scanner_state_() + // re-drives it from the hub, so clearing it there is free. + this->scanner_state_pending_ = false; +#else + // Force a poll-arm mismatch: a frame refused at subscribe time could + // otherwise match the stale detector and never be retried. Inert on + // unsubscribe: loop() returns at the no-subscriber gate before the + // detector runs, and a re-subscribe re-arms this anyway. + this->last_scan_running_ = !this->hub_->scan_running(); +#endif +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + this->pending_unpairing_.clear(); + this->pending_disconnections_.fill({}); + for (uint8_t i = 0; i < this->connection_count_; i++) { + // Neither a partial stream's tail nor an owed done belongs to the next + // session; silence (the client's timeout) arbitrates. + auto *connection = this->connections_[i]; + connection->park_service_stream_(); + connection->clear_owed_flags_(); + } +#endif } -void BluetoothProxy::bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "characteristic"); -} - -void BluetoothProxy::bluetooth_gatt_read_descriptor(const api::BluetoothGATTReadDescriptorRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "read", "descriptor"); -} - -void BluetoothProxy::bluetooth_gatt_write_descriptor(const api::BluetoothGATTWriteDescriptorRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "write", "descriptor"); -} - -void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg) { - this->handle_gatt_not_connected_(msg.address, 0, "get", "services"); -} - -void BluetoothProxy::bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg) { - this->handle_gatt_not_connected_(msg.address, msg.handle, "notify", "characteristic"); -} - -void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg) { - if (this->api_connection_ == nullptr) - return; - // Send results unchecked (esp32 parity): a drop resolves via the client timeout. - api::BluetoothSetConnectionParamsResponse resp; - resp.address = msg.address; - resp.error = GATT_NOT_CONNECTED; - this->api_connection_->send_message(resp); -} - -#endif // !BLUETOOTH_CONNECTION_SERVES_PROXY - void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) { - if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) { - // A previous subscriber still holds the slot. This is almost always a stale - // connection from a client that dropped without a clean disconnect and has - // not yet hit the keepalive timeout; rejecting the new subscriber would - // silently starve it of advertisements until it reconnects, so the newest - // subscriber wins instead. - char old_peername[socket::SOCKADDR_STR_LEN]; - char new_peername[socket::SOCKADDR_STR_LEN]; - ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), - api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), - this->api_connection_->get_peername_to(old_peername)); + if (api_connection != this->api_connection_) { + if (this->api_connection_ != nullptr) { + // A previous subscriber still holds the slot. This is almost always a + // stale connection from a client that dropped without a clean disconnect + // and has not yet hit the keepalive timeout; rejecting the new + // subscriber would silently starve it of advertisements until it + // reconnects, so the newest subscriber wins instead. + char old_peername[socket::SOCKADDR_STR_LEN]; + char new_peername[socket::SOCKADDR_STR_LEN]; + ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(), + api_connection->get_peername_to(new_peername), this->api_connection_->get_name(), + this->api_connection_->get_peername_to(old_peername)); + } + // Stale retry latches belong to the previous subscriber's session; a + // re-subscribe by the current one keeps what it is still owed. + this->reset_owed_replies_(); } - // A stale retry latch belongs to the previous subscriber's session. - this->connections_free_pending_ = false; this->api_connection_ = api_connection; #ifdef USE_BLE_SCANNER_STATE_CALLBACK // get_scanner_state() is part of the push-hub surface (see BLEHubContract). - this->send_bluetooth_scanner_state_(this->hub_->get_scanner_state()); + this->send_scanner_state_(this->hub_->get_scanner_state()); #else this->send_polled_scanner_state_(); #endif @@ -630,21 +758,10 @@ void BluetoothProxy::unsubscribe_api_connection(api::APIConnection *api_connecti return; } this->api_connection_ = nullptr; - this->connections_free_pending_ = false; + this->reset_owed_replies_(); } -void BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { - if (this->api_connection_ == nullptr) - return; - api::BluetoothDeviceConnectionResponse call; - call.address = address; - call.connected = connected; - call.mtu = mtu; - call.error = error; - // Fire and forget: a drop is covered by the client's own timeouts and the - // retried connections-free state. - this->api_connection_->send_message(call); -} +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void BluetoothProxy::send_connections_free() { if (this->api_connection_ != nullptr) { this->send_connections_free(this->api_connection_); @@ -661,22 +778,33 @@ void BluetoothProxy::send_connections_free(api::APIConnection *api_connection) { } } -void BluetoothProxy::send_gatt_services_done(uint64_t address) { +bool BluetoothProxy::send_device_connection(uint64_t address, bool connected, uint16_t mtu, conn_err_t error) { if (this->api_connection_ == nullptr) - return; - api::BluetoothGATTGetServicesDoneResponse call; + return true; // Nobody subscribed: nothing owed + api::BluetoothDeviceConnectionResponse call; call.address = address; - this->api_connection_->send_message(call); + call.connected = connected; + call.mtu = mtu; + call.error = error; + return this->api_connection_->send_message(call); } -void BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { +bool BluetoothProxy::send_gatt_services_done(uint64_t address) { if (this->api_connection_ == nullptr) - return; + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false + api::BluetoothGATTGetServicesDoneResponse call; + call.address = address; + return this->api_connection_->send_message(call); +} + +bool BluetoothProxy::send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error) { + if (this->api_connection_ == nullptr) + return true; // Nobody subscribed: nothing is owed, only a refused frame reports false api::BluetoothGATTErrorResponse call; call.address = address; call.handle = handle; call.error = error; - this->api_connection_->send_message(call); + return this->api_connection_->send_message(call); } void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err_t error) { @@ -687,22 +815,46 @@ void BluetoothProxy::send_device_pairing(uint64_t address, bool paired, conn_err call.paired = paired; call.error = error; - this->api_connection_->send_message(call); + if (!this->api_connection_->send_message(call)) { + // Not latched: a retried PAIR is answered from is_paired(), so the client + // recovers on its own. Still worth saying it happened. + this->log_reply_dropped_("Pairing", address); + } } void BluetoothProxy::send_device_unpairing(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; + // An owed success is the authoritative answer: a later attempt for the + // same address fails only because the first already removed the bond. + if (!this->pending_unpairing_.empty() && this->pending_unpairing_.matches(address) && + this->pending_unpairing_.error() == CONN_OK) { + success = true; + error = CONN_OK; + } api::BluetoothDeviceUnpairingResponse call; call.address = address; call.success = success; call.error = error; - this->api_connection_->send_message(call); + if (this->api_connection_->send_message(call)) { + // A later unpair landing for an address that still has one owed would + // otherwise have the drain repeat it. + if (this->pending_unpairing_.matches(address)) { + this->pending_unpairing_.clear(); + } + return; + } + if (this->pending_unpairing_.empty()) { + this->log_reply_deferred_("Unpair", address); + } else if (!this->pending_unpairing_.matches(address)) { + this->log_reply_displaced_("Unpair", this->pending_unpairing_.address(), address); + } + this->pending_unpairing_.set(address, error); } -// Shared by both platform paths: the neutral bluetooth_device_request() uses it to -// answer a clear-cache request with a clean error, so it must not be esp32-guarded. +// GATT arm only: the advertisement-only arm no longer dispatches CLEAR_CACHE, +// so its response encoder would be dead weight there. void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, conn_err_t error) { if (this->api_connection_ == nullptr) return; @@ -711,8 +863,12 @@ void BluetoothProxy::send_device_clear_cache(uint64_t address, bool success, con call.success = success; call.error = error; - this->api_connection_->send_message(call); + if (!this->api_connection_->send_message(call)) { + // Not latched: clear-cache is idempotent, so a retry gives the same answer. + this->log_reply_dropped_("Clear-cache", address); + } } +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothProxy *global_bluetooth_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index bb43329544..e233c38b56 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -10,6 +10,7 @@ #include "esphome/components/api/api_pb2.h" #include "esphome/core/automation.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/bluetooth_connection/bluetooth_connection.h" @@ -24,9 +25,11 @@ namespace esphome::bluetooth_proxy { using bluetooth_connection::CONN_OK; using bluetooth_connection::conn_err_t; using bluetooth_connection::GATT_NOT_CONNECTED; +using bluetooth_connection::DONE_SENDING_SERVICES; using bluetooth_connection::INIT_SENDING_SERVICES; +using bluetooth_connection::SERVICES_DONE_PENDING; -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS using BluetoothConnection = bluetooth_connection::BluetoothConnection; using ClientState = ble_device_base::ClientState; #endif @@ -57,8 +60,45 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS +/// One owed address-keyed reply in a single word: 48-bit address low, 16-bit +/// error on top. Every error that reaches it fits int16_t. +class PendingReply { + public: + constexpr void set(uint64_t address, conn_err_t error) { + // Mask: the address originates from the client, and a stray high bit + // must not corrupt the reason. + this->word_ = (address & ADDRESS_MASK) | (static_cast(static_cast(error)) << 48); + } + constexpr void clear() { this->word_ = 0; } + // Whole-word test: only (address 0, error 0) reads back as nothing owed. + // A zero-address failure still latches, which is correct - that reply is + // owed too. Neither backend can unpair address 0 successfully. + constexpr bool empty() const { return this->word_ == 0; } + // Masked like set(), so a stray high bit cannot defeat the pool lookups. + constexpr bool matches(uint64_t address) const { return this->address() == (address & ADDRESS_MASK); } + constexpr uint64_t address() const { return this->word_ & ADDRESS_MASK; } + constexpr conn_err_t error() const { return static_cast(this->word_ >> 48); } + + private: + static constexpr uint64_t ADDRESS_MASK = 0x0000FFFFFFFFFFFFULL; + uint64_t word_{0}; +}; +// Pin the packing at compile time: mask and sign round-trip for every +// reachable shape (negative, GATT status, ESP_ERR_* range, stray high bit). +constexpr bool pending_reply_round_trips(uint64_t address, uint64_t expected_address, conn_err_t error) { + PendingReply p; + p.set(address, error); + return p.address() == expected_address && p.error() == error && !p.empty() && p.matches(address); +} +static_assert(pending_reply_round_trips(0x0000112233445566ULL, 0x0000112233445566ULL, -1)); +static_assert(pending_reply_round_trips(0x0000FFFFFFFFFFFFULL, 0x0000FFFFFFFFFFFFULL, 0x8F)); +static_assert(pending_reply_round_trips(0xABCD112233445566ULL, 0x0000112233445566ULL, 0x110)); +static_assert(PendingReply{}.empty()); +#endif + class BluetoothProxy final : public Component { -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Allow the connection to update connections_free_response_ friend bluetooth_connection::BluetoothConnection; #endif @@ -69,9 +109,9 @@ class BluetoothProxy final : public Component { void setup() override; void loop() override; -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void register_connection(BluetoothConnection *connection); -#endif // BLUETOOTH_CONNECTION_SERVES_PROXY +#endif // USE_BLUETOOTH_PROXY_CONNECTIONS #ifndef USE_ESP32 // Run after the hub's setup() (the trackers use AFTER_WIFI): setup() below // snapshots scan_active()/scan_running() and installs the raw callback, and @@ -80,6 +120,7 @@ class BluetoothProxy final : public Component { float get_setup_priority() const override { return setup_priority::AFTER_WIFI - 1.0f; } #endif // !USE_ESP32 +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS void bluetooth_device_request(const api::BluetoothDeviceRequest &msg); void bluetooth_gatt_read(const api::BluetoothGATTReadRequest &msg); void bluetooth_gatt_write(const api::BluetoothGATTWriteRequest &msg); @@ -88,6 +129,7 @@ class BluetoothProxy final : public Component { void bluetooth_gatt_send_services(const api::BluetoothGATTGetServicesRequest &msg); void bluetooth_gatt_notify(const api::BluetoothGATTNotifyRequest &msg); void bluetooth_set_connection_params(const api::BluetoothSetConnectionParamsRequest &msg); +#endif void subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags); void unsubscribe_api_connection(api::APIConnection *api_connection); @@ -97,14 +139,23 @@ class BluetoothProxy final : public Component { return this->api_connection_ != nullptr && this->api_connection_->client_supports_api_version(1, 12); } - void send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + /// False only when a subscriber refused the frame; true = delivered or + /// nobody subscribed. Refusals latch in send_device_disconnected_() and + /// send_connected_reply_(); other callers report via log_reply_dropped_(). + bool send_device_connection(uint64_t address, bool connected, uint16_t mtu = 0, conn_err_t error = CONN_OK); void send_connections_free(); void send_connections_free(api::APIConnection *api_connection); - void send_gatt_services_done(uint64_t address); - void send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); + /// Same convention as send_device_connection: false only on a refused frame. + bool send_gatt_services_done(uint64_t address); + /// False only when the API refused the frame, so the reply is still owed. + bool send_gatt_error(uint64_t address, uint16_t handle, conn_err_t error); void send_device_pairing(uint64_t address, bool paired, conn_err_t error = CONN_OK); - void send_device_unpairing(uint64_t address, bool success, conn_err_t error = CONN_OK); + /// No default error: the drain rebuilds success as (error == CONN_OK), so a + /// caller that omitted it would have a reported failure resent as a success. + void send_device_unpairing(uint64_t address, bool success, conn_err_t error); void send_device_clear_cache(uint64_t address, bool success, conn_err_t error = CONN_OK); +#endif void bluetooth_scanner_set_mode(bool active); @@ -158,8 +209,8 @@ class BluetoothProxy final : public Component { return flags; } - void get_bluetooth_mac_address_pretty(std::span output) { - uint8_t mac[6] = {}; + void get_bluetooth_mac_address_pretty(std::span output) { + uint8_t mac[MAC_ADDRESS_SIZE] = {}; this->hub_->get_adapter_mac(mac); // Unavailable -> empty string: some hubs (rp2040's BTstack) only learn // the address once the link layer is up, and report all-zero until then. @@ -172,7 +223,9 @@ class BluetoothProxy final : public Component { protected: bool send_bluetooth_scanner_state_(ble_device_base::ScannerState state); -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + void send_scanner_state_(ble_device_base::ScannerState state); +#else void send_polled_scanner_state_(); #endif void on_raw_advertisement_(const ble_device_base::RawAdvertisement &raw); @@ -181,30 +234,27 @@ class BluetoothProxy final : public Component { void flush_pending_advertisements_() { if (this->response_.advertisements_len == 0) return; - this->api_connection_->send_message(this->response_); + // Perishable and the highest-frequency send here: a drop only reports at + // V, anything louder would be the flood the batch pacing exists to avoid. + [[maybe_unused]] bool sent = this->api_connection_->send_message(this->response_); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->log_advertisement_flush_(); + this->log_advertisement_flush_(sent); #endif this->response_.advertisements_len = 0; } - void log_advertisement_flush_(); + void log_advertisement_flush_(bool sent); -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS BluetoothConnection *get_connection_(uint64_t address, bool reserve); void log_connection_request_ignored_(BluetoothConnection *connection, ClientState state); void log_connection_info_(BluetoothConnection *connection, const char *message); -#endif void log_not_connected_gatt_(const char *action, const char *type); void handle_gatt_not_connected_(uint64_t address, uint16_t handle, const char *action, const char *type); -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY /// Keep the pre-allocated connections-free message in step when a /// connection slot changes address (0 = free). Called from the connection /// classes' set_address(). - // maybe_unused + guard: in a passive proxy (active: false) MAX is 0, the - // body is removed, and the free < MAX compare would trip -Wtype-limits. - void update_address_slot_([[maybe_unused]] uint64_t old_address, [[maybe_unused]] uint64_t new_address) { -#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 + void update_address_slot_(uint64_t old_address, uint64_t new_address) { auto &resp = this->connections_free_response_; if (new_address == 0 && old_address != 0) { if (resp.free < BLUETOOTH_PROXY_MAX_CONNECTIONS) { @@ -221,7 +271,6 @@ class BluetoothProxy final : public Component { } this->replace_allocated_slot_(0, new_address); } -#endif // BLUETOOTH_PROXY_MAX_CONNECTIONS > 0 } void replace_allocated_slot_(uint64_t find_value, uint64_t set_value); void log_slot_accounting_mismatch_(); @@ -231,15 +280,50 @@ class BluetoothProxy final : public Component { /// a 30-second timeout (DEFAULT_BLE_TIMEOUT) to detect incomplete service /// discovery and retry, rather than being told a partial list is complete. void reset_connection_slot_(BluetoothConnection *connection, conn_err_t reason); + /// Drop any owed freed-slot notification for this address (client reconnected). + void clear_pending_disconnection_(uint64_t address); + /// Send connected=false and pool it for the paced drain if refused. A + /// dropped disconnect desynchronises the proxy: the client keeps a link it + /// believes is live and every operation on it times out. Unsolicited and + /// drained notifications only; request answers use the variant below. + void send_device_disconnected_(uint64_t address, conn_err_t error = CONN_OK); + /// Answer a request with connected=false. Never pools: a refusal falls back + /// to the client's request timeout, keeping the pool for the unsolicited + /// notifications the client cannot recover on its own. + void answer_device_disconnected_(uint64_t address); + /// Pool a refused freed-slot notification for the paced drain. + void latch_pending_disconnection_(uint64_t address, conn_err_t error); +#endif + + /// Drop everything the ending session was owed. One list, so a new latch is + /// one edit rather than two call sites where an omission looks deliberate. + /// Drops state only, never sends: api_connection_ is the departing + /// subscriber on subscribe and nullptr on unsubscribe. + void reset_owed_replies_(); +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS + /// Report a reply we deliberately do not latch, so no drop is silent. + void log_reply_dropped_(const char *what, uint64_t address); + /// A latched reply's leading edge; the drain's re-refusals stay quiet. + void log_reply_deferred_(const char *what, uint64_t address); + /// A latched reply lost to a newer one for a different address. + void log_reply_displaced_(const char *what, uint64_t owed, uint64_t address); #endif // Memory optimized layout for 32-bit systems // Group 1: Pointers (4 bytes each, naturally aligned) api::APIConnection *api_connection_{nullptr}; -#ifdef BLUETOOTH_CONNECTION_SERVES_PROXY +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Group 2: Fixed-size array of connection pointers std::array connections_{}; + // Address-keyed pool of owed freed-slot notifications; loop() resends. + // Proxy-only state, kept off BluetoothConnection; entries are not tied to + // slot indices. + std::array pending_disconnections_{}; + // Owed unpair reply. The bond is already gone when the send is refused, so + // a retry is told the unpair failed when it succeeded. One slot: a second + // refused unpair displaces the first, as happened to both before this. + PendingReply pending_unpairing_{}; #endif ble_device_base::BLEHub *hub_{nullptr}; // Group 3: 4-byte types; paired with hub_ so the 8-aligned messages below @@ -249,18 +333,32 @@ class BluetoothProxy final : public Component { // BLE advertisement batching api::BluetoothLERawAdvertisementsResponse response_; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // Pre-allocated response message - always ready to send api::BluetoothConnectionsFreeResponse connections_free_response_; +#endif // Group 4: 1-byte types grouped together bool active_; +#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS // A dropped send (full TCP buffer) would leave the API client with a stale // slot state forever; the cached response is current by construction, so // retrying it from loop() is an idempotent resync. bool connections_free_pending_{false}; uint8_t connection_count_{0}; +#endif bool configured_scan_active_{false}; // Configured scan mode from YAML -#ifndef USE_BLE_SCANNER_STATE_CALLBACK +#ifdef USE_WIFI + /// Wi-Fi only: flush on every other non-empty tick (~200 ms) so partial + /// batches fill; an idle tick re-arms, so the first batch after a gap + /// still ships on the next tick. See loop(). + bool adv_flush_toggle_{false}; +#endif +#ifdef USE_BLE_SCANNER_STATE_CALLBACK + // A dropped push (full TX buffer) is re-queried from the hub and resent + // from loop(); the hub's current state is idempotent by construction. + bool scanner_state_pending_{false}; +#else bool last_scan_running_{false}; // Last scanner state reported to the subscriber #endif }; diff --git a/esphome/components/bme68x_bsec2/__init__.py b/esphome/components/bme68x_bsec2/__init__.py index 63f63c5da2..c12eb39d2d 100644 --- a/esphome/components/bme68x_bsec2/__init__.py +++ b/esphome/components/bme68x_bsec2/__init__.py @@ -1,4 +1,3 @@ -import hashlib from pathlib import Path from esphome import core, external_files @@ -12,6 +11,8 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_TEMPERATURE_OFFSET, ) +from esphome.external_files import RemoteFile +from esphome.types import ConfigType CODEOWNERS = ["@neffs", "@kbx81"] CONFLICTS_WITH = ["bme680_bsec"] @@ -74,11 +75,7 @@ VOLTAGE_FILE_NAME = { def _compute_local_file_path(url: str) -> Path: - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def _compute_url(config: dict) -> str: @@ -105,6 +102,42 @@ def download_bme68x_blob(config): return config +# Shared by the schema and the prefetch hook so they cannot drift. +_MODEL_VALIDATOR = cv.one_of(*MODEL_OPTIONS, lower=True) +_ALGORITHM_OUTPUT_VALIDATOR = cv.enum(ALGORITHM_OUTPUT_OPTIONS, lower=True) +# Key -> (validator, default) for the defaulted options that select the blob. +_BLOB_OPTIONS = { + CONF_OPERATING_AGE: (cv.enum(OPERATING_AGE_OPTIONS, lower=True), "28d"), + CONF_SAMPLE_RATE: (cv.enum(SAMPLE_RATE_OPTIONS, upper=True), "LP"), + CONF_SUPPLY_VOLTAGE: (cv.enum(VOLTAGE_OPTIONS, upper=True), "3.3V"), +} + + +def _extract_blob_ref(entry: ConfigType) -> RemoteFile | None: + """Raw entry to its BSEC2 blob; None when a value is unrecognized. + + Applies the schema defaults and validators read-only; skipped entries + are left to the schema validator. + """ + try: + spec = { + key: validator(str(entry.get(key, default))) # pylint: disable=not-callable + for key, (validator, default) in _BLOB_OPTIONS.items() + } + spec[CONF_MODEL] = _MODEL_VALIDATOR(str(entry.get(CONF_MODEL, ""))) + if (algorithm_output := entry.get(CONF_ALGORITHM_OUTPUT)) is not None: + spec[CONF_ALGORITHM_OUTPUT] = _ALGORITHM_OUTPUT_VALIDATOR( + str(algorithm_output) + ) + except cv.Invalid: + return None + url = _compute_url(spec) + return RemoteFile(url, _compute_local_file_path(url)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_blob_ref) + + def validate_bme68x(config): if CONF_ALGORITHM_OUTPUT not in config: return config @@ -128,19 +161,12 @@ CONFIG_SCHEMA_BASE = ( { cv.GenerateID(): cv.declare_id(BME68xBSEC2Component), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.Required(CONF_MODEL): cv.one_of(*MODEL_OPTIONS, lower=True), - cv.Optional(CONF_ALGORITHM_OUTPUT): cv.enum( - ALGORITHM_OUTPUT_OPTIONS, lower=True - ), - cv.Optional(CONF_OPERATING_AGE, default="28d"): cv.enum( - OPERATING_AGE_OPTIONS, lower=True - ), - cv.Optional(CONF_SAMPLE_RATE, default="LP"): cv.enum( - SAMPLE_RATE_OPTIONS, upper=True - ), - cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( - VOLTAGE_OPTIONS, upper=True - ), + cv.Required(CONF_MODEL): _MODEL_VALIDATOR, + cv.Optional(CONF_ALGORITHM_OUTPUT): _ALGORITHM_OUTPUT_VALIDATOR, + **{ + cv.Optional(key, default=default): validator + for key, (validator, default) in _BLOB_OPTIONS.items() + }, cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, cv.Optional( CONF_STATE_SAVE_INTERVAL, default="6hours" diff --git a/esphome/components/bme68x_bsec2_i2c/__init__.py b/esphome/components/bme68x_bsec2_i2c/__init__.py index c8ca0ba022..dacd4e32ad 100644 --- a/esphome/components/bme68x_bsec2_i2c/__init__.py +++ b/esphome/components/bme68x_bsec2_i2c/__init__.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import i2c +from esphome.components import bme68x_bsec2, i2c from esphome.components.bme68x_bsec2 import ( CONFIG_SCHEMA_BASE, BME68xBSEC2Component, @@ -13,6 +13,11 @@ AUTO_LOAD = ["bme68x_bsec2"] DEPENDENCIES = ["i2c"] MULTI_CONF = True +# The user-facing domain is this module (the base component only appears +# via AUTO_LOAD), so the batch-download hook must be re-exported here to +# take effect. +PREFETCH_FILES = bme68x_bsec2.PREFETCH_FILES + bme68x_bsec2_i2c_ns = cg.esphome_ns.namespace("bme68x_bsec2_i2c") BME68xBSEC2I2CComponent = bme68x_bsec2_i2c_ns.class_( "BME68xBSEC2I2CComponent", BME68xBSEC2Component, i2c.I2CDevice diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 8094903008..704a61d4de 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -14,7 +14,7 @@ static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate")); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = get_mac_address_pretty_into_buffer(mac_s); #ifdef USE_ESP8266 stream->print(ESPHOME_F("{\"mac\":\"")); diff --git a/esphome/components/debug/debug_esp32.cpp b/esphome/components/debug/debug_esp32.cpp index 7c01f9b54f..969cd840cf 100644 --- a/esphome/components/debug/debug_esp32.cpp +++ b/esphome/components/debug/debug_esp32.cpp @@ -4,6 +4,7 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include #include @@ -249,7 +250,7 @@ size_t DebugComponent::get_device_info_(std::span const char *reset_reason = get_reset_reason_(std::span(reset_buffer)); const char *wakeup_cause = get_wakeup_cause_(std::span(wakeup_buffer)); - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); ESP_LOGD(TAG, diff --git a/esphome/components/debug/debug_rp2.cpp b/esphome/components/debug/debug_rp2.cpp index 336e9c7e06..4ace4be0a3 100644 --- a/esphome/components/debug/debug_rp2.cpp +++ b/esphome/components/debug/debug_rp2.cpp @@ -1,8 +1,9 @@ #include "debug_component.h" #ifdef USE_RP2 #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/core/log.h" -#include +#include #include #if defined(PICO_RP2350) #include @@ -68,13 +69,14 @@ const char *DebugComponent::get_reset_reason_(std::span buffer) { return ""; } -uint32_t DebugComponent::get_free_heap_() { return ::rp2040.getFreeHeap(); } +// RAMAllocator already implements the free-heap calculation for this platform, so it is not duplicated here. +uint32_t DebugComponent::get_free_heap_() { return RAMAllocator().get_free_heap_size(); } size_t DebugComponent::get_device_info_(std::span buffer, size_t pos) { constexpr size_t size = DEVICE_INFO_BUFFER_SIZE; char *buf = buffer.data(); - uint32_t cpu_freq = RP2040::f_cpu(); + uint32_t cpu_freq = clock_get_hz(clk_sys); ESP_LOGD(TAG, "CPU Frequency: %" PRIu32, cpu_freq); pos = buf_append_printf(buf, size, pos, "|CPU Frequency: %" PRIu32, cpu_freq); diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 2e72c78974..ada6d25db5 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3280,27 +3280,45 @@ def copy_files(): __version__, ) + # Remote extra build files are fetched into the shared download cache in + # one parallel batch (conditional requests skip unchanged files), then + # copied into the build tree like their local counterparts. + sources: dict[str, Path] = {} + remote: list[tuple[str, str]] = [] for file in CORE.data[KEY_ESP32][KEY_EXTRA_BUILD_FILES].values(): name: str = file[KEY_NAME] path: Path = file[KEY_PATH] if str(path).startswith("http"): - import requests - - from esphome.happy_eyeballs import ensure_happy_eyeballs - - ensure_happy_eyeballs() - - try: - req = requests.get(path, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise EsphomeError( - f"Could not download extra build file {path}: {e}" - ) from e - CORE.relative_build_path(name).parent.mkdir(parents=True, exist_ok=True) - CORE.relative_build_path(name).write_bytes(req.content) + remote.append((name, str(path))) else: - copy_file_if_changed(path, CORE.relative_build_path(name)) + sources[name] = path + if remote: + # Imported lazily: requests (via external_files) is a heavy import + # and remote extra build files are rare. + from esphome import external_files + + downloads: list[external_files.RemoteFile] = [] + for name, url in remote: + cache_path = external_files.compute_local_file_path(KEY_ESP32, url) + # Unverifiable bytes: an unrevalidated copy is an error, matching + # the old always-download behavior on network failure. + downloads.append( + external_files.RemoteFile(url, cache_path, allow_stale=False) + ) + sources[name] = cache_path + try: + external_files.download_content_many( + downloads, description="extra build file(s)" + ) + except cv.MultipleInvalid as e: + details = "; ".join(str(err) for err in e.errors) + raise EsphomeError( + f"Could not download extra build file(s): {details}" + ) from e + except cv.Invalid as e: + raise EsphomeError(f"Could not download extra build file(s): {e}") from e + for name, source in sources.items(): + copy_file_if_changed(source, CORE.relative_build_path(name)) def _decode_pc(config, addr): diff --git a/esphome/components/esp32/helpers.cpp b/esphome/components/esp32/helpers.cpp index afcec8bfc7..c2ff6cf34d 100644 --- a/esphome/components/esp32/helpers.cpp +++ b/esphome/components/esp32/helpers.cpp @@ -109,7 +109,7 @@ void set_mac_address(uint8_t *mac) { esp_base_mac_addr_set(mac); } bool has_custom_mac_address() { #if !defined(USE_ESP32_IGNORE_EFUSE_CUSTOM_MAC) - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; // do not use 'esp_efuse_mac_get_custom(mac)' because it drops an error in the logs whenever it fails #ifndef USE_ESP32_VARIANT_ESP32 return (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA_MAC_CUSTOM, mac, MAC_ADDRESS_SIZE_BITS) == ESP_OK) && diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index d11683ab35..16501ef3b2 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -674,21 +674,21 @@ void ESP32BLE::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gat } #endif -void ESP32BLE::get_mac_msb_first(uint8_t out[6]) const { +void ESP32BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { // The running stack owns the address (on hosted controllers it lives in // the remote chip's efuse); null before init becomes all-zero. const uint8_t *mac = esp_bt_dev_get_address(); if (mac != nullptr) { - memcpy(out, mac, 6); + memcpy(out, mac, MAC_ADDRESS_SIZE); } else { - memset(out, 0, 6); + memset(out, 0, MAC_ADDRESS_SIZE); } } float ESP32BLE::get_setup_priority() const { return setup_priority::BLUETOOTH; } void ESP32BLE::dump_config() { - uint8_t mac_address[6]; + uint8_t mac_address[MAC_ADDRESS_SIZE]; this->get_mac_msb_first(mac_address); if (mac_address_is_valid(mac_address)) { const char *io_capability_s; @@ -713,7 +713,7 @@ void ESP32BLE::dump_config() { break; } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, "BLE:\n" diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 45cfd8ee71..2a355a6c8b 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -109,7 +109,7 @@ class ESP32BLE final : public Component { void loop() override; void dump_config() override; /// Adapter MAC in printable (MSB-first) order; all-zero until the stack is up. - void get_mac_msb_first(uint8_t out[6]) const; + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; float get_setup_priority() const override; void set_name(const char *name) { this->name_ = name; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 18b6cf022d..798fd6e0ca 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -55,6 +55,7 @@ void ESP32BLETracker::setup() { #ifdef USE_OTA_STATE_LISTENER void ESP32BLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) { if (state == ota::OTA_STARTED) { + ESP_LOGD(TAG, "Stopping scan for OTA"); this->scan_continuous_before_ota_ = this->scan_continuous_; this->stop_scan(); #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT @@ -190,7 +191,9 @@ void ESP32BLETracker::loop() { void ESP32BLETracker::start_scan() { this->start_scan_(true); } void ESP32BLETracker::stop_scan() { - ESP_LOGD(TAG, "Stopping scan."); + // V to match the start log: the mode-switch and OTA callers narrate their + // reason at D themselves, and the user-facing stop action is deliberate. + ESP_LOGV(TAG, "Stopping scan."); this->scan_continuous_ = false; this->stop_scan_(); } @@ -199,8 +202,9 @@ void ESP32BLETracker::ble_before_disabled_event_handler() { this->stop_scan_(); void ESP32BLETracker::stop_scan_() { if (this->scanner_state_ != ScannerState::RUNNING && this->scanner_state_ != ScannerState::FAILED) { - // If scanner is already idle, there's nothing to stop - this is not an error - if (this->scanner_state_ != ScannerState::IDLE) { + // IDLE means there is nothing to stop; STOPPING means a stop is already in + // flight and will finish on its own. Neither is an error. + if (this->scanner_state_ != ScannerState::IDLE && this->scanner_state_ != ScannerState::STOPPING) { ESP_LOGE(TAG, "Cannot stop scan: %s", this->scanner_state_to_string_(this->scanner_state_)); } return; diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index 30b85b5417..7c3e5538fd 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -200,7 +200,7 @@ class ESP32BLETracker final : public Component, return {/* active_scan = */ true, /* merges_scan_response = */ true, /* gatt = */ true, /* scan_mode_switch = */ false}; } - void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } bool scan_running() { return this->scanner_state_ == ScannerState::RUNNING; } bool scan_active() { return this->scan_active_; } // The mode is driven through this tracker's own API (see get_capabilities); diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index dc084796e7..646e0af8e6 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -140,11 +140,17 @@ class EthernetComponent final : public Component { bool is_disabled() { return this->disabled_; } bool is_enabled() { return !this->disabled_; } +#ifdef USE_ESP32 + /// esp_netif handle, used by network for default-route arbitration. + /// nullptr until the driver/netif installation has run. + esp_netif_t *get_esp_netif() { return this->eth_netif_; } +#endif + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); #endif - void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } + void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } network::IPAddresses get_ip_addresses(); network::IPAddress get_dns_address(uint8_t num); @@ -336,7 +342,7 @@ class EthernetComponent final : public Component { bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ - optional> fixed_mac_; + optional> fixed_mac_; #ifdef USE_ETHERNET_IP_STATE_LISTENERS StaticVector ip_state_listeners_; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 7cf8cdf736..0220d6a19b 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -429,9 +429,9 @@ void EthernetComponent::ethernet_lazy_init_() { #endif // !USE_ETHERNET_SPI // use ESP internal eth mac - uint8_t mac_addr[6]; + uint8_t mac_addr[MAC_ADDRESS_SIZE]; if (this->fixed_mac_.has_value()) { - memcpy(mac_addr, this->fixed_mac_->data(), 6); + memcpy(mac_addr, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac_addr, ESP_MAC_ETH); } @@ -789,16 +789,25 @@ void EthernetComponent::start_connect_() { #ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { - LwIPLock lock; + // Set DNS through esp_netif so the servers are stored in the netif's own + // dns[] array; raw dns_setserver() would be lost when the default-route + // arbitration re-applies the default netif's DNS. + // Log-only on failure: the link still has a working IP/gateway, so degraded + // name resolution does not justify marking the whole component failed. + esp_netif_dns_info_t dns{}; if (this->manual_ip_->dns1.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns1; - dns_setserver(0, &d); + dns.ip = this->manual_ip_->dns1; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_MAIN, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set main DNS failed: %s", esp_err_to_name(err)); + } } if (this->manual_ip_->dns2.is_set()) { - ip_addr_t d; - d = this->manual_ip_->dns2; - dns_setserver(1, &d); + dns.ip = this->manual_ip_->dns2; + err = esp_netif_set_dns_info(this->eth_netif_, ESP_NETIF_DNS_BACKUP, &dns); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Set backup DNS failed: %s", esp_err_to_name(err)); + } } } else #endif @@ -926,7 +935,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC. if (this->fixed_mac_.has_value()) { - memcpy(mac, this->fixed_mac_->data(), 6); + memcpy(mac, this->fixed_mac_->data(), MAC_ADDRESS_SIZE); } else { esp_read_mac(mac, ESP_MAC_ETH); } @@ -944,7 +953,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() { const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/components/ethernet/ethernet_component_rp2.cpp b/esphome/components/ethernet/ethernet_component_rp2.cpp index 4d6d6c4f5b..119e447689 100644 --- a/esphome/components/ethernet/ethernet_component_rp2.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2.cpp @@ -245,7 +245,7 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { if (this->eth_ != nullptr) { this->eth_->macAddress(mac); } else { - memset(mac, 0, 6); + memset(mac, 0, MAC_ADDRESS_SIZE); } } @@ -256,7 +256,7 @@ std::string EthernetComponent::get_eth_mac_address_pretty() { const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_eth_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/components/file/image.py b/esphome/components/file/image.py index 9a7c762a79..b54c3f2adf 100644 --- a/esphome/components/file/image.py +++ b/esphome/components/file/image.py @@ -1,7 +1,6 @@ from __future__ import annotations import contextlib -import hashlib import io import logging from pathlib import Path @@ -43,15 +42,13 @@ from esphome.const import ( ) from esphome.core import CORE, HexInt from esphome.cpp_generator import MockObj, MockObjClass +from esphome.external_files import RemoteFile from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) -# If the MDI file cannot be downloaded within this time, abort. -IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds - SOURCE_LOCAL = "local" SOURCE_WEB = "web" @@ -65,16 +62,16 @@ MDI_SOURCES = { SOURCE_MEMORY: "https://raw.githubusercontent.com/Pictogrammers/Memory/refs/heads/main/src/svg/", } +# Shared by the schema validator and the prefetch extractor so they cannot +# drift. +_MDI_ICON_RE = re.compile(r"^[a-zA-Z0-9\-]+$") -def compute_local_image_path(value) -> Path: + +def compute_local_image_path(value: str | ConfigType) -> Path: url = value[CONF_URL] if isinstance(value, dict) else value - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] # Downloaded files are cached under the shared `image` domain directory so # the cache location is unaffected by which platform requested the file. - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, url) def local_path(value): @@ -83,16 +80,20 @@ def local_path(value): def download_file(url, path): - external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT) + # The shared NETWORK_TIMEOUT applies; a per-caller timeout would be + # silently ignored on a per-run memo hit anyway (memos key by path). + external_files.download_content(url, path) return str(path) -def download_gh_svg(value, source): - mdi_id = value[CONF_ICON] if isinstance(value, dict) else value +def _gh_svg_url_path(mdi_id: str, source: str) -> tuple[str, Path]: base_dir = external_files.compute_local_file_dir(DOMAIN) / source - path = base_dir / f"{mdi_id}.svg" + return MDI_SOURCES[source] + mdi_id + ".svg", base_dir / f"{mdi_id}.svg" - url = MDI_SOURCES[source] + mdi_id + ".svg" + +def download_gh_svg(value: str | ConfigType, source: str) -> str: + mdi_id = value[CONF_ICON] if isinstance(value, dict) else value + url, path = _gh_svg_url_path(mdi_id, source) return download_file(url, path) @@ -101,17 +102,53 @@ def download_image(value): return download_file(value, compute_local_image_path(value)) -def validate_file_shorthand(value): - value = cv.string_strict(value) +def _parse_remote_shorthand(value: str) -> RemoteFile | None: + """Parse a string `file:` shorthand to its remote file; None if local. + + Raises cv.Invalid for a malformed icon name. Shared by the schema + validator and the prefetch extractor so they cannot drift. + """ parts = value.strip().split(":") if len(parts) == 2 and parts[0] in MDI_SOURCES: - match = re.match(r"^[a-zA-Z0-9\-]+$", parts[1]) - if match is None: + if _MDI_ICON_RE.match(parts[1]) is None: raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.") - return download_gh_svg(parts[1], parts[0]) - + return RemoteFile(*_gh_svg_url_path(parts[1], parts[0])) if value.startswith(("http://", "https://")): - return download_image(value) + return RemoteFile(value, compute_local_image_path(value)) + return None + + +def _extract_file_ref(value: object) -> RemoteFile | None: + """Map a raw, pre-schema `file:` value to its remote file. + + Returns None for local files and anything it does not recognize; the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + return _parse_remote_shorthand(value) + except cv.Invalid: + return None + if isinstance(value, dict): + source = value.get(CONF_SOURCE) + if source == SOURCE_WEB and isinstance(url := value.get(CONF_URL), str): + return RemoteFile(url, compute_local_image_path(url)) + if source in MDI_SOURCES and isinstance(icon := value.get(CONF_ICON), str): + return RemoteFile(*_gh_svg_url_path(icon, source)) + return None + + +def _extract_entry_ref(entry: ConfigType) -> RemoteFile | None: + return _extract_file_ref(entry.get(CONF_FILE)) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_entry_ref) + + +def validate_file_shorthand(value): + value = cv.string_strict(value) + if (remote := _parse_remote_shorthand(value)) is not None: + return download_file(remote.url, remote.path) value = cv.file_(value) return local_path(value) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 5872b607f1..918fde5dbd 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -1,6 +1,5 @@ -from collections.abc import MutableMapping +from collections.abc import Iterable, MutableMapping import functools -import hashlib from itertools import accumulate import logging from pathlib import Path @@ -17,7 +16,6 @@ from freetype import ( FT_Exception, ft_pixel_mode_mono, ) -import requests from esphome import external_files import esphome.codegen as cg @@ -36,7 +34,7 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.external_files import RemoteFile from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -296,46 +294,80 @@ def validate_weight_name(value): return FONT_WEIGHTS[cv.one_of(*FONT_WEIGHTS, lower=True, space="-")(value)] -def _compute_local_font_path(value: dict) -> Path: - url = value[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - _LOGGER.debug("_compute_local_font_path: %s", base_dir / key) - return base_dir / key +def _web_font_path(value: dict) -> Path: + return external_files.compute_local_file_path(DOMAIN, value[CONF_URL]) / "font.ttf" -def download_gfont(value): +def _gfonts_css_url(value: dict) -> str: + return ( + f"https://fonts.googleapis.com/css2?family={value[CONF_FAMILY]}" + f":ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" + ) + + +def _gfonts_cache_path(value: dict, suffix: str) -> Path: + name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1" + return external_files.compute_local_file_dir(DOMAIN) / f"{name}.{suffix}" + + +def _gfonts_ttf_path(value: dict) -> Path: + return _gfonts_cache_path(value, "ttf") + + +def _gfonts_css_path(value: dict) -> Path: + return _gfonts_cache_path(value, "css") + + +def _parse_gfonts_css(css: str) -> str | None: + """Extract the truetype URL from a Google Fonts CSS response.""" + match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", css) + return match.group(1) if match else None + + +def download_gfont(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value - name = ( - f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}" - ) - url = f"https://fonts.googleapis.com/css2?family={name}" - path = ( - external_files.compute_local_file_dir(DOMAIN) - / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf" - ) + path = _gfonts_ttf_path(value) if not external_files.is_file_recent(path, value[CONF_REFRESH]): _LOGGER.debug("download_gfont: path=%s", path) + url = _gfonts_css_url(value) + css_path = _gfonts_css_path(value) try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT) - req.raise_for_status() - except requests.exceptions.RequestException as e: + css_bytes = external_files.download_content(url, css_path) + except cv.Invalid as e: raise cv.Invalid( f"Could not download font at {url}, please check the fonts exists " f"at google fonts ({e})" ) from e - match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text) - if match is None: + if not ( + external_files.is_fresh_this_run(css_path) or CORE.skip_external_update + ): + # Same rule as PREFETCH_FILES stage two: a CSS body that could + # not be revalidated may name a rotated ttf URL. Use the cached + # font instead (the failed check already warned). + if path.exists(): + FONT_CACHE[value] = path + return value raise cv.Invalid( - f"Could not extract ttf file from gfonts response for {name}, " - f"please report this." + f"Could not refresh the Google Fonts CSS for " + f"{value[CONF_FAMILY]} and no cached font is available" + ) + try: + css = css_bytes.decode("utf-8") + except UnicodeDecodeError as e: + # Do not leave an unusable body in the cache to be served again. + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Bad response from Google Fonts for {value[CONF_FAMILY]}: " + f"not a text document" + ) from e + ttf_url = _parse_gfonts_css(css) + if ttf_url is None: + css_path.unlink(missing_ok=True) + raise cv.Invalid( + f"Could not extract ttf file from gfonts response for " + f"{value[CONF_FAMILY]}, please report this." ) - - ttf_url = match.group(1) _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url) external_files.download_content(ttf_url, path) @@ -346,11 +378,11 @@ def download_gfont(value): return value -def download_web_font(value): +def download_web_font(value: ConfigType) -> ConfigType: if value in FONT_CACHE: return value url = value[CONF_URL] - path = _compute_local_font_path(value) / "font.ttf" + path = _web_font_path(value) external_files.download_content(url, path) _LOGGER.debug("download_web_font: path=%s", path) @@ -358,13 +390,18 @@ def download_web_font(value): return value +# Shared by the schema and the prefetch extractor so they cannot drift. +_DEFAULT_WEIGHT = "regular" +_DEFAULT_ITALIC = False +_DEFAULT_REFRESH = "1d" +_WEIGHT_VALIDATOR = cv.Any(cv.int_, validate_weight_name) +_REFRESH_VALIDATOR = cv.All(cv.string, cv.source_refresh) + EXTERNAL_FONT_SCHEMA = cv.Schema( { - cv.Optional(CONF_WEIGHT, default="regular"): cv.Any( - cv.int_, validate_weight_name - ), - cv.Optional(CONF_ITALIC, default=False): cv.boolean, - cv.Optional(CONF_REFRESH, default="1d"): cv.All(cv.string, cv.source_refresh), + cv.Optional(CONF_WEIGHT, default=_DEFAULT_WEIGHT): _WEIGHT_VALIDATOR, + cv.Optional(CONF_ITALIC, default=_DEFAULT_ITALIC): cv.boolean, + cv.Optional(CONF_REFRESH, default=_DEFAULT_REFRESH): _REFRESH_VALIDATOR, } ) @@ -387,36 +424,123 @@ WEB_FONT_SCHEMA = cv.All( ) -def validate_file_shorthand(value): - value = cv.string_strict(value) +_GFONTS_SHORTHAND_RE = re.compile(r"^gfonts://([^@]+)(@.+)?$") + + +def _shorthand_to_file_dict(value: str) -> ConfigType | None: + """Typed-dict form of a remote font shorthand. + + Shared by the schema validator and the prefetch extractor so the two + cannot drift. Returns None for values that are not remote shorthand + (i.e. local paths); raises cv.Invalid for a malformed gfonts shorthand. + """ if value.startswith("gfonts://"): - match = re.match(r"^gfonts://([^@]+)(@.+)?$", value) - if match is None: + if (match := _GFONTS_SHORTHAND_RE.match(value)) is None: raise cv.Invalid("Could not parse gfonts shorthand syntax, please check it") - family = match.group(1) - weight = match.group(2) - data = { + data = {CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: match.group(1)} + if match.group(2): + data[CONF_WEIGHT] = match.group(2)[1:] + return data + if value.startswith(("http://", "https://")): + return {CONF_TYPE: TYPE_WEB, CONF_URL: value} + return None + + +def _extract_remote_font(value: object) -> ConfigType | None: + """Map a raw, pre-schema font `file:` value to a normalized remote spec. + + Read-only mirror of `validate_file_shorthand` / `TYPED_FILE_SCHEMA` for + the prefetch hooks; returns None for local fonts and anything it does + not recognize. A wrong answer only wastes or misses a prefetch, the + schema validators stay authoritative. + """ + if isinstance(value, str): + try: + value = _shorthand_to_file_dict(value) + except cv.Invalid: + return None + if not isinstance(value, dict): + return None + font_type = value.get(CONF_TYPE) + if font_type == TYPE_WEB and isinstance(url := value.get(CONF_URL), str): + return {CONF_TYPE: TYPE_WEB, CONF_URL: url} + if font_type == TYPE_GFONTS and isinstance(family := value.get(CONF_FAMILY), str): + try: + italic = cv.boolean(value.get(CONF_ITALIC, _DEFAULT_ITALIC)) + weight = _WEIGHT_VALIDATOR(value.get(CONF_WEIGHT, _DEFAULT_WEIGHT)) + refresh = _REFRESH_VALIDATOR(value.get(CONF_REFRESH, _DEFAULT_REFRESH)) + except cv.Invalid: + return None + return { CONF_TYPE: TYPE_GFONTS, CONF_FAMILY: family, + CONF_WEIGHT: weight, + CONF_ITALIC: italic, + CONF_REFRESH: refresh, } - if weight is not None: - data[CONF_WEIGHT] = weight[1:] - return font_file_schema(data) + return None - if value.startswith(("http://", "https://")): - return font_file_schema( - { - CONF_TYPE: TYPE_WEB, - CONF_URL: value, - } - ) - return font_file_schema( - { - CONF_TYPE: TYPE_LOCAL, - CONF_PATH: value, - } - ) +def _iter_remote_specs(entries: list[ConfigType]) -> Iterable[ConfigType]: + """Yield the remote spec of every `file:` value, including extras.""" + for entry in entries: + values = [entry.get(CONF_FILE)] + extras = entry.get(CONF_EXTRAS) + if isinstance(extras, dict): + # The schema runs cv.ensure_list on extras, so a bare mapping + # is valid raw config; mirror that normalization here. + extras = [extras] + if isinstance(extras, list): + values.extend( + extra.get(CONF_FILE) for extra in extras if isinstance(extra, dict) + ) + for value in values: + if (spec := _extract_remote_font(value)) is not None: + yield spec + + +def PREFETCH_FILES(entries: list[ConfigType]) -> Iterable[list[RemoteFile]]: + """Batch-download hook: web fonts, then Google Fonts CSS, then ttf. + + Stage one fetches web fonts and the CSS of stale gfonts; stage two + parses the now-cached CSS for the ttf URLs it names. + """ + stage1: list[RemoteFile] = [] + # Keyed by cache path: the same font at several sizes is one download, + # one freshness stat, and one stage-two CSS parse. + stale_gfonts: dict[Path, ConfigType] = {} + seen_web: set[Path] = set() + for spec in _iter_remote_specs(entries): + if spec[CONF_TYPE] == TYPE_WEB: + if (path := _web_font_path(spec)) not in seen_web: + seen_web.add(path) + stage1.append(RemoteFile(spec[CONF_URL], path)) + elif (css_path := _gfonts_css_path(spec)) not in stale_gfonts and ( + not external_files.is_file_recent( + _gfonts_ttf_path(spec), spec[CONF_REFRESH] + ) + ): + stale_gfonts[css_path] = spec + stage1.append(RemoteFile(_gfonts_css_url(spec), css_path)) + yield stage1 + + yield [ + RemoteFile(ttf_url, _gfonts_ttf_path(spec)) + for css_path, spec in stale_gfonts.items() + # Only trust CSS that stage one actually refreshed this run; a + # leftover from an earlier run may name a rotated ttf URL. + if external_files.is_fresh_this_run(css_path) + and css_path.exists() + and (ttf_url := _parse_gfonts_css(css_path.read_text("utf-8", "replace"))) + is not None + ] + + +def validate_file_shorthand(value: object) -> ConfigType: + value = cv.string_strict(value) + if (data := _shorthand_to_file_dict(value)) is None: + data = {CONF_TYPE: TYPE_LOCAL, CONF_PATH: value} + return font_file_schema(data) TYPED_FILE_SCHEMA = cv.typed_schema( diff --git a/esphome/components/gsl3670/touchscreen.py b/esphome/components/gsl3670/touchscreen.py index fc0318f076..ccccf06d69 100644 --- a/esphome/components/gsl3670/touchscreen.py +++ b/esphome/components/gsl3670/touchscreen.py @@ -29,6 +29,8 @@ from esphome.const import ( CONF_URL, ) from esphome.core import ID +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DEPENDENCIES = ["i2c"] AUTO_LOAD = ["touchscreen"] @@ -103,8 +105,7 @@ def _validate_firmware_data(data: bytes, source: str) -> None: def _cache_path(url: str) -> Path: """Cache path for a downloaded firmware blob, keyed by URL.""" - key = hashlib.sha256(url.encode()).hexdigest()[:8] - return external_files.compute_local_file_dir(DOMAIN) / key + return external_files.compute_local_file_path(DOMAIN, url) def firmware_path(firmware: dict) -> Path: @@ -156,6 +157,23 @@ FIRMWARE_SCHEMA = cv.All( ) +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if firmware is None: + model = str(entry.get(CONF_MODEL, "CUSTOM")).upper() + firmware = MODELS.get(model, {}).get(CONF_FIRMWARE) + if ( + isinstance(firmware, dict) + and CONF_FILE not in firmware + and isinstance(url := firmware.get(CONF_URL), str) + ): + return RemoteFile(url, _cache_path(url)) + return None + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def _config_schema(config): model_option = { cv.Optional(CONF_MODEL, default="CUSTOM"): cv.one_of(*MODELS, upper=True) diff --git a/esphome/components/hoermann_hcp/__init__.py b/esphome/components/hoermann_hcp/__init__.py new file mode 100644 index 0000000000..958b495c2e --- /dev/null +++ b/esphome/components/hoermann_hcp/__init__.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import modbus +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@zweckj"] +DEPENDENCIES = ["modbus"] +MULTI_CONF = True + +CONF_HOERMANN_HCP_ID = "hoermann_hcp_id" + +hoermann_hcp_ns = cg.esphome_ns.namespace("hoermann_hcp") +HoermannHcp = hoermann_hcp_ns.class_( + "HoermannHcp", cg.PollingComponent, modbus.ModbusServerDevice +) + +# The Hoermann UAP module answers on Modbus server address 2. +CONFIG_SCHEMA = ( + cv.Schema({cv.GenerateID(): cv.declare_id(HoermannHcp)}) + .extend(cv.polling_component_schema("500ms")) + .extend(modbus.modbus_device_schema(0x02, role="server")) +) + +FINAL_VALIDATE_SCHEMA = modbus.final_validate_modbus_device( + "hoermann_hcp", role="server" +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/hoermann_hcp/binary_sensor/__init__.py b/esphome/components/hoermann_hcp/binary_sensor/__init__.py new file mode 100644 index 0000000000..3de6a161e6 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/__init__.py @@ -0,0 +1,36 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import DEVICE_CLASS_CONNECTIVITY, ENTITY_CATEGORY_DIAGNOSTIC +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +CONF_IS_CONNECTED = "is_connected" + +HoermannHcpConnectedBinarySensor = hoermann_hcp_ns.class_( + "HoermannHcpConnectedBinarySensor", binary_sensor.BinarySensor, cg.Component +) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp), + cv.Optional(CONF_IS_CONNECTED): binary_sensor.binary_sensor_schema( + HoermannHcpConnectedBinarySensor, + device_class=DEVICE_CLASS_CONNECTIVITY, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ).extend(cv.COMPONENT_SCHEMA), + } + ), + cv.has_at_least_one_key(CONF_IS_CONNECTED), +) + + +async def to_code(config: ConfigType) -> None: + if (conf := config.get(CONF_IS_CONNECTED)) is not None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await binary_sensor.new_binary_sensor(conf, parent) + await cg.register_component(var, conf) diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp new file mode 100644 index 0000000000..edce6ce4c2 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.cpp @@ -0,0 +1,18 @@ +#include "hoermann_hcp_binary_sensor.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.binary_sensor"; + +void HoermannHcpConnectedBinarySensor::setup() { + // Publishing unconditionally is deliberate: the base class dedupes, and filters need every input to drive + // their timers. + this->parent_->add_on_state_callback([this]() { this->publish_state(this->parent_->is_valid()); }); + this->publish_initial_state(this->parent_->is_valid()); +} + +void HoermannHcpConnectedBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Hoermann HCP Connected", this); } + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h new file mode 100644 index 0000000000..c111c17834 --- /dev/null +++ b/esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpConnectedBinarySensor : public binary_sensor::BinarySensor, public Component { + public: + explicit HoermannHcpConnectedBinarySensor(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + + protected: + HoermannHcp *const parent_; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/__init__.py b/esphome/components/hoermann_hcp/cover/__init__.py new file mode 100644 index 0000000000..50deacff63 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/__init__.py @@ -0,0 +1,22 @@ +import esphome.codegen as cg +from esphome.components import cover +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpCover = hoermann_hcp_ns.class_("HoermannHcpCover", cover.Cover, cg.Component) + +CONFIG_SCHEMA = ( + cover.cover_schema(HoermannHcpCover) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await cover.new_cover(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp new file mode 100644 index 0000000000..66a141758e --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.cpp @@ -0,0 +1,87 @@ +#include "hoermann_hcp_cover.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.cover"; + +cover::CoverTraits HoermannHcpCover::get_traits() { + cover::CoverTraits traits; + traits.set_supports_position(true); + traits.set_supports_stop(true); + traits.set_supports_toggle(true); + return traits; +} + +void HoermannHcpCover::setup() { + // Nothing is published before the bus controller is heard from, and the untouched position reads as fully + // open, so flag the entity until the first contact clears it again. + this->status_set_warning("waiting for the bus controller"); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpCover::dump_config() { LOG_COVER("", "Hoermann HCP Cover", this); } + +void HoermannHcpCover::control(const cover::CoverCall &call) { + bool accepted = true; + if (call.get_stop()) + accepted &= this->parent_->stop_door(); + if (call.get_toggle().has_value()) + accepted &= this->parent_->impulse_door(); + if (const auto position = call.get_position()) + accepted &= this->parent_->set_position(*position); + if (!accepted) { + // The command never reached the door, so publish the unchanged state over the one the caller assumed. + ESP_LOGW(TAG, "Command was not accepted by the door"); + this->publish_state(false); + } +} + +void HoermannHcpCover::update_from_state_() { + if (!this->parent_->is_valid()) { + this->status_set_warning(); + // The door can now move unheard, so drop the baseline a direction would be inferred from and stop + // reporting motion instead of leaving the cover travelling until the controller returns. + this->previous_position_ = NAN; + if (this->current_operation != cover::COVER_OPERATION_IDLE) { + this->current_operation = cover::COVER_OPERATION_IDLE; + this->publish_state(); + } + return; + } + this->status_clear_warning(); + + const auto previous_operation = this->current_operation; + const float current_position = this->parent_->get_current_position(); + switch (this->parent_->get_door_state()) { + case DoorState::OPENING: + this->current_operation = cover::COVER_OPERATION_OPENING; + break; + case DoorState::CLOSING: + this->current_operation = cover::COVER_OPERATION_CLOSING; + break; + case DoorState::MOVE_VENTING: + case DoorState::MOVE_HALF: + // These states carry no direction, so keep the current one until the position actually moves. + if (!std::isnan(this->previous_position_) && current_position != this->previous_position_) { + this->current_operation = current_position > this->previous_position_ ? cover::COVER_OPERATION_OPENING + : cover::COVER_OPERATION_CLOSING; + } + break; + default: + this->current_operation = cover::COVER_OPERATION_IDLE; + break; + } + this->previous_position_ = current_position; + + // Compare against the position last published, which starts at COVER_OPEN rather than at zero. + const bool changed = this->position != current_position || previous_operation != this->current_operation; + this->position = current_position; + if (changed) { + // The bus reports the position on every broadcast, so nothing here is worth restoring from flash. + this->publish_state(false); + } +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h new file mode 100644 index 0000000000..1ba8328fd2 --- /dev/null +++ b/esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "esphome/components/cover/cover.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpCover : public cover::Cover, public Component { + public: + explicit HoermannHcpCover(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void dump_config() override; + cover::CoverTraits get_traits() override; + void control(const cover::CoverCall &call) override; + + protected: + void update_from_state_(); + HoermannHcp *const parent_; + // NAN until the first position is observed, so no direction is inferred from a baseline that never existed. + float previous_position_{NAN}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.cpp b/esphome/components/hoermann_hcp/hoermann_hcp.cpp new file mode 100644 index 0000000000..a780854831 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.cpp @@ -0,0 +1,464 @@ +#include "hoermann_hcp.h" + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp"; + +// Hoermann HCP holding-register blocks. +static constexpr uint16_t COMMAND_REG = 0x9C41; // Commands written by the bus controller +static constexpr uint16_t STATE_REG = 0x9CB9; // Internal state read back by the bus controller +static constexpr uint16_t BROADCAST_REG = 0x9D31; // Door status broadcast by the bus controller +static constexpr float CLOSE_POSITION_THRESHOLD = 0.05f; +static constexpr float OPEN_POSITION_THRESHOLD = 0.95f; +// Only the parity of the outstanding toggles says where the lamp is heading, so the count must not run away. +static constexpr uint8_t MAX_LIGHT_TOGGLES_IN_FLIGHT = 4; + +// Command encoding: the high byte of the first register is the phase (0x02 pressed, 0x01 released) and the +// rest names the button - the low byte for the door commands, the second register for those that do not fit +// there. Both halves repeat that name, so neither register is a level to hold; they carry one event each. +static constexpr HoermannHcpCommand COMMAND_OPEN{"open", 0x0210, 0x0110}; +static constexpr HoermannHcpCommand COMMAND_CLOSE{"close", 0x0220, 0x0120}; +static constexpr HoermannHcpCommand COMMAND_IMPULSE{"impulse", 0x0240, 0x0140}; +// The lamp is named in the second register, but its phase bytes follow no scheme the door commands share. +static constexpr HoermannHcpCommand COMMAND_TOGGLE_LAMP{"toggle light", 0x0100, 0x0800, 0x0200, 0x0200, false}; + +// High byte of the state register and the door state it stands for. State 0x00 is decoded separately because +// its low byte tells a plain stop from the vent position. +struct DoorStateMapping { + uint8_t code; + DoorState state; +}; +static constexpr DoorStateMapping DOOR_STATE_MAPPINGS[] = { + {0x01, DoorState::OPENING}, {0x02, DoorState::CLOSING}, {0x05, DoorState::MOVE_HALF}, + {0x09, DoorState::MOVE_VENTING}, {0x0A, DoorState::VENT}, {0x20, DoorState::OPEN}, + {0x40, DoorState::CLOSED}, {0x80, DoorState::HALF_OPEN}, +}; + +// The hub rejects a reply whose register count does not match the request, so an unrecognized block length +// is padded with zeros rather than answered with an exception that would fail the controller's whole poll. +static void push_zeros(modbus::RegisterValues ®isters, uint16_t count) { + for (uint16_t i = 0; i < count; i++) + registers.push_back(0x0000); +} + +// True while the door is travelling. An impulse toggles the door, so it only stops one that is moving. +static bool is_moving(DoorState state) { + switch (state) { + case DoorState::OPENING: + case DoorState::CLOSING: + case DoorState::MOVE_HALF: + case DoorState::MOVE_VENTING: + return true; + default: + return false; + } +} + +void HoermannHcp::update() { + const uint32_t now = millis(); + // Time out the connection flag if the bus controller stopped polling. + if (this->valid_ && now - this->last_response_ > this->connection_timeout_ms_) + this->set_valid_(false); + // Status broadcasts alone keep the connection alive, so a command the controller never fetches would + // otherwise block every later one for as long as it keeps broadcasting. + if (this->next_command_ != nullptr && now - this->command_queued_at_ > this->connection_timeout_ms_) { + // Dropping after the press was presented leaves the door without its release value, which is worth saying + // apart from a command the controller never looked at. + if (this->command_written_at_ != 0) { + ESP_LOGW(TAG, "Bus controller stopped polling during '%s' command, dropping it mid key press", + this->next_command_->name); + } else { + ESP_LOGW(TAG, "Bus controller did not fetch '%s' command, dropping it", this->next_command_->name); + } + this->drop_command_(); + // Children may have assumed the command would land, so let them re-derive from the door. + this->changed_ = true; + } + // A target waits for a door still travelling the other way to turn around. If it never does, the target has + // to go as well, otherwise it would cut a later move short. The connection timeout doubles as that window. + if (this->has_target_() && !this->target_started_ && now - this->target_queued_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not start moving towards the requested position, dropping it"); + this->clear_target_(); + } + // The door took the lamp key press but never reported the lamp changing, so stop expecting it to. + if (this->light_toggle_released_at_ != 0 && now - this->light_toggle_released_at_ > this->connection_timeout_ms_) { + ESP_LOGW(TAG, "Door did not report the lamp changing, giving up on the toggle"); + this->forget_light_toggles_(); + } + if (this->changed_) { + this->changed_ = false; + this->state_callback_.call(); + } +} + +void HoermannHcp::dump_config() { + ESP_LOGCONFIG(TAG, + "Hoermann HCP bridge:\n" + " Modbus server address: 0x%02X", + this->get_address()); +} + +modbus::ResponseStatus HoermannHcp::on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) { + if (start_address != STATE_REG) { + ESP_LOGW(TAG, "Unknown read address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // 0x17 read half: STATE_REG is read back right after COMMAND_REG was written, so echo the stored message + // counter (high byte) and command (low byte). The read length identifies which internal block is requested. + const uint16_t counter = this->command_reg_value_ & 0xFF00; + const uint16_t command = static_cast((this->command_reg_value_ & 0x00FF) << 8); + + switch (number_of_registers) { + case 8: + // Command request: return the internal state, injecting any pending command. + registers.push_back(counter); + registers.push_back(static_cast(0x0001 | command)); + this->push_command_registers_(registers); + push_zeros(registers, 4); + break; + case 2: + // Empty command request. + registers.push_back(static_cast(0x0004 | counter)); + registers.push_back(command); + break; + case 5: + // Bus scan (the bus controller discovering us, typically at startup). + ESP_LOGD(TAG, "Bus scan received from bus controller"); + registers.push_back(counter); + registers.push_back(static_cast(0x0005 | command)); + registers.push_back(0x0430); + registers.push_back(0x10FF); + registers.push_back(0xA845); + break; + default: + ESP_LOGW(TAG, "Unknown read request (read %u registers)", number_of_registers); + push_zeros(registers, number_of_registers); + break; + } + + return {}; +} + +modbus::ResponseStatus HoermannHcp::on_write_registers(uint16_t start_address, + const modbus::RegisterValues ®isters) { + if (start_address == COMMAND_REG) { + // 0x17 write half: stash the command register so the following read half can echo its message counter and + // command byte back from STATE_REG. The hub always runs the write before the read within one request. + this->record_response_(); + this->command_reg_value_ = registers[0]; + return {}; + } + + if (start_address != BROADCAST_REG) { + // Every device sees every broadcast, so a frame meant for another node is ordinary traffic + ESP_LOGV(TAG, "Ignoring write to address 0x%04X", start_address); + return modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + + this->record_response_(); + + // Door status broadcast. The state is decoded first so that a frame reporting both a new state and a new + // position checks the target against the new state. + if (registers.size() > 2) + this->on_state_reg_(registers[2]); + if (registers.size() > 1) + this->on_position_reg_(registers[1]); + if (registers.size() > 6) { + this->on_light_reg_(registers[6]); + return {}; + } + // Nothing refreshes the lamp any more, so what was read before must not be commanded against. + this->set_light_seen_(false); + if (!this->short_broadcast_logged_) { + this->short_broadcast_logged_ = true; + ESP_LOGD(TAG, "Broadcast of %u registers carries no lamp state", static_cast(registers.size())); + } + return {}; +} + +void HoermannHcp::push_command_registers_(modbus::RegisterValues ®isters) { + const HoermannHcpCommand *command = this->next_command_; + if (command == nullptr) { + push_zeros(registers, 2); + return; + } + if (this->command_written_at_ == 0) { + // First read after the command was queued: present the "key pressed" values. + this->command_written_at_ = millis(); + ESP_LOGI(TAG, "Sending '%s' command to door", command->name); + registers.push_back(command->pressed_value); + registers.push_back(command->pressed_value_2); + return; + } + if (millis() - this->command_written_at_ <= this->key_press_delay_ms_) { + // Between the two events there is nothing to report, including in the second register. + push_zeros(registers, 2); + return; + } + // Enough time passed: present the "key released" values and clear the command. + ESP_LOGD(TAG, "Released '%s' command", command->name); + this->command_written_at_ = 0; + this->next_command_ = nullptr; + // A toggle whose count was already settled, by a lamp change reported from the door's side, has nothing left + // to wait for, so it must not re-arm the watchdog. + if (command == &COMMAND_TOGGLE_LAMP && this->light_toggles_in_flight_ != 0) + this->light_toggle_released_at_ = millis(); + registers.push_back(command->released_value); + registers.push_back(command->released_value_2); +} + +void HoermannHcp::on_position_reg_(uint16_t value) { + // Low byte: current position. + const uint8_t position = static_cast(value); + if (this->position_raw_ == position) + return; + + this->position_raw_ = position; + this->update_current_position_(); + // Until the door actually travels the way it was told to, its position says nothing about the target. + if (!this->has_target_() || !this->target_started_) + return; + + // The door only knows "open" and "close", so a half-open target is reached by stopping it on the way. + const bool reached = this->target_direction_ == DoorState::OPENING + ? this->current_position_ >= this->target_position_ + : this->current_position_ <= this->target_position_; + if (reached) + this->stop_door(); +} + +void HoermannHcp::on_state_reg_(uint16_t value) { + // The low byte is part of the state for 0x00, so the whole register has to be compared, not just the high byte. + const uint16_t previous = this->prev_state_reg_; + this->prev_state_reg_ = value; + if (previous == value) + return; + + const uint8_t state = value >> 8; + if (state == 0x00) { + // Low byte 0x61 marks the door resting in the vent position, anything else a plain stop. + this->set_door_state_((value & 0x00FF) == 0x61 ? DoorState::VENT : DoorState::STOPPED); + return; + } + for (const auto &mapping : DOOR_STATE_MAPPINGS) { + if (mapping.code == state) { + this->set_door_state_(mapping.state); + return; + } + } + // The low byte can change on its own, so only report a state we cannot decode once. + if (state != (previous >> 8)) + ESP_LOGW(TAG, "Unknown door state 0x%02X", state); +} + +// Low byte of register 6: bit 0x10 is the lamp, bit 0x04 the relay. The reference implementation records +// 0x00, 0x04, 0x10 and 0x14, so only the lamp bit decides here. +void HoermannHcp::on_light_reg_(uint16_t value) { + this->set_light_seen_(true); + this->set_light_on_((value & 0x0010) != 0); +} + +bool HoermannHcp::queue_command_(const HoermannHcpCommand &command) { + if (!this->valid_) { + // Queueing now would fire the command whenever the controller comes back, which may be much later. + ESP_LOGW(TAG, "Not connected to the bus controller, dropping '%s' command", command.name); + return false; + } + if (this->next_command_ != nullptr) { + ESP_LOGW(TAG, "Previous command not yet fetched by the bus controller"); + return false; + } + // A new command supersedes any half-open target the door was still travelling to. + if (command.clears_target) + this->clear_target_(); + this->next_command_ = &command; + this->command_queued_at_ = millis(); + return true; +} + +bool HoermannHcp::open_door() { return this->queue_command_(COMMAND_OPEN); } +bool HoermannHcp::close_door() { return this->queue_command_(COMMAND_CLOSE); } +bool HoermannHcp::impulse_door() { return this->queue_command_(COMMAND_IMPULSE); } +bool HoermannHcp::toggle_light() { + if (this->light_toggles_in_flight_ >= MAX_LIGHT_TOGGLES_IN_FLIGHT) { + ESP_LOGW(TAG, "Too many lamp toggles are still waiting to be confirmed, dropping this one"); + return false; + } + if (!this->queue_command_(COMMAND_TOGGLE_LAMP)) + return false; + this->light_toggles_in_flight_++; + return true; +} +bool HoermannHcp::is_light_toggle_pending_() const { return this->next_command_ == &COMMAND_TOGGLE_LAMP; } + +uint8_t HoermannHcp::unsent_light_toggles_() const { + return this->is_light_toggle_pending_() && this->command_written_at_ == 0 ? 1 : 0; +} + +bool HoermannHcp::cancel_light_toggle() { + // Once the pressed value has been presented the key press is already on the wire, so only an untouched + // command can be withdrawn. + if (!this->is_light_toggle_pending_() || this->command_written_at_ != 0) + return false; + ESP_LOGD(TAG, "Cancelling '%s' command the controller had not fetched", this->next_command_->name); + this->drop_command_(); + return true; +} + +bool HoermannHcp::stop_door() { + if (!is_moving(this->door_state_)) { + this->clear_target_(); + return true; + } + // On success queue_command_() clears the target; on refusal it stays armed so the next position retries. + return this->queue_command_(COMMAND_IMPULSE); +} + +bool HoermannHcp::set_position(float position) { + // The first and last movement segments are inconsistent on some doors, so snap to fully open/closed. + if (position <= CLOSE_POSITION_THRESHOLD) + return this->close_door(); + if (position >= OPEN_POSITION_THRESHOLD) + return this->open_door(); + // Asking the door to travel to where it already is means stopping it. + if (position == this->current_position_) + return this->stop_door(); + + // The door itself has no notion of a target, so it is started in the right direction and stopped on the way. + const bool opening = position > this->current_position_; + if (!this->queue_command_(opening ? COMMAND_OPEN : COMMAND_CLOSE)) + return false; + this->target_position_ = position; + this->target_queued_at_ = millis(); + this->target_direction_ = opening ? DoorState::OPENING : DoorState::CLOSING; + // A door already travelling that way is on its way; one moving the other way has to turn around first. + this->target_started_ = this->door_state_ == this->target_direction_; + return true; +} + +void HoermannHcp::record_response_() { + this->last_response_ = millis(); + this->set_valid_(true); +} + +void HoermannHcp::set_valid_(bool valid) { + if (this->valid_ == valid) + return; + this->valid_ = valid; + this->changed_ = true; + if (valid) { + ESP_LOGI(TAG, "Bus controller connected"); + return; + } + ESP_LOGW(TAG, "Bus controller connection lost (no request for %" PRIu32 "ms)", millis() - this->last_response_); + // Drop what the controller never fetched, so it neither blocks later commands nor fires on reconnect. + this->drop_command_(); + // The door cannot be watched while the bus is quiet, so a target left armed would stop it long afterwards. + this->clear_target_(); + this->forget_light_toggles_(); + // The lamp can be switched at the door while the bus is quiet, so what was last read is no longer trusted. + this->set_light_seen_(false); + this->short_broadcast_logged_ = false; +} + +void HoermannHcp::drop_command_() { + const bool was_light_toggle = this->is_light_toggle_pending_(); + // Cleared first so the settling below no longer counts this command among the toggles still to be sent. + this->next_command_ = nullptr; + this->command_written_at_ = 0; + if (was_light_toggle) { + // A lamp toggle says nothing about where the door was going, so it leaves the target alone. + this->light_toggle_settled_(); + } else { + this->clear_target_(); + } +} + +void HoermannHcp::light_toggle_settled_() { + if (this->light_toggles_in_flight_ == 0) + return; + this->light_toggles_in_flight_--; + // Only a toggle the door has been shown can still be confirmed, so unsent ones leave nothing to wait for. + if (this->light_toggles_in_flight_ == this->unsent_light_toggles_()) + this->light_toggle_released_at_ = 0; + // The light was showing where the lamp was heading, so it has to be told to look again. + this->changed_ = true; +} + +void HoermannHcp::forget_light_toggles_() { + // Nothing outstanding must always mean nothing to wait for, or the watchdog below would fire for ever. + this->light_toggle_released_at_ = 0; + // A toggle the door has not been shown yet is still going to fire, so it keeps counting. + const uint8_t unsent = this->unsent_light_toggles_(); + if (this->light_toggles_in_flight_ == unsent) + return; + this->light_toggles_in_flight_ = unsent; + this->changed_ = true; +} + +void HoermannHcp::set_door_state_(DoorState state) { + if (this->door_state_ == state) + return; + this->door_state_ = state; + this->changed_ = true; + this->update_current_position_(); + if (!this->has_target_()) + return; + if (state == this->target_direction_) { + this->target_started_ = true; + } else if (this->target_started_ && !is_moving(state)) { + // The door came to rest without reaching the target, so the request it belonged to is over. + this->clear_target_(); + } +} + +void HoermannHcp::update_current_position_() { + // Doors do not always park at exactly 0 or 200, and Cover::is_fully_closed() is an exact comparison, so + // trust the reported end stop over the raw count. + float position = static_cast(this->position_raw_) / 200.0f; + if (this->door_state_ == DoorState::CLOSED) { + position = 0.0f; + } else if (this->door_state_ == DoorState::OPEN) { + position = 1.0f; + } + if (this->current_position_ != position) { + this->current_position_ = position; + this->changed_ = true; + } +} + +void HoermannHcp::clear_target_() { + this->target_position_ = 0.0f; + this->target_started_ = false; +} + +void HoermannHcp::set_light_on_(bool on) { + if (this->light_on_ == on) + return; + this->light_on_ = on; + this->changed_ = true; + if (this->light_toggles_in_flight_ <= this->unsent_light_toggles_()) { + // The door has not been shown a toggle that could explain this, so the lamp was switched at the door. + ESP_LOGD(TAG, "Lamp %s at the door", ONOFF(on)); + return; + } + // The door acted, so one of the toggles it has seen has arrived. Any others still count. + this->light_toggle_settled_(); +} + +void HoermannHcp::set_light_seen_(bool seen) { + if (this->light_seen_ == seen) + return; + this->light_seen_ = seen; + // A resting door changes nothing else, so without this the light would never hear about it. + this->changed_ = true; +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/hoermann_hcp.h b/esphome/components/hoermann_hcp/hoermann_hcp.h new file mode 100644 index 0000000000..41fd7617e4 --- /dev/null +++ b/esphome/components/hoermann_hcp/hoermann_hcp.h @@ -0,0 +1,147 @@ +#pragma once + +#include + +#include "esphome/components/modbus/modbus.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +namespace esphome::hoermann_hcp { + +// Door state as reported by the Hoermann bus controller. +enum class DoorState : uint8_t { + OPEN, + OPENING, + CLOSED, + CLOSING, + HALF_OPEN, + MOVE_VENTING, + VENT, + MOVE_HALF, + STOPPED, +}; + +// A HCP command is a simulated key press: the pressed value is presented to the bus controller, then after a +// short delay the released value. Each half also carries a second register, which only the lamp command uses. +struct HoermannHcpCommand { + const char *name; + uint16_t pressed_value; + uint16_t released_value; + uint16_t pressed_value_2{0x0000}; + uint16_t released_value_2{0x0000}; + // A door command supersedes a half-open target; the lamp has no bearing on where the door is going. + bool clears_target{true}; +}; + +class HoermannHcp : public PollingComponent, public modbus::ModbusServerDevice { + public: + void update() override; + void dump_config() override; + + // Registered by child entities to be notified when the door state changes. + template void add_on_state_callback(F &&callback) { + this->state_callback_.add(std::forward(callback)); + } + + // Modbus server callbacks. The bus controller pushes commands and polls state with 0x17 (the hub runs the write + // half first, storing the command register that the read half echoes back) and broadcasts status with 0x10. + modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) override; + modbus::ResponseStatus on_read_holding_registers(uint16_t start_address, uint16_t number_of_registers, + modbus::RegisterValues ®isters) override; + + // Positions follow the cover convention: 0.0 is fully closed, 1.0 fully open. These return false when the bus + // controller cannot be asked right now, so the caller can react. + bool open_door(); + bool close_door(); + bool impulse_door(); + bool stop_door(); + bool set_position(float position); + bool toggle_light(); + + DoorState get_door_state() const { return this->door_state_; } + float get_current_position() const { return this->current_position_; } + bool is_valid() const { return this->valid_; } + bool is_light_on() const { return this->light_on_; } + // False until a broadcast has actually carried the lamp register. Bus traffic alone makes the connection + // valid without saying anything about the lamp, so is_light_on() would still be its default. + bool is_light_known() const { return this->light_seen_; } + // Where the lamp ends up once every toggle on its way has landed, each of which inverts it. Until then the + // lamp still reads as its old self, so this is what a request has to be judged against. + bool is_light_heading_on() const { return this->light_on_ != (this->light_toggles_in_flight_ % 2 != 0); } + // Drops a lamp toggle the controller has not started reading, so a reversing request cancels it outright + // instead of fighting it. Returns false if there is nothing to cancel. + bool cancel_light_toggle(); + + protected: + // True while a lamp toggle is queued but not yet fetched, so the lamp is about to invert. + bool is_light_toggle_pending_() const; + // Toggles the door has not been shown yet, which is at most the one still waiting in the command slot. + uint8_t unsent_light_toggles_() const; + void record_response_(); + // Returns false when the bus controller has not fetched the previous command yet. + bool queue_command_(const HoermannHcpCommand &command); + // Throws away the pending command, taking any armed target with it unless the command was the lamp toggle. + void drop_command_(); + // One outstanding toggle reached the lamp, was withdrawn, or was thrown away. + void light_toggle_settled_(); + // Stops expecting the toggles the door has already been shown to reach the lamp. + void forget_light_toggles_(); + // Appends the two key-press registers and advances the pending command's press/release state. + void push_command_registers_(modbus::RegisterValues ®isters); + void on_position_reg_(uint16_t value); + void on_state_reg_(uint16_t value); + void on_light_reg_(uint16_t value); + + void set_valid_(bool valid); + void set_door_state_(DoorState state); + // Recomputes the reported position from position_raw_ and the current door state. + void update_current_position_(); + bool has_target_() const { return this->target_position_ != 0.0f; } + void clear_target_(); + void set_light_on_(bool on); + void set_light_seen_(bool seen); + + CallbackManager state_callback_; + + float current_position_{0.0f}; + // Position the door was told to travel to; 0.0 means no target is armed. + float target_position_{0.0f}; + + // Pending command / key-press state machine. + const HoermannHcpCommand *next_command_{nullptr}; + uint32_t command_queued_at_{0}; + // Separate from command_queued_at_ so an unrelated command cannot extend the target's start deadline. + uint32_t target_queued_at_{0}; + uint32_t command_written_at_{0}; + uint32_t last_response_{0}; + // When the door was last handed a lamp key press. It reports the lamp a moment later, so this bounds the + // wait. Queueing another toggle deliberately leaves it alone, so the one already sent keeps its deadline. + uint32_t light_toggle_released_at_{0}; + + // A command is "pressed" for this long before its end value is sent. + uint16_t key_press_delay_ms_{100}; + // Drop the "connected" flag if the bus controller has not polled us for this long. + uint16_t connection_timeout_ms_{2000}; + // The state starts on a value the bus controller never reports, so the first broadcast is decoded even when + // it reads 0x0000. + uint16_t prev_state_reg_{0xFFFF}; + // 0x17 write half: command register last written to COMMAND_REG. The read half echoes its high-byte message + // counter and low-byte command back from STATE_REG. + uint16_t command_reg_value_{0}; + + DoorState door_state_{DoorState::CLOSED}; + // Direction the door was started in for the current target. A target armed while the door is still travelling + // the other way must not be judged by the reported direction until the door has turned around. + DoorState target_direction_{DoorState::STOPPED}; + // Position as reported by the bus controller, 0..200 across the full travel. + uint8_t position_raw_{0}; + uint8_t light_toggles_in_flight_{0}; + bool target_started_{false}; + bool valid_{false}; + bool changed_{false}; + bool light_on_{false}; + bool light_seen_{false}; + bool short_broadcast_logged_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/__init__.py b/esphome/components/hoermann_hcp/light/__init__.py new file mode 100644 index 0000000000..e895115db4 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/__init__.py @@ -0,0 +1,24 @@ +import esphome.codegen as cg +from esphome.components import light +import esphome.config_validation as cv +from esphome.types import ConfigType + +from .. import CONF_HOERMANN_HCP_ID, HoermannHcp, hoermann_hcp_ns + +DEPENDENCIES = ["hoermann_hcp"] + +HoermannHcpLight = hoermann_hcp_ns.class_( + "HoermannHcpLight", light.LightOutput, cg.Component +) + +CONFIG_SCHEMA = ( + light.light_schema(HoermannHcpLight, light.LightType.BINARY) + .extend({cv.GenerateID(CONF_HOERMANN_HCP_ID): cv.use_id(HoermannHcp)}) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_HOERMANN_HCP_ID]) + var = await light.new_light(config, parent) + await cg.register_component(var, config) diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp new file mode 100644 index 0000000000..d3d784928d --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.cpp @@ -0,0 +1,82 @@ +#include "hoermann_hcp_light.h" + +#include "esphome/core/log.h" + +namespace esphome::hoermann_hcp { + +static const char *const TAG = "hoermann_hcp.light"; + +light::LightTraits HoermannHcpLight::get_traits() { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::ON_OFF}); + return traits; +} + +void HoermannHcpLight::setup() { + // Nothing is known about the lamp until the bus controller is heard from, so flag the entity until then. + this->status_set_warning(LOG_STR("waiting for the bus controller")); + this->parent_->add_on_state_callback([this]() { this->update_from_state_(); }); +} + +void HoermannHcpLight::setup_state(light::LightState *state) { this->light_state_ = state; } + +void HoermannHcpLight::write_state(light::LightState *state) { + bool binary; + state->current_values_as_binary(&binary); + // A publish of ours only reaches write_state() a loop pass later, by which time the lamp may have moved on, + // so it is recognised by the value it carried rather than by the current one. + const optional published = this->published_state_; + this->published_state_.reset(); + // LightState::setup() always performs a call, so the very first write here is the restored state coming back + // rather than a request. + const bool restored = !this->boot_replay_done_; + this->boot_replay_done_ = true; + const bool heading_on = this->parent_->is_light_heading_on(); + if (binary == heading_on) + return; + if (restored) { + ESP_LOGD(TAG, "Ignoring the restored state, the door decides what the lamp is doing"); + } else if (published != binary) { + if (!this->parent_->is_light_known()) { + // Commanding a lamp that has not been read could switch off one that is already on. + ESP_LOGW(TAG, "Door has not reported the lamp yet, ignoring the requested state"); + } else if (this->parent_->cancel_light_toggle() || this->parent_->toggle_light()) { + // A toggle the controller has not fetched is withdrawn outright rather than fought with a second one. + return; + } else { + ESP_LOGW(TAG, "Light command was not accepted by the door"); + } + } + // Nothing was sent, so the entity has to go back to showing the lamp rather than the request. + this->publish_lamp_state_(heading_on); +} + +void HoermannHcpLight::update_from_state_() { + if (this->light_state_ == nullptr) + return; + if (!this->parent_->is_valid()) { + this->status_set_warning(LOG_STR("bus controller not responding")); + return; + } + if (!this->parent_->is_light_known()) { + // Commands are refused until the door says, so say so rather than looking healthy and doing nothing. + this->status_set_warning(LOG_STR("door has not reported the lamp")); + return; + } + this->status_clear_warning(); + const bool heading_on = this->parent_->is_light_heading_on(); + if (this->light_state_->remote_values.is_on() != heading_on) + this->publish_lamp_state_(heading_on); +} + +// Re-enters write_state() a loop pass later, where published_state_ marks the write as ours. +void HoermannHcpLight::publish_lamp_state_(bool on) { + this->published_state_ = on; + auto call = this->light_state_->make_call(); + call.set_state(on); + // The bus reports the lamp on every broadcast, so nothing here is worth restoring from flash. + call.set_save(false); + call.perform(); +} + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h new file mode 100644 index 0000000000..82b12cb791 --- /dev/null +++ b/esphome/components/hoermann_hcp/light/hoermann_hcp_light.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/components/light/light_output.h" +#include "esphome/core/component.h" +#include "../hoermann_hcp.h" + +namespace esphome::hoermann_hcp { + +class HoermannHcpLight : public light::LightOutput, public Component { + public: + explicit HoermannHcpLight(HoermannHcp *parent) : parent_(parent) {} + + void setup() override; + void setup_state(light::LightState *state) override; + light::LightTraits get_traits() override; + void write_state(light::LightState *state) override; + + protected: + void update_from_state_(); + void publish_lamp_state_(bool on); + + HoermannHcp *const parent_; + light::LightState *light_state_{nullptr}; + // Value last published and not yet seen come back, so the write carrying it is that publish, not a request. + optional published_state_; + // Set by the first write_state(), which is always the restored state replayed on boot. + bool boot_replay_done_{false}; +}; + +} // namespace esphome::hoermann_hcp diff --git a/esphome/components/host/helpers.cpp b/esphome/components/host/helpers.cpp index 7e8849b3e1..7274d9de57 100644 --- a/esphome/components/host/helpers.cpp +++ b/esphome/components/host/helpers.cpp @@ -39,7 +39,7 @@ bool Mutex::try_lock() { return static_cast(handle_)->try_lock(); void Mutex::unlock() { static_cast(handle_)->unlock(); } void get_mac_address_raw(uint8_t *mac) { // NOLINT(readability-non-const-parameter) - static const uint8_t esphome_host_mac_address[6] = USE_ESPHOME_HOST_MAC_ADDRESS; + static const uint8_t esphome_host_mac_address[MAC_ADDRESS_SIZE] = USE_ESPHOME_HOST_MAC_ADDRESS; memcpy(mac, esphome_host_mac_address, sizeof(esphome_host_mac_address)); } diff --git a/esphome/components/internal_temperature/internal_temperature_rp2.cpp b/esphome/components/internal_temperature/internal_temperature_rp2.cpp index 11f8e27fc3..2e408b3b01 100644 --- a/esphome/components/internal_temperature/internal_temperature_rp2.cpp +++ b/esphome/components/internal_temperature/internal_temperature_rp2.cpp @@ -3,17 +3,76 @@ #include "esphome/core/log.h" #include "internal_temperature.h" -#include "Arduino.h" +#include +#include +#include + +// The RP2 variant headers (pulled in transitively by Arduino.h) define +// ADC_RESOLUTION as the pin-level ADC bit count, which would be substituted +// into the constant below. Nothing here uses the Arduino definition, so drop +// it for this file. Not restored with pop_macro: the uses below would then be +// substituted again. +#undef ADC_RESOLUTION namespace esphome::internal_temperature { static const char *const TAG = "internal_temperature.rp2"; +// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040 +// and RP2350A, but input 8 on RP2350B, which has eight external channels rather +// than four. +// +// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That +// derives from NUM_ADC_CHANNELS, which settles from a board header, and +// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die +// is only declared later, by the variant's pins_arduino.h, so the SDK constant +// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file +// is compiled, on both arduino-pico and pico-sdk builds. +#if defined(PICO_RP2350) && !defined(PICO_RP2350A) +#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen" +#endif +#if defined(PICO_RP2350) && !PICO_RP2350A +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8; +#else +static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4; +#endif +static constexpr float ADC_VREF = 3.3f; +static constexpr float ADC_RESOLUTION = 4096.0f; // 12-bit +// RP2040 datasheet 4.9.5 / RP2350 datasheet 12.4.6: T = 27 - (V - 0.706) / 0.001721 +static constexpr float TEMPERATURE_AT_REFERENCE = 27.0f; +static constexpr float REFERENCE_VOLTAGE = 0.706f; +static constexpr float VOLTS_PER_DEGREE = 0.001721f; +// The sensor is powered down again after each read, so every conversion is the +// first one after enabling. Let the bias circuitry settle first, matching what +// the adc component does for its own temperature readings. +static constexpr uint32_t SETTLE_TIME_US = 1000; + +static float read_internal_temperature() { + // adc_init() resets the ADC block, so this runs at most once for this + // component. The adc component guards its own adc_init() the same way, so a + // redundant reset is still possible when both are used. That is harmless + // because both re-select their input on every read. + static bool adc_ready = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + if (!adc_ready) { + adc_init(); + adc_ready = true; + } + + adc_set_temp_sensor_enabled(true); + busy_wait_us(SETTLE_TIME_US); + adc_select_input(TEMPERATURE_ADC_INPUT); + const uint16_t raw = adc_read(); + adc_set_temp_sensor_enabled(false); + + const float voltage = raw * (ADC_VREF / ADC_RESOLUTION); + return TEMPERATURE_AT_REFERENCE - (voltage - REFERENCE_VOLTAGE) / VOLTS_PER_DEGREE; +} + void InternalTemperatureSensor::update() { float temperature = NAN; bool success = false; - temperature = analogReadTemp(); + temperature = read_internal_temperature(); success = (temperature != 0.0f); if (success && std::isfinite(temperature)) { diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 32e49c643f..914de8e145 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -8,6 +8,7 @@ #endif #include "esphome/core/application.h" +#include "esphome/core/helpers.h" namespace esphome::ld2410 { @@ -178,7 +179,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2410Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -511,7 +512,7 @@ bool LD2410Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index a0cce36d16..061846f1f1 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -121,7 +121,7 @@ class LD2410Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; #ifdef USE_NUMBER diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 093e8c72dc..7041b7539f 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -197,7 +197,7 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2412Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -555,7 +555,7 @@ bool LD2412Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index f722f938ae..a52402c2ea 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -124,7 +124,7 @@ class LD2412Component final : public Component, public uart::UARTDevice { uint8_t out_pin_level_ = 0; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; bool bluetooth_on_{false}; bool dynamic_background_correction_active_{false}; diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index 0dc2638aad..4b41d63a88 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -184,7 +184,7 @@ void LD2450Component::setup() { } void LD2450Component::dump_config() { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; char version_s[20]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ld24xx::format_version_str(this->version_, version_s); @@ -680,7 +680,7 @@ bool LD2450Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 10f9bb874a..c4f06ad224 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -169,7 +169,7 @@ class LD2450Component : public Component, public uart::UARTDevice { uint32_t moving_presence_millis_ = 0; uint32_t timeout_ = 5; uint8_t buffer_data_[MAX_LINE_LENGTH]; - uint8_t mac_address_[6] = {0, 0, 0, 0, 0, 0}; + uint8_t mac_address_[MAC_ADDRESS_SIZE] = {0, 0, 0, 0, 0, 0}; uint8_t version_[6] = {0, 0, 0, 0, 0, 0}; uint8_t buffer_pos_ = 0; // where to resume processing/populating buffer uint8_t zone_type_ = 0; diff --git a/esphome/components/ld24xx/ld24xx.h b/esphome/components/ld24xx/ld24xx.h index cba1b68a15..deac04e86f 100644 --- a/esphome/components/ld24xx/ld24xx.h +++ b/esphome/components/ld24xx/ld24xx.h @@ -45,8 +45,7 @@ static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; // Helper function to format MAC address with stack allocation // Returns pointer to UNKNOWN_MAC constant or formatted buffer -// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator) -inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { +inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { if (mac_address_is_valid(mac_address)) { format_mac_addr_upper(mac_address, buffer.data()); return buffer.data(); diff --git a/esphome/components/ln882h_ble/ln882h_ble.cpp b/esphome/components/ln882h_ble/ln882h_ble.cpp index 152ca571e9..021e138f08 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.cpp +++ b/esphome/components/ln882h_ble/ln882h_ble.cpp @@ -236,7 +236,7 @@ static void ble_scan_callback(void *param) { // downstream the value is used exactly like on ESP32. const int8_t raw = info->rssi; - memcpy(slot->mac, info->trans_addr, 6); + memcpy(slot->mac, info->trans_addr, MAC_ADDRESS_SIZE); slot->rssi = (raw > 20) ? static_cast(-raw) : raw; slot->addr_type = info->trans_addr_type; slot->is_scan_response = report_type == GAPM_REPORT_TYPE_SCAN_RSP_LEG; @@ -407,7 +407,7 @@ void LN882HBLE::resolve_mac_() { ESP_LOGW(TAG, "BLE address KV unavailable; deriving address from WiFi MAC"); } if (!have_unique_addr) { - uint8_t wifi_mac[6] = {0}; + uint8_t wifi_mac[MAC_ADDRESS_SIZE] = {0}; get_mac_address_raw(wifi_mac); // MSB-first // Reverse into controller (LSB-first) order, then BLE = WiFi + 1: increment // the NIC low byte (addr[0] once reversed), no carry, OUI unchanged — the @@ -421,7 +421,7 @@ void LN882HBLE::resolve_mac_() { ESP_LOGD(TAG, "MAC derived (WiFi+1) and stored"); } } - memcpy(this->ble_mac_, bt_addr.addr, 6); + memcpy(this->ble_mac_, bt_addr.addr, MAC_ADDRESS_SIZE); } // --------------------------------------------------------------------------- diff --git a/esphome/components/ln882h_ble/ln882h_ble.h b/esphome/components/ln882h_ble/ln882h_ble.h index 2186822208..5b4a67b566 100644 --- a/esphome/components/ln882h_ble/ln882h_ble.h +++ b/esphome/components/ln882h_ble/ln882h_ble.h @@ -23,8 +23,8 @@ enum class BLEComponentState : uint8_t { /// One scan report from the controller, decoded from the SDK's rw-task event /// (RSSI already sign-corrected). struct BLEScanReport { - uint8_t mac[6]; // as the controller delivers it (LSB-first) - int8_t rssi; // signed dBm (-127..+20) + uint8_t mac[MAC_ADDRESS_SIZE]; // as the controller delivers it (LSB-first) + int8_t rssi; // signed dBm (-127..+20) uint8_t addr_type; bool is_scan_response; // report is a scan response (active scan) bool scannable; // advertisement may be followed by a scan response @@ -138,7 +138,7 @@ class LN882HBLE final : public Component { // Reports rejected by the legacy-only filter (rw-task producer, main-task // consumer via exchange in loop()). std::atomic rejected_reports_{0}; - uint8_t ble_mac_[6]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // controller (LSB-first) order, as ln_bd_addr_t stores it BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{false}; bool scanning_{false}; // controller scan running (re-entry guard for scan_start) diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp index 11ea46525c..e1083e5fbe 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.cpp @@ -116,7 +116,8 @@ bool LN882HBLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // scan_start() re-enters cleanly (stops + GAPM settle). No on_scan_end and // no period reset: the scan logically continues, only the mode changes. if (this->scan_running_) { diff --git a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h index 2d88b938dd..dc42aebce9 100644 --- a/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h +++ b/esphome/components/ln882h_ble_tracker/ln882h_ble_tracker.h @@ -90,8 +90,8 @@ class LN882HBLETracker : public Component, } // The controller stores the address LSB-first (BLE convention); the contract // wants printable (MSB-first) order. - void get_adapter_mac(uint8_t out[6]) { - uint8_t mac[6]; + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { + uint8_t mac[MAC_ADDRESS_SIZE]; this->parent_->get_mac_lsb_first(mac); for (int i = 0; i < 6; i++) out[i] = mac[5 - i]; diff --git a/esphome/components/micro_wake_word/__init__.py b/esphome/components/micro_wake_word/__init__.py index 255923f878..092c4977ce 100644 --- a/esphome/components/micro_wake_word/__init__.py +++ b/esphome/components/micro_wake_word/__init__.py @@ -166,12 +166,7 @@ MANIFEST_SCHEMA_V2 = cv.Schema( def _compute_local_file_path(config: dict) -> Path: - url = config[CONF_URL] - h = hashlib.new("sha256") - h.update(url.encode()) - key = h.hexdigest()[:8] - base_dir = external_files.compute_local_file_dir(DOMAIN) - return base_dir / key + return external_files.compute_local_file_path(DOMAIN, config[CONF_URL]) def _convert_manifest_v1_to_v2(v1_manifest): @@ -389,11 +384,14 @@ def _download_http_models(config: ConfigType) -> ConfigType: return config external_files.download_content_many( - ((url, path / "manifest.json") for path, url in http_models.items()), + ( + external_files.RemoteFile(url, path / "manifest.json") + for path, url in http_models.items() + ), description="wake word manifest(s)", ) - model_files: list[tuple[str, Path]] = [] + model_files: list[external_files.RemoteFile] = [] errors: list[cv.Invalid] = [] for path, url in http_models.items(): try: @@ -412,7 +410,7 @@ def _download_http_models(config: ConfigType) -> ConfigType: cv.Invalid(f"Manifest file at {url} is missing the 'model' key") ) continue - model_files.append((urljoin(url, model), path / model)) + model_files.append(external_files.RemoteFile(urljoin(url, model), path / model)) if errors: raise cv.MultipleInvalid(errors) diff --git a/esphome/components/mitsubishi_cn105/__init__.py b/esphome/components/mitsubishi_cn105/__init__.py index 7d5594495a..450d1cd222 100644 --- a/esphome/components/mitsubishi_cn105/__init__.py +++ b/esphome/components/mitsubishi_cn105/__init__.py @@ -2,9 +2,15 @@ from esphome import automation import esphome.codegen as cg from esphome.components import uart import esphome.config_validation as cv -from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL -from esphome.core import ID -from esphome.cpp_generator import MockObj +from esphome.const import ( + CONF_DIRECTION, + CONF_ID, + CONF_ON_STATE, + CONF_TEMPERATURE, + CONF_UPDATE_INTERVAL, +) +from esphome.core import ID, Lambda +from esphome.cpp_generator import LambdaExpression, MockObj from esphome.types import ConfigType, TemplateArgsType CODEOWNERS = ["@crnjan"] @@ -13,6 +19,8 @@ DOMAIN = "mitsubishi_cn105" CONF_MITSUBISHI_CN105_ID = f"{DOMAIN}_id" CONF_TELEMETRY_REQUEST_MIN_INTERVAL = "telemetry_request_min_interval" +CONF_VANE = "vane" +CONF_VERTICAL = "vertical" mitsubishi_ns = cg.esphome_ns.namespace(DOMAIN) @@ -22,6 +30,22 @@ MitsubishiCN105Component = mitsubishi_ns.class_( uart.UARTDevice, ) +VaneState = mitsubishi_ns.struct("VaneState") +VaneCall = mitsubishi_ns.class_("VaneCall") +VerticalVaneMode = mitsubishi_ns.enum("VerticalVaneMode") + +# The insertion order must match VALUES in +# select/mitsubishi_cn105_vane_select_vertical.cpp. +VERTICAL_VANE_DIRECTIONS = { + "AUTO": VerticalVaneMode.VERTICAL_VANE_MODE_AUTO, + "1": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_1, + "2": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_2, + "3": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_3, + "4": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_4, + "5": VerticalVaneMode.VERTICAL_VANE_MODE_POSITION_5, + "SWING": VerticalVaneMode.VERTICAL_VANE_MODE_SWING, +} + SetRemoteTemperatureAction = mitsubishi_ns.class_( "SetRemoteTemperatureAction", automation.Action, @@ -34,6 +58,11 @@ ClearRemoteTemperatureAction = mitsubishi_ns.class_( cg.Parented.template(MitsubishiCN105Component), ) +VaneControlAction = mitsubishi_ns.class_( + "VaneControlAction", + automation.Action, +) + CONFIG_SCHEMA = ( cv.Schema( { @@ -42,6 +71,11 @@ CONFIG_SCHEMA = ( cv.Optional( CONF_TELEMETRY_REQUEST_MIN_INTERVAL, default="60s" ): cv.update_interval, + cv.Optional(CONF_VANE): cv.Schema( + { + cv.Optional(CONF_ON_STATE): automation.validate_automation({}), + } + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -80,6 +114,15 @@ async def to_code(config: ConfigType) -> None: config[CONF_TELEMETRY_REQUEST_MIN_INTERVAL] ) ) + if on_state := config.get(CONF_VANE, {}).get(CONF_ON_STATE): + cg.add_global(mitsubishi_ns.using) + for conf in on_state: + await automation.build_callback_automation( + var, + "add_on_vane_state_callback", + [(VaneState.operator("const").operator("ref"), "x")], + conf, + ) REMOTE_TEMPERATURE_ACTION_SCHEMA = cv.Schema( @@ -135,3 +178,70 @@ async def clear_temperature_action_to_code( var = cg.new_Pvariable(action_id, template_arg) await cg.register_parented(var, config[CONF_ID]) return var + + +VANE_CONTROL_FIELDS = ( + ( + (CONF_VERTICAL, CONF_DIRECTION), + "vertical.set_direction", + VerticalVaneMode, + ), +) + +VANE_CONTROL_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Component), + cv.Optional(CONF_VERTICAL): cv.Schema( + { + cv.Optional(CONF_DIRECTION): cv.templatable( + cv.enum(VERTICAL_VANE_DIRECTIONS, upper=True) + ), + } + ), + } +) + + +@automation.register_action( + f"{DOMAIN}.vane.control", + VaneControlAction, + VANE_CONTROL_ACTION_SCHEMA, + synchronous=True, +) +async def vane_control_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + cg.add_global(mitsubishi_ns.using) + parent = await cg.get_variable(config[CONF_ID]) + normalized_args = [ + (cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), name) + for t, name in args + ] + forwarded_args = ", ".join(name for _, name in args) + body_lines: list[str] = [] + + for path, setter, type_ in VANE_CONTROL_FIELDS: + if (section := config.get(path[0])) is None: + continue + if (value := section.get(path[1])) is None: + continue + if isinstance(value, Lambda): + inner = await cg.process_lambda( + value, + normalized_args, + return_type=type_, + ) + body_lines.append(f"call.{setter}(({inner})({forwarded_args}));") + else: + body_lines.append(f"call.{setter}({cg.safe_exp(value)});") + + apply_lambda = LambdaExpression( + ["\n".join(body_lines)], + [(VaneCall.operator("ref"), "call"), *normalized_args], + capture="", + return_type=cg.void, + ) + return cg.new_Pvariable(action_id, template_arg, parent, apply_lambda) diff --git a/esphome/components/mitsubishi_cn105/automation.h b/esphome/components/mitsubishi_cn105/automation.h index 879e556f9c..2fc6ba3c32 100644 --- a/esphome/components/mitsubishi_cn105/automation.h +++ b/esphome/components/mitsubishi_cn105/automation.h @@ -4,6 +4,8 @@ #include "esphome/core/automation.h" +#include + namespace esphome::mitsubishi_cn105 { template @@ -20,4 +22,21 @@ class ClearRemoteTemperatureAction : public Action, public Parentedparent_->clear_remote_temperature(); } }; +template class VaneControlAction : public Action { + public: + using ApplyFn = void (*)(VaneCall &, const std::remove_cvref_t &...); + + VaneControlAction(MitsubishiCN105Component *parent, ApplyFn apply) : parent_(parent), apply_(apply) {} + + void play(const Ts &...x) override { + auto call = this->parent_->make_vane_call(); + this->apply_(call, x...); + call.perform(); + } + + protected: + MitsubishiCN105Component *parent_; + ApplyFn apply_; +}; + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index 415de34166..6683a9a25b 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -4,6 +4,7 @@ #include #include #include +#include "mitsubishi_cn105_properties.h" namespace esphome::mitsubishi_cn105 { @@ -11,8 +12,6 @@ static const char *const TAG = "mitsubishi_cn105.driver"; static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000; -static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; - static constexpr size_t REQUEST_PAYLOAD_LEN = 0x10; static constexpr size_t HEADER_LEN = 5; static constexpr uint8_t PREAMBLE = 0xFC; @@ -31,86 +30,6 @@ static constexpr uint8_t STATUS_MSG_TELEMETRY = 0x03; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; -template struct LookupMap { - using value_type = decltype(Unknown); - static constexpr auto UNKNOWN_VALUE = Unknown; - const std::array table; - - constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; } - - constexpr bool reverse_lookup(value_type value, uint8_t &out) const { - static_assert(N <= std::numeric_limits::max()); - if (value == UNKNOWN_VALUE) { - return false; - } - for (uint8_t i = 0; i < static_cast(N); ++i) { - if (this->table[i] == value) { - out = i; - return true; - } - } - return false; - } - - constexpr bool is_valid(value_type value) const { - uint8_t raw; - return reverse_lookup(value, raw); - } -}; - -template static constexpr auto make_map(const T (&values)[N]) { - return LookupMap{std::to_array(values)}; -} - -static constexpr auto PROTOCOL_MODE_MAP = make_map({ - MitsubishiCN105::Mode::UNKNOWN, // 0x00 - MitsubishiCN105::Mode::HEAT, // 0x01 - MitsubishiCN105::Mode::DRY, // 0x02 - MitsubishiCN105::Mode::COOL, // 0x03 - MitsubishiCN105::Mode::UNKNOWN, // 0x04 - MitsubishiCN105::Mode::UNKNOWN, // 0x05 - MitsubishiCN105::Mode::UNKNOWN, // 0x06 - MitsubishiCN105::Mode::FAN_ONLY, // 0x07 - MitsubishiCN105::Mode::AUTO // 0x08 -}); - -static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map({ - MitsubishiCN105::FanMode::AUTO, // 0x00 - MitsubishiCN105::FanMode::QUIET, // 0x01 - MitsubishiCN105::FanMode::SPEED_1, // 0x02 - MitsubishiCN105::FanMode::SPEED_2, // 0x03 - MitsubishiCN105::FanMode::UNKNOWN, // 0x04 - MitsubishiCN105::FanMode::SPEED_3, // 0x05 - MitsubishiCN105::FanMode::SPEED_4 // 0x06 -}); - -static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map({ - MitsubishiCN105::VaneMode::AUTO, // 0x00 - MitsubishiCN105::VaneMode::POSITION_1, // 0x01 - MitsubishiCN105::VaneMode::POSITION_2, // 0x02 - MitsubishiCN105::VaneMode::POSITION_3, // 0x03 - MitsubishiCN105::VaneMode::POSITION_4, // 0x04 - MitsubishiCN105::VaneMode::POSITION_5, // 0x05 - MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::VaneMode::SWING // 0x07 -}); - -static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map({ - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 - MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 - MitsubishiCN105::WideVaneMode::LEFT, // 0x02 - MitsubishiCN105::WideVaneMode::CENTER, // 0x03 - MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 - MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 - MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A - MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B - MitsubishiCN105::WideVaneMode::SWING // 0x0C -}); - static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0})); } @@ -124,10 +43,6 @@ static constexpr auto make_packet(uint8_t type, const std::arrayset_state_(State::CONNECTING); } @@ -277,14 +192,14 @@ bool MitsubishiCN105::should_request_telemetry_() const { return (get_loop_time_ms() - *this->last_telemetry_update_ms_) >= this->telemetry_request_min_interval_ms_; } -void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) { - FrameParser::dump_buffer_vv("TX", packet, len); - this->device_.write_array(packet, len); +void MitsubishiCN105::send_packet_(std::span packet) { + FrameParser::dump_buffer_vv("TX", packet.data(), packet.size()); + this->device_.write_array(packet.data(), packet.size()); this->operation_start_ms_ = get_loop_time_ms(); } void MitsubishiCN105::update_status_() { - std::array payload = {this->current_status_msg_type_}; + std::array payload{this->current_status_msg_type_}; this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload)); } @@ -336,12 +251,22 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) } bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len) { + Property::Decoder decoder{std::span{payload, len}, this->property_context_, this->pending_updates_}; switch (msg_type) { case STATUS_MSG_SETTINGS: - return this->parse_status_settings_(payload, len); + if (!decoder.decode_settings(this->status_)) { + ESP_LOGVV(TAG, "RX settings payload too short"); + return false; + } + return true; case STATUS_MSG_TELEMETRY: - return this->parse_status_telemetry_(payload, len); + if (!decoder.decode_room_temperature(this->status_)) { + ESP_LOGVV(TAG, "RX telemetry payload too short"); + return false; + } + this->last_telemetry_update_ms_ = get_loop_time_ms(); + return true; default: ESP_LOGVV(TAG, "RX unsupported status msg type 0x%02X", msg_type); @@ -349,54 +274,6 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay } } -bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len) { - if (len <= 10) { - ESP_LOGVV(TAG, "RX settings payload too short"); - return false; - } - - if (!this->pending_updates_.contains(UpdateFlag::POWER)) { - this->status_.power_on = payload[2] != 0; - } - - this->use_temperature_encoding_b_ = payload[10] != 0; - if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); - } - - if (!this->pending_updates_.contains(UpdateFlag::MODE)) { - const bool i_see = payload[3] > 0x08; - this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0)); - } - - if (!this->pending_updates_.contains(UpdateFlag::FAN)) { - this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]); - } - - if (!this->pending_updates_.contains(UpdateFlag::VANE)) { - this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]); - } - - this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80; - if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) { - this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F); - } - - return true; -} - -bool MitsubishiCN105::parse_status_telemetry_(const uint8_t *payload, size_t len) { - if (len <= 5) { - ESP_LOGVV(TAG, "RX telemetry payload too short"); - return false; - } - - this->status_.room_temperature = decode_temperature(payload[2], payload[5], 10); - this->last_telemetry_update_ms_ = get_loop_time_ms(); - - return true; -} - void MitsubishiCN105::set_remote_temperature(float temperature) { if (std::isnan(temperature)) { ESP_LOGD(TAG, "Ignoring NaN remote temperature"); @@ -415,12 +292,12 @@ void MitsubishiCN105::clear_remote_temperature() { void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) { this->remote_temperature_half_deg_ = temperature_half_deg; - this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Remote::ID); } void MitsubishiCN105::set_power(bool power_on) { this->status_.power_on = power_on; - this->pending_updates_.set(UpdateFlag::POWER); + this->pending_updates_.set(Property::Power::ID); } void MitsubishiCN105::set_target_temperature(float target_temperature) { @@ -429,101 +306,42 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { return; } this->status_.target_temperature = target_temperature; - this->pending_updates_.set(UpdateFlag::TEMPERATURE); + this->pending_updates_.set(Property::Temperature::Target::ID); } void MitsubishiCN105::set_mode(Mode mode) { - if (!PROTOCOL_MODE_MAP.is_valid(mode)) { - ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast(mode)); - return; + if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast(mode)); } - this->status_.mode = mode; - this->pending_updates_.set(UpdateFlag::MODE); } void MitsubishiCN105::set_fan_mode(FanMode fan_mode) { - if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) { - ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast(fan_mode)); - return; + if (!Property::FanMode::validate_and_set(fan_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid fan mode: %u", static_cast(fan_mode)); } - this->status_.fan_mode = fan_mode; - this->pending_updates_.set(UpdateFlag::FAN); } void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) { - if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) { - ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast(vane_mode)); - return; + if (!Property::VaneMode::validate_and_set(vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid vane mode: %u", static_cast(vane_mode)); } - this->status_.vane_mode = vane_mode; - this->pending_updates_.set(UpdateFlag::VANE); } void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) { - if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) { - ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast(wide_vane_mode)); - return; + if (!Property::WideVaneMode::validate_and_set(wide_vane_mode, this->status_, this->pending_updates_)) { + ESP_LOGD(TAG, "Ignoring invalid wide vane mode: %u", static_cast(wide_vane_mode)); } - this->status_.wide_vane_mode = wide_vane_mode; - this->pending_updates_.set(UpdateFlag::WIDE_VANE); } void MitsubishiCN105::apply_settings_() { std::array payload{}; + Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; // Apply all other pending settings first; handle REMOTE_TEMPERATURE last - if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) { - payload[0] = 0x07; - if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) { - payload[3] = 0x80; - } else { - payload[1] = 0x01; - payload[2] = static_cast(this->remote_temperature_half_deg_ - 16); - payload[3] = static_cast(this->remote_temperature_half_deg_ + 128); - } - this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE); + if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) { + encoder.encode_remote_temperature(this->remote_temperature_half_deg_); } else { - payload[0] = 0x01; - if (this->pending_updates_.contains(UpdateFlag::POWER)) { - payload[1] |= 0x01; - payload[3] = this->status_.power_on ? 0x01 : 0x00; - } - - if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) { - payload[1] |= 0x04; - if (this->use_temperature_encoding_b_) { - payload[14] = static_cast(std::round(this->status_.target_temperature * 2.0f) + 128); - } else { - payload[5] = - static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature)); - } - } - - if (this->pending_updates_.contains(UpdateFlag::MODE) && - PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) { - payload[1] |= 0x02; - } - - if (this->pending_updates_.contains(UpdateFlag::FAN) && - PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) { - payload[1] |= 0x08; - } - - if (this->pending_updates_.contains(UpdateFlag::VANE) && - PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) { - payload[1] |= 0x10; - } - - if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) && - PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) { - payload[2] |= 0x01; - if (this->set_wide_vane_high_bit_) { - payload[13] |= 0x80; - } - } - - this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN, - UpdateFlag::VANE, UpdateFlag::WIDE_VANE); + encoder.encode_settings(this->status_); } this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload)); diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index 3169359290..b6b11b4820 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -5,6 +5,7 @@ #include #include +#include namespace esphome::mitsubishi_cn105 { @@ -121,44 +122,47 @@ class MitsubishiCN105 { uint8_t read_pos_{0}; }; - enum class UpdateFlag : uint8_t { + enum class PropertyId : uint8_t { TEMPERATURE = 0, POWER = 1, MODE = 2, FAN = 3, VANE = 4, WIDE_VANE = 5, - REMOTE_TEMPERATURE = 6, + REMOTE_TEMPERATURE = 6 }; struct UpdateFlags { - template void set(Flags... flags) { (this->mask_.insert(flags), ...); } - template void clear(Flags... flags) { (this->mask_.erase(flags), ...); } + void set(PropertyId id) { this->mask_.insert(id); } + void clear(PropertyId id) { this->mask_.erase(id); } bool any() const { return !this->mask_.empty(); } - bool contains(UpdateFlag flag) const { return this->mask_.count(flag); } - bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); } + bool contains(PropertyId id) const { return this->mask_.count(id); } + bool contains_only(PropertyId id) const { return this->mask_.get_mask() == Mask{id}.get_mask(); } protected: using Mask = - FiniteSetMask(UpdateFlag::REMOTE_TEMPERATURE) + 1>>; - + FiniteSetMask(PropertyId::REMOTE_TEMPERATURE) + 1>>; Mask mask_; }; + struct PropertyContext { + bool use_temperature_encoding_b{false}; + bool set_wide_vane_high_bit{false}; + }; + + friend struct Property; + void set_state_(State new_state); void did_transition_(State to); bool process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len); bool process_status_packet_(const uint8_t *payload, size_t len); bool parse_status_payload_(uint8_t msg_type, const uint8_t *payload, size_t len); - bool parse_status_settings_(const uint8_t *payload, size_t len); - bool parse_status_telemetry_(const uint8_t *payload, size_t len); - void send_packet_(const uint8_t *packet, size_t len); + void send_packet_(std::span packet); void update_status_(); bool should_request_telemetry_() const; void apply_settings_(); bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); - 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); @@ -175,8 +179,7 @@ class MitsubishiCN105 { Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; - bool use_temperature_encoding_b_{false}; - bool set_wide_vane_high_bit_{false}; + PropertyContext property_context_; FrameParser frame_parser_; uint8_t current_status_msg_type_{0}; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index 13e02668d1..197e1e1bb5 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -133,9 +133,7 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) { } } - if (this->parent_->is_status_initialized()) { - this->apply_values_(); - } + this->parent_->publish_status(); } void MitsubishiCN105Climate::apply_values_() { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp index 166e7fbf88..8e9e954645 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.cpp @@ -27,8 +27,15 @@ void MitsubishiCN105Component::setup() { this->hp_.initialize(); } void MitsubishiCN105Component::loop() { if (this->hp_.update()) { - this->status_callback_.call(); + this->notify_status_listeners_(); } } +void VaneCall::perform() { + if (const auto &direction = this->vertical.get_direction(); direction.has_value()) { + this->parent_->set_vane_mode(static_cast(*direction)); + } + this->parent_->publish_status(); +} + } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 2319ea7c54..6461fb464b 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -6,9 +6,50 @@ #include "esphome/components/uart/uart.h" #include +#include namespace esphome::mitsubishi_cn105 { +enum VerticalVaneMode : uint8_t { + VERTICAL_VANE_MODE_AUTO = static_cast(MitsubishiCN105::VaneMode::AUTO), + VERTICAL_VANE_MODE_POSITION_1 = static_cast(MitsubishiCN105::VaneMode::POSITION_1), + VERTICAL_VANE_MODE_POSITION_2 = static_cast(MitsubishiCN105::VaneMode::POSITION_2), + VERTICAL_VANE_MODE_POSITION_3 = static_cast(MitsubishiCN105::VaneMode::POSITION_3), + VERTICAL_VANE_MODE_POSITION_4 = static_cast(MitsubishiCN105::VaneMode::POSITION_4), + VERTICAL_VANE_MODE_POSITION_5 = static_cast(MitsubishiCN105::VaneMode::POSITION_5), + VERTICAL_VANE_MODE_SWING = static_cast(MitsubishiCN105::VaneMode::SWING), + VERTICAL_VANE_MODE_UNKNOWN = static_cast(MitsubishiCN105::VaneMode::UNKNOWN), +}; + +struct VaneState { + struct Vertical { + VerticalVaneMode direction; + }; + + Vertical vertical; +}; + +class MitsubishiCN105Component; + +struct VaneCall { + struct Vertical { + void set_direction(VerticalVaneMode direction) { this->direction_ = direction; } + const std::optional &get_direction() const { return this->direction_; } + + protected: + std::optional direction_; + }; + + explicit VaneCall(MitsubishiCN105Component *parent) : parent_(parent) {} + + Vertical vertical; + + void perform(); + + protected: + MitsubishiCN105Component *parent_; +}; + class MitsubishiCN105Component : public Component, public uart::UARTDevice { public: explicit MitsubishiCN105Component() : hp_(*this) {} @@ -29,6 +70,7 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { void set_fan_mode(MitsubishiCN105::FanMode fan_mode) { this->hp_.set_fan_mode(fan_mode); } void set_vane_mode(MitsubishiCN105::VaneMode vane_mode) { this->hp_.set_vane_mode(vane_mode); } void set_wide_vane_mode(MitsubishiCN105::WideVaneMode mode) { this->hp_.set_wide_vane_mode(mode); } + VaneCall make_vane_call() { return VaneCall(this); } const MitsubishiCN105::Status &status() const { return this->hp_.status(); } bool is_status_initialized() const { return this->hp_.is_status_initialized(); } @@ -38,9 +80,27 @@ class MitsubishiCN105Component : public Component, public uart::UARTDevice { this->status_callback_.add(std::forward(callback)); } + template void add_on_vane_state_callback(F &&callback) { + this->vane_state_callback_.add(std::forward(callback)); + } + + void publish_status() { + if (this->is_status_initialized()) { + this->notify_status_listeners_(); + } + } + protected: + void notify_status_listeners_() { + this->status_callback_.call(); + this->vane_state_callback_.call(VaneState{ + .vertical = {.direction = static_cast(this->status().vane_mode)}, + }); + } + MitsubishiCN105 hp_; CallbackManager status_callback_; + LazyCallbackManager vane_state_callback_; }; } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h new file mode 100644 index 0000000000..1f5faf61af --- /dev/null +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_properties.h @@ -0,0 +1,302 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "mitsubishi_cn105.h" + +namespace esphome::mitsubishi_cn105 { + +template struct LookupMap { + using value_type = decltype(Unknown); + const std::array table; + + constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : Unknown; } + + constexpr bool reverse_lookup(value_type value, uint8_t &out) const { + static_assert(N <= std::numeric_limits::max()); + if (value == Unknown) { + return false; + } + for (uint8_t i = 0; i < static_cast(N); ++i) { + if (this->table[i] == value) { + out = i; + return true; + } + } + return false; + } +}; + +template static constexpr auto make_map(const T (&values)[N]) { + return LookupMap{std::to_array(values)}; +} + +struct Property { + using PropertyId = MitsubishiCN105::PropertyId; + using Status = MitsubishiCN105::Status; + using PropertyContext = MitsubishiCN105::PropertyContext; + + struct Power { + static constexpr auto ID = PropertyId::POWER; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.power_on = payload[2] != 0; + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x01; + payload[3] = status.power_on ? 0x01 : 0x00; + } + }; + + struct Temperature { + struct Target { + static constexpr auto ID = PropertyId::TEMPERATURE; + static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.use_temperature_encoding_b = payload[10] != 0; + } + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.target_temperature = Temperature::decode(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + payload[1] |= 0x04; + if (ctx.use_temperature_encoding_b) { + payload[14] = static_cast(std::round(status.target_temperature * 2.0f) + 128); + } else { + payload[5] = static_cast(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(status.target_temperature)); + } + } + }; + + struct Room { + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.room_temperature = Temperature::decode(payload[2], payload[5], 10); + } + }; + + struct Remote { + static constexpr auto ID = PropertyId::REMOTE_TEMPERATURE; + + static void encode(uint8_t *payload, uint8_t remote_temperature_half_deg, const PropertyContext &) { + if (remote_temperature_half_deg == MitsubishiCN105::REMOTE_TEMPERATURE_DISABLED) { + payload[3] = 0x80; + } else { + payload[1] = 0x01; + payload[2] = static_cast(remote_temperature_half_deg - 16); + payload[3] = static_cast(remote_temperature_half_deg + 128); + } + } + }; + + protected: + static constexpr float decode(int temp_a, int temp_b, int delta) { + return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a; + } + }; + + template struct Lookup { + using Value = std::remove_cvref_t().*Field)>; + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) {} + + static void decode(Status &status, const uint8_t *payload, const PropertyContext &ctx) { + status.*Field = Derived::MAP.lookup(Derived::decode_raw(payload, ctx)); + } + + static void encode(uint8_t *payload, const Status &status, const PropertyContext &ctx) { + uint8_t raw; + if (Derived::MAP.reverse_lookup(status.*Field, raw)) { + Derived::encode_raw(payload, raw, ctx); + } + } + + template static bool validate_and_set(Value value, Status &status, Mask &mask) { + uint8_t raw; + if (!Derived::MAP.reverse_lookup(value, raw)) { + return false; + } + status.*Field = value; + mask.set(Derived::ID); + return true; + } + + private: + friend Derived; + constexpr Lookup() = default; + }; + + struct Mode : Lookup { + static constexpr auto ID = PropertyId::MODE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::Mode::UNKNOWN, // 0x00 + MitsubishiCN105::Mode::HEAT, // 0x01 + MitsubishiCN105::Mode::DRY, // 0x02 + MitsubishiCN105::Mode::COOL, // 0x03 + MitsubishiCN105::Mode::UNKNOWN, // 0x04 + MitsubishiCN105::Mode::UNKNOWN, // 0x05 + MitsubishiCN105::Mode::UNKNOWN, // 0x06 + MitsubishiCN105::Mode::FAN_ONLY, // 0x07 + MitsubishiCN105::Mode::AUTO // 0x08 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { + const bool i_see = payload[3] > 0x08; + return payload[3] - (i_see ? 0x08 : 0); + } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x02; + payload[4] = raw; + } + }; + + struct FanMode : Lookup { + static constexpr auto ID = PropertyId::FAN; + static constexpr auto MAP = make_map({ + MitsubishiCN105::FanMode::AUTO, // 0x00 + MitsubishiCN105::FanMode::QUIET, // 0x01 + MitsubishiCN105::FanMode::SPEED_1, // 0x02 + MitsubishiCN105::FanMode::SPEED_2, // 0x03 + MitsubishiCN105::FanMode::UNKNOWN, // 0x04 + MitsubishiCN105::FanMode::SPEED_3, // 0x05 + MitsubishiCN105::FanMode::SPEED_4 // 0x06 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[5]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x08; + payload[6] = raw; + } + }; + + struct VaneMode : Lookup { + static constexpr auto ID = PropertyId::VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::VaneMode::AUTO, // 0x00 + MitsubishiCN105::VaneMode::POSITION_1, // 0x01 + MitsubishiCN105::VaneMode::POSITION_2, // 0x02 + MitsubishiCN105::VaneMode::POSITION_3, // 0x03 + MitsubishiCN105::VaneMode::POSITION_4, // 0x04 + MitsubishiCN105::VaneMode::POSITION_5, // 0x05 + MitsubishiCN105::VaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::VaneMode::SWING // 0x07 + }); + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[6]; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &) { + payload[1] |= 0x10; + payload[7] = raw; + } + }; + + struct WideVaneMode : Lookup { + static constexpr auto ID = PropertyId::WIDE_VANE; + static constexpr auto MAP = make_map({ + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00 + MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01 + MitsubishiCN105::WideVaneMode::LEFT, // 0x02 + MitsubishiCN105::WideVaneMode::CENTER, // 0x03 + MitsubishiCN105::WideVaneMode::RIGHT, // 0x04 + MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07 + MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09 + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A + MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B + MitsubishiCN105::WideVaneMode::SWING // 0x0C + }); + + static void decode_context(PropertyContext &ctx, const uint8_t *payload) { + ctx.set_wide_vane_high_bit = (payload[9] & 0xF0) == 0x80; + } + + static uint8_t decode_raw(const uint8_t *payload, const PropertyContext &ctx) { return payload[9] & 0x0F; } + + static void encode_raw(uint8_t *payload, uint8_t raw, const PropertyContext &ctx) { + payload[2] |= 0x01; + payload[13] = ctx.set_wide_vane_high_bit ? raw | 0x80 : raw; + } + }; + + template struct Decoder { + const std::span payload; + PropertyContext &context; + const Mask &pending_writes; + + bool ESPHOME_ALWAYS_INLINE decode_settings(Status &status) { + if (this->payload.size() <= 10) { + return false; + } + this->decode_(status); + return true; + } + + bool ESPHOME_ALWAYS_INLINE decode_room_temperature(Status &status) { + if (this->payload.size() <= 5) { + return false; + } + this->decode_(status); + return true; + } + + protected: + template ESPHOME_ALWAYS_INLINE void decode_one_(Out &out) { + T::decode_context(this->context, this->payload.data()); + if constexpr (requires { T::ID; }) { + if (this->pending_writes.contains(T::ID)) { + return; + } + } + T::decode(out, this->payload.data(), this->context); + } + + template void ESPHOME_ALWAYS_INLINE decode_(Out &out) { + (this->decode_one_(out), ...); + } + }; + + template struct Encoder { + uint8_t *payload; + const PropertyContext &context; + Mask &pending_writes; + + void ESPHOME_ALWAYS_INLINE encode_settings(const Status &status) { + this->payload[0] = 0x01; + this->encode_and_clear_(status); + } + + void ESPHOME_ALWAYS_INLINE encode_remote_temperature(uint8_t remote_temperature_half_deg) { + this->payload[0] = 0x07; + this->encode_and_clear_(remote_temperature_half_deg); + } + + protected: + template void ESPHOME_ALWAYS_INLINE encode_and_clear_(const In &in) { + (this->encode_one_(in), ...); + (this->pending_writes.clear(T::ID), ...); + } + + template void encode_one_(const In &in) { + if (this->pending_writes.contains(T::ID)) { + T::encode(this->payload, in, this->context); + } + } + }; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/__init__.py b/esphome/components/mitsubishi_cn105/select/__init__.py new file mode 100644 index 0000000000..4ca12edbb4 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/__init__.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import select +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.types import ConfigType + +from .. import ( + MITSUBISHI_CN105_DEVICE_SCHEMA, + VERTICAL_VANE_DIRECTIONS, + MitsubishiCN105Component, + mitsubishi_ns, + register_mitsubishi_cn105_device, +) + +DEPENDENCIES = ["mitsubishi_cn105"] + +CONF_VERTICAL_VANE_DIRECTION = "vertical_vane_direction" + +MitsubishiCN105VerticalVaneDirectionSelect = mitsubishi_ns.class_( + "MitsubishiCN105VerticalVaneDirectionSelect", + select.Select, + cg.Component, + cg.Parented.template(MitsubishiCN105Component), +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.Optional(CONF_VERTICAL_VANE_DIRECTION): select.select_schema( + MitsubishiCN105VerticalVaneDirectionSelect, + icon="mdi:arrow-up-down", + ), + } +).extend(MITSUBISHI_CN105_DEVICE_SCHEMA) + + +async def to_code(config: ConfigType) -> None: + if vertical_vane_direction := config.get(CONF_VERTICAL_VANE_DIRECTION): + var = cg.new_Pvariable(vertical_vane_direction[CONF_ID]) + await cg.register_component(var, vertical_vane_direction) + await select.register_select( + var, + vertical_vane_direction, + options=[direction.capitalize() for direction in VERTICAL_VANE_DIRECTIONS], + ) + await register_mitsubishi_cn105_device(var, config) diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp new file mode 100644 index 0000000000..d703ddbb02 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.cpp @@ -0,0 +1,39 @@ +#include "mitsubishi_cn105_vane_select_vertical.h" + +#include + +namespace esphome::mitsubishi_cn105 { + +// NOTE: This order must match VERTICAL_VANE_DIRECTIONS in the hub's __init__.py. +// MitsubishiCN105VerticalVaneDirectionSelect uses the preferred index-based +// Select API, so Python option order and this array must stay aligned. +static constexpr std::array VALUES{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, MitsubishiCN105::VaneMode::POSITION_2, + MitsubishiCN105::VaneMode::POSITION_3, MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, +}; + +void MitsubishiCN105VerticalVaneDirectionSelect::setup() { + this->parent_->add_on_status_callback([this]() { this->publish_vane_state(this->parent_->status().vane_mode); }); + if (this->parent_->is_status_initialized()) { + this->publish_vane_state(this->parent_->status().vane_mode); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::control(size_t index) { + if (index < VALUES.size()) { + this->parent_->set_vane_mode(VALUES[index]); + this->parent_->publish_status(); + } +} + +void MitsubishiCN105VerticalVaneDirectionSelect::publish_vane_state(MitsubishiCN105::VaneMode mode) { + for (size_t i = 0; i < VALUES.size(); ++i) { + if (VALUES[i] == mode) { + this->publish_state(i); + return; + } + } +} + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h new file mode 100644 index 0000000000..76977d59d7 --- /dev/null +++ b/esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h @@ -0,0 +1,21 @@ +#pragma once + +#include "../mitsubishi_cn105_component.h" + +#include "esphome/components/select/select.h" +#include "esphome/core/component.h" + +namespace esphome::mitsubishi_cn105 { + +class MitsubishiCN105VerticalVaneDirectionSelect : public select::Select, + public Component, + public Parented { + public: + void setup() override; + void publish_vane_state(MitsubishiCN105::VaneMode mode); + + protected: + void control(size_t index) override; +}; + +} // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/modbus/__init__.py b/esphome/components/modbus/__init__.py index 377dadad76..58bd0f65dc 100644 --- a/esphome/components/modbus/__init__.py +++ b/esphome/components/modbus/__init__.py @@ -28,6 +28,7 @@ MAX_NUM_OF_DISCRETE_INPUTS_TO_READ = 2000 MAX_NUM_OF_COILS_TO_WRITE = 1968 MAX_NUM_OF_REGISTERS_TO_READ = 125 MAX_NUM_OF_REGISTERS_TO_WRITE = 123 +MAX_NUM_OF_REGISTERS_TO_WRITE_RW = 121 modbus_ns = cg.esphome_ns.namespace("modbus") Modbus = modbus_ns.class_("Modbus", cg.Component, uart.UARTDevice) diff --git a/esphome/components/modbus/modbus.cpp b/esphome/components/modbus/modbus.cpp index 901bfcc52e..5305f6313f 100644 --- a/esphome/components/modbus/modbus.cpp +++ b/esphome/components/modbus/modbus.cpp @@ -382,7 +382,7 @@ ModbusServerDevice *ModbusServerHub::find_device_(uint8_t address) { } ResponseStatus ModbusServerHub::check_address_range_(uint16_t start_address, uint16_t count) { - if ((uint32_t) start_address + count > 0x10000u) { + if (!helpers::address_range_fits(start_address, count)) { ESP_LOGW(TAG, "Address out of range - start: %" PRIu16 " num: %" PRIu16, start_address, count); return ExceptionCode::ILLEGAL_DATA_ADDRESS; } @@ -815,6 +815,16 @@ void ModbusClientHub::send_next_frame_() { } cmd->sent(); + if (cmd->frame.address() == BROADCAST_ADDRESS) { + // A broadcast (address 0) is never answered (Modbus 4.1), so it is fire-and-forget: on_sent above + // reports the transmission, and the entry then retires with no terminal callback instead of + // occupying the waiting slot until the send-wait timeout expires. The turnaround delay already + // spaces the next frame; the following sweep erases the entry. + ESP_LOGV(TAG, "Broadcast to address 0 sent; no reply expected (fire-and-forget)"); + cmd->complete_broadcast(); + this->sweep_needed_ = true; + return; + } this->waiting_for_response_ = true; } @@ -1033,9 +1043,24 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M ESP_LOGE(TAG, "Frame too large, refused: %" PRIu8 ":%zu bytes", address, pdu.size()); return false; } + // classify() drives both the broadcast guard and the continuous check below; compute it once. + const CommandPriority priority = ModbusDeviceCommand::classify(pdu[0]); + + // A broadcast (address 0) is never answered (Modbus 4.1), so it is only meaningful for a command that + // changes state. Refuse a broadcast that expects a reply - anything but a write or a custom/vendor code - + // as it could never deliver a result, so the caller learns via the false return (and on_not_sent). + // 0x17 (read/write multiple) is a knowing inclusion: classify() treats it as a write, so its write half + // lands on every server and its unanswerable read half is simply discarded. An exception-flagged custom + // code (0x80 bit set) is refused: is_function_code_custom() masks that bit away, so exclude it explicitly + // here to match classify()'s exception-first handling of the write side. + if (address == BROADCAST_ADDRESS && priority != CommandPriority::WRITE && + (!helpers::is_function_code_custom(pdu[0]) || helpers::is_function_code_exception(pdu[0]))) { + ESP_LOGW(TAG, "Broadcast refused for function 0x%X: a broadcast (address 0) is never answered", pdu[0]); + return false; + } // continuous is ignored for every mutating code (re-writing a value forever is never intended). - const bool mutates = ModbusDeviceCommand::classify(pdu[0]) == CommandPriority::WRITE; + const bool mutates = priority == CommandPriority::WRITE; bool continuous = false; if (options.continuous) { if (mutates) { @@ -1056,7 +1081,8 @@ bool ModbusClientHub::queue_pdu(uint8_t address, std::span pdu, M continue; if (device == nullptr) { // A dropped read is routine (DEBUG); a dropped write/custom warns (unobservable without a device). - const bool requeueable = !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read(pdu[0]); + const bool requeueable = + !helpers::is_function_code_exception(pdu[0]) && helpers::is_function_code_read_only(pdu[0]); if (requeueable) { ESP_LOGD(TAG, "Anonymous duplicate of active frame for %" PRIu8 " (function 0x%X), dropped", address, pdu[0]); } else { @@ -1236,7 +1262,14 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu switch (function_code) { case FunctionCode::READ_HOLDING_REGISTERS: - case FunctionCode::READ_INPUT_REGISTERS: { + case FunctionCode::READ_INPUT_REGISTERS: + // FC 0x17 lands here too: its read start address and read quantity sit at the same request offsets as a + // plain read's (bytes 1..2 and 3..4), so start_address and count_or_value already hold the read block; its + // response carries only that read data, and the write half is confirmed by the response arriving at all. + // An exception routes here as well (the gate only validates the request when status is set), delivering + // empty registers with the error in status - so a 0x17 subclass handles success and failure in the one + // on_read_holding_registers() callback and never needs to also override on_error(). + case FunctionCode::READ_WRITE_MULTIPLE_REGISTERS: { // Decode the big-endian register words into host byte order. The gate guarantees a success response // carries exactly count_or_value registers (and count_or_value <= MAX_NUM_OF_REGISTERS_TO_READ, the // capacity of RegisterValues); a mismatch was diverted to on_custom_response(), never clamped. On @@ -1248,10 +1281,15 @@ void ModbusClientDevice::dispatch_response_(std::span request_pdu } } std::span register_span(registers.data(), registers.size()); - if (function_code == FunctionCode::READ_HOLDING_REGISTERS) { + if (function_code == FunctionCode::READ_INPUT_REGISTERS) { + this->on_read_input_registers(start_address, register_span, status); + } else if (function_code == FunctionCode::READ_HOLDING_REGISTERS || + function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) { this->on_read_holding_registers(start_address, register_span, status); } else { - this->on_read_input_registers(start_address, register_span, status); + // Unreachable for the current case labels; match explicitly so a function code added to this group + // later is diverted to on_custom_response() rather than silently delivered as a holding read. + this->on_custom_response(request_pdu, response_pdu, status); } break; } diff --git a/esphome/components/modbus/modbus.h b/esphome/components/modbus/modbus.h index 6331f23f99..dfe4a4872d 100644 --- a/esphome/components/modbus/modbus.h +++ b/esphome/components/modbus/modbus.h @@ -151,9 +151,7 @@ struct ModbusDeviceCommand { static CommandPriority classify(uint8_t function_code) { if (helpers::is_function_code_exception(function_code)) return CommandPriority::READ; - const auto code = static_cast(function_code); - if (helpers::is_function_code_write(function_code) || code == FunctionCode::MASK_WRITE_REGISTER || - code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS) { + if (helpers::is_function_code_write(function_code)) { return CommandPriority::WRITE; } return CommandPriority::READ; @@ -162,7 +160,7 @@ struct ModbusDeviceCommand { // Requests this entry can serve: a standard read twice (run plus one re-run), everything else once. uint8_t max_pending() const { const uint8_t fc = this->frame.pdu()[0]; - const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read(fc); + const bool requeueable = !helpers::is_function_code_exception(fc) && helpers::is_function_code_read_only(fc); return (requeueable && !this->continuous) ? 2 : 1; } // Device-scoped clear: detach with no callback (device-less, pending 0). An entry still waiting for @@ -173,6 +171,15 @@ struct ModbusDeviceCommand { this->pending = 0; this->device = nullptr; } + // Fire-and-forget completion for a broadcast (address 0): the frame was transmitted (on_sent already + // fired), but a broadcast is never answered (Modbus 4.1), so the entry retires with NO terminal + // callback and the sweep erases it. Unlike response()/error()/timed_out(), it delivers nothing. + // A broadcast only carries a write or a custom code (reads are refused at queue_pdu()), and every such + // code caps pending at 1, so pending is always 1 here - clear it. + void complete_broadcast() { + this->state = FrameState::RETIRED; + this->pending = 0; + } // Re-ready for another transmission, restamped to the tail of its class (hub passes next_seq_++). void requeue(uint16_t seq) { this->state = FrameState::READY; @@ -272,7 +279,8 @@ class ModbusClientHub : public Modbus { }; /// Queue a request. The name says queue, not send: the frame is appended to the transmit queue and /// goes out later from loop(), so a true return means accepted into the machine (it will resolve in - /// exactly one terminal callback), NOT that anything reached the wire - that is on_sent(). False means + /// exactly one terminal callback - except a broadcast (address 0), which is never answered and so gets + /// only on_sent()), NOT that anything reached the wire - that is on_sent(). False means /// it never entered the machine at all (empty or oversize PDU, full queue, anonymous or over-cap /// duplicate) and no callback of any kind will follow; the false return is the whole story. bool queue_pdu(uint8_t address, std::span pdu, ModbusClientDevice *device = nullptr, @@ -413,13 +421,14 @@ class ModbusServerHub : public Modbus { /// Callback contract. Each accepted request ends in exactly ONE terminal: on_response() (data), /// on_error() (exception), on_no_response() (timeout/interruption), or on_not_sent() (dropped by /// clear_tx_queue_for_address before transmission). A request refused at queue_pdu() (false return) -/// gets none. on_sent() is additional, once per transmission, never for an on_not_sent() request. -/// on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, all -/// from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from +/// gets none, and a broadcast (address 0) gets on_sent() with NO terminal, since a broadcast is never +/// answered (Modbus 4.1). on_sent() is additional, once per transmission, never for an on_not_sent() +/// request. on_response()/on_error() fire at parse time and on_no_response() at the send-wait watchdog, +/// all from a quiescent hub; only on_not_sent() is delivered by the sweep. Sending or clearing from /// inside a callback is safe (picked up by the next sweep). Exceptions to "exactly one terminal": -/// clear_tx_queue_for_device() drops the caller's own frames silently; a continuous poll's cycles are -/// its own accounting (a one-shot duplicate downgrades the poll to a one-shot; a continuous duplicate -/// merges into it). +/// a broadcast is fire-and-forget (on_sent, no terminal); clear_tx_queue_for_device() drops the caller's +/// own frames silently; a continuous poll's cycles are its own accounting (a one-shot duplicate +/// downgrades the poll to a one-shot; a continuous duplicate merges into it). /// /// Invariants: /// - Public entry points (queue_pdu/clear_tx_queue_*) only append to the queue or mutate an existing @@ -533,8 +542,9 @@ class ModbusClientDevice { this); } /// See ModbusClientHub::queue_pdu(): true = accepted into the queue and a terminal callback will - /// follow, false = refused at the door and nothing further happens. Neither means the frame is on - /// the wire; on_sent() reports that. + /// follow (except a broadcast (address 0), which is never answered and so gets only on_sent()), + /// false = refused at the door and nothing further happens. Neither means the frame is on the wire; + /// on_sent() reports that. bool queue_pdu(std::span pdu, CommandOptions options = {}) { return this->parent_->queue_pdu(this->address_, pdu, this, options); } @@ -550,8 +560,9 @@ class ModbusClientDevice { this->parent_->queue_pdu(payload[0], std::span(payload).subspan(1), this); } // The typed request builders below all queue through queue_pdu(), so they share its contract: true - // means the request is queued and will resolve in exactly one terminal callback, false means it was - // refused outright with no callback. Neither says the frame has been transmitted - on_sent() does. + // means the request is queued and will resolve in exactly one terminal callback (except a broadcast + // (address 0), which is never answered and so gets only on_sent()), false means it was refused outright + // with no callback. Neither says the frame has been transmitted - on_sent() does. // Reads via the table-appropriate function code; an unreadable entity type maps to INVALID, which // create_read_pdu() rejects into an empty PDU and queue_pdu() refuses with a false return. bool read_entities(EntityType entity_type, uint16_t start_address, uint16_t number_of_entities, @@ -594,6 +605,16 @@ class ModbusClientDevice { bool write_multiple_coils(uint16_t start_address, PackedBits bits) { return this->queue_pdu(helpers::create_write_coils_pdu(start_address, bits)); } + /// FC 0x17: the read-back is delivered through on_read_holding_registers() (the response carries only the + /// read registers, the same wire shape as a holding-register read). A device exception - typically a + /// rejected write half - arrives at that same on_read_holding_registers() with the error in its status, + /// exactly as success does, so a subclass overriding that one callback handles both outcomes and never + /// needs to also override on_error(). + bool read_write_multiple_registers(uint16_t read_start_address, uint16_t read_count, uint16_t write_start_address, + std::span write_values) { + return this->queue_pdu(helpers::create_read_write_multiple_registers_pdu(read_start_address, read_count, + write_start_address, write_values)); + } inline void clear_tx_queue_for_address() { this->parent_->clear_tx_queue_for_address(this->address_); } inline void clear_tx_queue_for_device() { this->parent_->clear_tx_queue_for_device(this); } diff --git a/esphome/components/modbus/modbus_helpers.cpp b/esphome/components/modbus/modbus_helpers.cpp index 4287256101..db21b6e6fd 100644 --- a/esphome/components/modbus/modbus_helpers.cpp +++ b/esphome/components/modbus/modbus_helpers.cpp @@ -8,10 +8,11 @@ namespace esphome::modbus::helpers { static const char *const TAG = "modbus_helpers"; // A quantity/address pair is standard when the quantity is non-zero, within the per-table maximum, -// and the range [start_address, start_address + quantity) stays inside the 16-bit address space -// (the 32-bit promotion is the overflow guard - a 16-bit sum could wrap and pass). +// and the range [start_address, start_address + quantity) stays inside the 16-bit address space. +// Non-logging twin of register_block_in_range(): the same three predicates for the parser side, taking a +// uint16_t quantity. register_block_in_range() is the builder-side variant that also logs which half failed. static bool quantity_in_range(uint16_t start_address, uint16_t quantity, uint16_t max_quantity) { - return quantity != 0 && quantity <= max_quantity && uint32_t(start_address) + quantity <= 0x10000u; + return quantity != 0 && quantity <= max_quantity && address_range_fits(start_address, quantity); } // The spec allows exactly ON (0xFF00) and OFF (0x0000) for a single-coil value, on the request and @@ -307,16 +308,20 @@ std::optional registers_to_number(const uint16_t *registers, size_t cou return payload_to_number(bytes, required_size, sensor_value_type, 0, 0xFFFFFFFF); } +// Append a 16-bit value to a PDU in big-endian (wire) byte order. +template static void append_pdu_word(StaticVector &pdu, uint16_t value) { + pdu.push_back(value >> 8); + pdu.push_back(value >> 0); +} + // Every request PDU opens with the same 5-byte layout: function code, then two big-endian 16-bit // fields (start address + quantity for reads and multi-writes, address + value for single writes). template static void append_pdu_header(StaticVector &pdu, FunctionCode function_code, uint16_t first, uint16_t second) { pdu.push_back(static_cast(function_code)); - pdu.push_back(first >> 8); - pdu.push_back(first >> 0); - pdu.push_back(second >> 8); - pdu.push_back(second >> 0); + append_pdu_word(pdu, first); + append_pdu_word(pdu, second); } // Zero the unused bits of a multi-coil write's final data byte, as the spec requires. Kept in one @@ -335,7 +340,7 @@ ReadPdu create_read_pdu(FunctionCode function_code, uint16_t start_address, uint ESP_LOGE(TAG, "Number of entities is zero for function code %02X", static_cast(function_code)); return pdu; } - if (uint32_t(start_address) + number_of_entities > 0x10000u) { + if (!address_range_fits(start_address, number_of_entities)) { ESP_LOGE(TAG, "Read of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, start_address); return pdu; @@ -378,7 +383,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) // Generic entry point; prefer the direction- and type-specific builders (create_read_pdu(), // create_write_registers_pdu(), etc.) which bound their inputs per spec. - if (is_function_code_read(static_cast(function_code))) { + if (is_function_code_read_only(static_cast(function_code))) { if (values != nullptr || values_len > 0) { ESP_LOGW(TAG, "Values provided for read function code %02X, but will be ignored", static_cast(function_code)); @@ -417,7 +422,7 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, static_cast(function_code)); return pdu; } - if (!is_single && uint32_t(start_address) + number_of_entities > 0x10000u) { + if (!is_single && !address_range_fits(start_address, number_of_entities)) { ESP_LOGE(TAG, "Write of %u entities at %u runs past the 16-bit address space, dropping request", number_of_entities, start_address); return pdu; @@ -460,29 +465,59 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, return pdu; } +// Validate one register block for a client builder: a non-zero quantity within max_quantity that does not +// run past the 16-bit address space (register count × 2 stays within MAX_PDU_SIZE as a result). On failure +// it logs the reason and returns false, on which the caller returns an empty PDU. `role` names the block in +// the log ("Read"/"Write"). Logging twin of quantity_in_range(): the same three predicates, split so each +// failure names its reason, and taking size_t so an oversize span is caught before any narrowing. +static bool register_block_in_range(const LogString *role, uint16_t start_address, size_t quantity, + uint16_t max_quantity) { + if (quantity == 0 || quantity > max_quantity) { + ESP_LOGE(TAG, "%s count %zu out of range [1, %u], dropping request", LOG_STR_ARG(role), quantity, max_quantity); + return false; + } + if (!address_range_fits(start_address, quantity)) { + ESP_LOGE(TAG, "%s of %zu registers at %u runs past the 16-bit address space, dropping request", LOG_STR_ARG(role), + quantity, start_address); + return false; + } + return true; +} + PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values) { PduBuffer pdu; // declared before every return so NRVO fires (all paths return the same object) - if (values.empty()) { - ESP_LOGE(TAG, "No values provided for write multiple registers, dropping request"); - return pdu; - } - // Byte count is registers × 2 (per spec); bounding the register count keeps the PDU within MAX_PDU_SIZE. - if (values.size() > MAX_NUM_OF_REGISTERS_TO_WRITE) { - ESP_LOGE(TAG, "values.size() %zu exceeds maximum registers to write %u, dropping request", values.size(), - MAX_NUM_OF_REGISTERS_TO_WRITE); - return pdu; - } - if (uint32_t(start_address) + values.size() > 0x10000u) { - ESP_LOGE(TAG, "Write of %zu registers at %u runs past the 16-bit address space, dropping request", values.size(), - start_address); + if (!register_block_in_range(LOG_STR("Write"), start_address, values.size(), MAX_NUM_OF_REGISTERS_TO_WRITE)) { return pdu; } append_pdu_header(pdu, FunctionCode::WRITE_MULTIPLE_REGISTERS, start_address, values.size()); pdu.push_back(static_cast(values.size() * 2)); // byte count for (auto v : values) { - auto decoded_value = decode_value(v); - pdu.push_back(decoded_value[0]); - pdu.push_back(decoded_value[1]); + append_pdu_word(pdu, v); + } + return pdu; +} + +PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, + uint16_t write_start_address, + std::span write_values) { + PduBuffer pdu; + if (!register_block_in_range(LOG_STR("Read"), read_start_address, read_count, MAX_NUM_OF_REGISTERS_TO_READ)) { + return pdu; + } + if (!register_block_in_range(LOG_STR("Write"), write_start_address, write_values.size(), + MAX_NUM_OF_REGISTERS_TO_WRITE_RW)) { + return pdu; + } + // fc + read start(2) + read qty(2) + write start(2) + write qty(2) + write byte count(1) + write values. + const auto write_count = static_cast(write_values.size()); + pdu.push_back(static_cast(FunctionCode::READ_WRITE_MULTIPLE_REGISTERS)); + append_pdu_word(pdu, read_start_address); + append_pdu_word(pdu, read_count); + append_pdu_word(pdu, write_start_address); + append_pdu_word(pdu, write_count); + pdu.push_back(static_cast(write_count * 2)); // byte count + for (auto v : write_values) { + append_pdu_word(pdu, v); } return pdu; } @@ -512,7 +547,7 @@ static void build_write_coils_pdu(PduBuffer &pdu, uint16_t start_address, Packed ESP_LOGE(TAG, "count %u exceeds maximum coils to write %u, dropping request", count, MAX_NUM_OF_COILS_TO_WRITE); return; } - if (uint32_t(start_address) + count > 0x10000u) { + if (!address_range_fits(start_address, count)) { ESP_LOGE(TAG, "Write of %u coils at %u runs past the 16-bit address space, dropping request", count, start_address); return; } diff --git a/esphome/components/modbus/modbus_helpers.h b/esphome/components/modbus/modbus_helpers.h index e47a6835cd..c737e206c0 100644 --- a/esphome/components/modbus/modbus_helpers.h +++ b/esphome/components/modbus/modbus_helpers.h @@ -11,7 +11,8 @@ namespace esphome::modbus::helpers { -inline bool is_function_code_read(uint8_t function_code) { +// Pure read codes (0x01-0x04): they only read, so they are idempotent and safe to retry. +inline bool is_function_code_read_only(uint8_t function_code) { FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); return masked_function_code == FunctionCode::READ_COILS || masked_function_code == FunctionCode::READ_DISCRETE_INPUTS || @@ -19,12 +20,27 @@ inline bool is_function_code_read(uint8_t function_code) { masked_function_code == FunctionCode::READ_INPUT_REGISTERS; } +// Codes whose response carries read-back data: the pure reads plus 0x17, which reads and writes at once. +inline bool is_function_code_read(uint8_t function_code) { + return is_function_code_read_only(function_code) || + static_cast(function_code & FUNCTION_CODE_MASK) == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS; +} + +// Codes that mutate registers or coils: the pure writes, 0x16 mask-write, and 0x17 read/write multiple. inline bool is_function_code_write(uint8_t function_code) { FunctionCode masked_function_code = static_cast(function_code & FUNCTION_CODE_MASK); return masked_function_code == FunctionCode::WRITE_SINGLE_COIL || masked_function_code == FunctionCode::WRITE_SINGLE_REGISTER || masked_function_code == FunctionCode::WRITE_MULTIPLE_COILS || - masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS; + masked_function_code == FunctionCode::WRITE_MULTIPLE_REGISTERS || + masked_function_code == FunctionCode::MASK_WRITE_REGISTER || + masked_function_code == FunctionCode::READ_WRITE_MULTIPLE_REGISTERS; +} + +// True if [start_address, start_address + count) fits within the 16-bit Modbus address space. The 32-bit +// promotion is the overflow guard - a 16-bit sum could wrap and pass. +inline bool address_range_fits(uint16_t start_address, size_t count) { + return uint32_t(start_address) + count <= 0x10000u; } inline bool is_function_code_exception(uint8_t function_code) { @@ -90,8 +106,8 @@ inline uint8_t server_frame_data_offset(const uint8_t *frame, size_t size) { } /** Returns the payload portion of a server response PDU: the bytes after the function code, and for the - * standard read responses (0x01-0x04) also after the byte-count byte. Responses to 0x14/0x17 also carry a - * byte-count byte, but those codes are not implemented and their count byte is left in the payload. For + * read responses (0x01-0x04 and 0x17) also after the byte-count byte. Response 0x14 also carries a + * byte-count byte, but that code is not implemented and its count byte is left in the payload. For * an exception PDU the payload is the exception code byte (the read check must not see the masked * function code, or an exception-of-read would classify as a read and return an empty span). Returns an * empty span if the PDU is too short. @@ -432,6 +448,21 @@ PduBuffer create_client_pdu(FunctionCode function_code, uint16_t start_address, */ PduBuffer create_write_registers_pdu(uint16_t start_address, std::span values); +/** Create modbus read/write multiple registers command + * Function 0x17 Read/Write Multiple Registers + * Writes write_values then reads read_count registers in one transaction (write first, per Modbus 6.17); + * the response carries only the read registers. + * @param read_start_address modbus address of the first register to read back + * @param read_count number of registers to read (at most MAX_NUM_OF_REGISTERS_TO_READ) + * @param write_start_address modbus address of the first register to write + * @param write_values register values to write; the register count is write_values.size() (at most + * MAX_NUM_OF_REGISTERS_TO_WRITE_RW). Any contiguous uint16_t container converts. + * @return PDU (function code + data, no address, no CRC); an empty PDU on any out-of-range input + */ +PduBuffer create_read_write_multiple_registers_pdu(uint16_t read_start_address, uint16_t read_count, + uint16_t write_start_address, + std::span write_values); + /** Create modbus write single register command * Function 0x06 Write Single Register * @param start_address modbus address of the register to write diff --git a/esphome/components/modbus_client/__init__.py b/esphome/components/modbus_client/__init__.py index 52a61cacad..bb113d649c 100644 --- a/esphome/components/modbus_client/__init__.py +++ b/esphome/components/modbus_client/__init__.py @@ -28,9 +28,12 @@ CONF_ON_NO_RESPONSE = "on_no_response" CONF_ON_NOT_SENT = "on_not_sent" CONF_ON_SENT = "on_sent" CONF_PDU = "pdu" +CONF_READ_ADDRESS = "read_address" +CONF_READ_COUNT = "read_count" CONF_RETRY = "retry" CONF_START_ADDRESS = "start_address" CONF_VALUES = "values" +CONF_WRITE_ADDRESS = "write_address" modbus_client_ns = cg.esphome_ns.namespace("modbus_client") ModbusClientSendAction = modbus_client_ns.class_( @@ -55,6 +58,9 @@ WriteMultipleRegistersAction = modbus_client_ns.class_( WriteMultipleCoilsAction = modbus_client_ns.class_( "WriteMultipleCoilsAction", automation.Action, modbus.ModbusClientDevice ) +ReadWriteMultipleRegistersAction = modbus_client_ns.class_( + "ReadWriteMultipleRegistersAction", automation.Action, modbus.ModbusClientDevice +) # Packed bit view delivered to read_coils / read_discrete_inputs on_response handlers. PackedBits = modbus.modbus_ns.class_("PackedBits") @@ -255,21 +261,30 @@ async def modbus_client_send_to_code(config, action_id, template_arg, args): _REGISTER_SPAN = cg.std_span.template(cg.uint16.operator("const")) -# Every typed action addresses a register or coil range and reports through the same two reply handlers. -_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend( +# The reply-handler pair every typed-dispatch action reports through. Kept in one place so the +# read/write-multiple schema (which cannot require start_address) shares it instead of drifting. +# Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the hub +# reuses once the handler returns, so a deferring action would resume on freed memory. A reply the +# dispatch gate diverts (not a standard-conformant transaction) arrives at on_custom_response with the +# raw request/response PDUs; real device exceptions still arrive via on_error. +_REPLY_HANDLERS_SCHEMA = cv.Schema( { - cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), - # Both use _handler_schema(): the decoded arguments (values span, bits view) point at buffers the - # hub reuses once the handler returns, so a deferring action would resume on freed memory. cv.Optional(CONF_ON_RESPONSE): _handler_schema(), - # A reply the dispatch gate diverts (not a standard-conformant transaction) arrives here with the - # raw request/response PDUs; real device exceptions still arrive via on_error. cv.Optional(CONF_ON_CUSTOM_RESPONSE): _handler_schema(), } ) +# Every typed action addresses a register or coil range and reports through the shared reply handlers. +_TYPED_ACTION_SCHEMA = _ACTION_BASE_SCHEMA.extend(_REPLY_HANDLERS_SCHEMA).extend( + { + cv.Required(CONF_START_ADDRESS): cv.templatable(cv.hex_uint16_t), + } +) -def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]: + +def _no_address_overflow( + count_key: str, address_key: str = CONF_START_ADDRESS +) -> Callable[[ConfigType], ConfigType]: """Reject a range that runs past the 16-bit address space, which the device could never answer. Only literal configurations can be checked: either operand may be a lambda, and its value is not known @@ -278,17 +293,17 @@ def _no_address_overflow(count_key: str) -> Callable[[ConfigType], ConfigType]: """ def validate(config: ConfigType) -> ConfigType: - start = config[CONF_START_ADDRESS] + start = config[address_key] count = config[count_key] if isinstance(start, Lambda) or isinstance(count, Lambda): return config - # CONF_COUNT is a number; CONF_VALUES is the list whose length is the count. + # A count key holds a number; a values key holds the list whose length is the count. length = count if isinstance(count, int) else len(count) if start + length > 0x10000: raise cv.Invalid( - f"{CONF_START_ADDRESS} 0x{start:04X} plus {length} entities runs past the end of the " + f"{address_key} 0x{start:04X} plus {length} entities runs past the end of the " f"16-bit address space (last addressable entity is 0xFFFF)", - path=[CONF_START_ADDRESS], + path=[address_key], ) return config @@ -468,3 +483,64 @@ async def write_multiple_coils_to_code(config, action_id, template_arg, args): arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*packed)) cg.add(var.set_values_static(arr, len(values))) return await register_client_action(var, config, args, []) + + +# Read/write multiple registers (FC 0x17) writes one register block and reads another in a single +# transaction, so it has two address ranges and uses read_address/write_address instead of start_address. +# Note the two meanings of `values`: here it is the block being WRITTEN, while in on_response the lambda +# argument `values` is the block that was READ BACK (host-order words, the same shape as +# read_holding_registers, so a caller can feed it through the same handler). +_READ_WRITE_MULTIPLE_REGISTERS_SCHEMA = cv.All( + _ACTION_BASE_SCHEMA.extend(_REPLY_HANDLERS_SCHEMA).extend( + { + cv.Required(CONF_READ_ADDRESS): cv.templatable(cv.hex_uint16_t), + cv.Optional(CONF_READ_COUNT, default=1): cv.templatable( + cv.int_range(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_READ) + ), + cv.Required(CONF_WRITE_ADDRESS): cv.templatable(cv.hex_uint16_t), + cv.Required(CONF_VALUES): cv.templatable( + cv.All( + cv.ensure_list(cv.hex_uint16_t), + cv.Length(min=1, max=modbus.MAX_NUM_OF_REGISTERS_TO_WRITE_RW), + ) + ), + } + ), + _no_address_overflow(CONF_READ_COUNT, CONF_READ_ADDRESS), + _no_address_overflow(CONF_VALUES, CONF_WRITE_ADDRESS), +) + + +@automation.register_action( + "modbus_client.read_write_multiple_registers", + ReadWriteMultipleRegistersAction, + _READ_WRITE_MULTIPLE_REGISTERS_SCHEMA, + synchronous=True, +) +async def read_write_multiple_registers_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + cg.add( + var.set_read_address( + await cg.templatable(config[CONF_READ_ADDRESS], args, cg.uint16) + ) + ) + cg.add( + var.set_read_count( + await cg.templatable(config[CONF_READ_COUNT], args, cg.uint16) + ) + ) + cg.add( + var.set_write_address( + await cg.templatable(config[CONF_WRITE_ADDRESS], args, cg.uint16) + ) + ) + values = config[CONF_VALUES] + if cg.is_template(values): + templ = await cg.templatable(values, args, cg.std_vector.template(cg.uint16)) + cg.add(var.set_values_template(templ)) + else: + # A static list goes to flash, so play() sends straight from there without allocating. + arr_id = ID(f"{action_id}_values", is_declaration=True, type=cg.uint16) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*values)) + cg.add(var.set_values_static(arr, len(values))) + return await register_client_action(var, config, args, [(_REGISTER_SPAN, "values")]) diff --git a/esphome/components/modbus_client/modbus_client.h b/esphome/components/modbus_client/modbus_client.h index f9a00d65f6..20dc1a4745 100644 --- a/esphome/components/modbus_client/modbus_client.h +++ b/esphome/components/modbus_client/modbus_client.h @@ -64,7 +64,8 @@ template class ClientActionBase : public Action, public m protected: /// The hub refuses some sends at the door with no callback (a duplicate write already pending, a full /// queue, or an empty PDU - which is how the create_*_pdu() builders reject out-of-spec input). Every - /// send still gets exactly one outcome, so resolve refusals here via on_not_sent. + /// send still gets exactly one outcome (a broadcast (address 0) is the exception - never answered, it + /// resolves through on_sent() alone), so resolve refusals here via on_not_sent. /// Takes a span, not a PduBuffer: the builders return right-sized buffers (a read PDU is 5 bytes), and /// a PduBuffer parameter would widen each one to the 253-byte maximum just to cross the call. void send_or_resolve_(std::span pdu) { @@ -332,4 +333,56 @@ template class WriteMultipleCoilsAction : public TypedClientActi } values_; }; +/// modbus_client.read_write_multiple_registers (FC 0x17): writes one register block and reads another back in +/// one transaction (write first, per Modbus 6.17). on_response delivers the read-back words as `values`. +template class ReadWriteMultipleRegistersAction : public TypedClientActionBase { + public: + TEMPLATABLE_VALUE(uint16_t, read_address) + TEMPLATABLE_VALUE(uint16_t, read_count) + TEMPLATABLE_VALUE(uint16_t, write_address) + + /// Static config: the write registers live in flash, so play() neither allocates nor copies. + void set_values_static(const uint16_t *values, size_t len) { + this->values_.data = values; + this->len_ = static_cast(len); + } + /// Lambda config: the write registers are only known at play() time. + void set_values_template(std::vector (*func)(Ts...)) { + this->values_.func = func; + this->len_ = -1; // sentinel: template mode + } + + Trigger> *get_response_trigger() { return &this->response_trigger_; } + + void play(const Ts &...x) override { + const uint16_t read_start = this->read_address_.value(x...); + const uint16_t read_count = this->read_count_.value(x...); + const uint16_t write_start = this->write_address_.value(x...); + // An out-of-range read/write count builds an empty PDU (the builder logs why), resolving via on_not_sent. + if (this->len_ >= 0) { + this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( + read_start, read_count, write_start, + std::span(this->values_.data, static_cast(this->len_)))); + return; + } + const std::vector values = this->values_.func(x...); + this->send_or_resolve_(modbus::helpers::create_read_write_multiple_registers_pdu( + read_start, read_count, write_start, std::span(values))); + } + // The 0x17 response carries only the read block, so the hub dispatch delivers it as a holding-register read. + void on_read_registers(modbus::EntityType entity_type, uint16_t start_address, std::span registers, + modbus::ResponseStatus status) override { + if (modbus::succeeded(status)) + this->response_trigger_.trigger(registers); + } + + protected: + Trigger> response_trigger_; + ssize_t len_{-1}; // -1 = template mode, >= 0 = static mode with this many write registers + union Values { + std::vector (*func)(Ts...); + const uint16_t *data; + } values_; +}; + } // namespace esphome::modbus_client diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 35f21fd0af..2c568938e4 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -109,8 +109,22 @@ void ModbusCommandItem::on_not_sent(std::span request_pdu) { // Fired once per wire transmission (including hub re-queues from a retry), so the on_command_sent // trigger reflects when the frame actually went out, not when it was queued. void ModbusCommandItem::on_sent(std::span request_pdu) { - if (this->controller_ != nullptr) - this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); + if (this->controller_ == nullptr) + return; + this->controller_->command_sent(static_cast(this->function_code_), this->start_address_); + // A broadcast (address 0) is never answered (Modbus 4.1), so the hub delivers no terminal callback. + // on_sent is this command's only callback, so drop the one-shot from the queue here, or it would leak. + // Test the address the frame went to, not address_: a custom command's frame carries its own address + // (frame[0]), which may differ from this controller's. (unqueue_command() is a no-op for a poll.) + uint8_t wire_address = this->address_; + if (this->function_code_ == FunctionCode::CUSTOM) { + std::span frame = + this->custom_data_ != nullptr ? std::span(*this->custom_data_) : this->payload; + if (!frame.empty()) + wire_address = frame[0]; + } + if (wire_address == modbus::BROADCAST_ADDRESS) + this->controller_->unqueue_command(this); } bool ModbusCommandItem::on_no_response(std::span request_pdu) { diff --git a/esphome/components/modbus_server/__init__.py b/esphome/components/modbus_server/__init__.py index 14f4ca8a4d..16b956d7b5 100644 --- a/esphome/components/modbus_server/__init__.py +++ b/esphome/components/modbus_server/__init__.py @@ -12,6 +12,7 @@ from esphome.types import ConfigType from .const import ( CONF_ALLOW_PARTIAL_READ, + CONF_BITS, CONF_COURTESY_RESPONSE, CONF_READ_LAMBDA, CONF_REGISTER_LAST_ADDRESS, @@ -34,6 +35,7 @@ ModbusServer = modbus_server_ns.class_( ServerCourtesyResponse = modbus_server_ns.struct("ServerCourtesyResponse") ServerRegister = modbus_server_ns.struct("ServerRegister") +ServerBit = modbus_server_ns.class_("ServerBit") SERVER_COURTESY_RESPONSE_SCHEMA = cv.Schema( { @@ -64,6 +66,32 @@ ModbusServerRegisterSchema = cv.Schema( ) +ModbusServerBitSchema = cv.Schema( + { + cv.GenerateID(): cv.declare_id(ServerBit), + cv.Required(CONF_ADDRESS): cv.hex_uint16_t, + cv.Required(CONF_READ_LAMBDA): cv.returning_lambda, + cv.Optional(CONF_WRITE_LAMBDA): cv.returning_lambda, + } +) + + +def _validate_unique_bit_addresses(config: ConfigType) -> ConfigType: + # Coils and discrete inputs share one bit address space (like holding/input registers share the + # register table), so each bit address may appear only once. + seen: set[int] = set() + for bit in config.get(CONF_BITS, []): + address = bit[CONF_ADDRESS] + if address in seen: + raise cv.Invalid( + f"Bit address 0x{address:04X} is configured more than once; coils and discrete " + "inputs share one bit address space, so each address must be unique", + path=[CONF_BITS], + ) + seen.add(address) + return config + + def _validate_register_ranges(config: ConfigType) -> ConfigType: # Each register occupies [address, address + register_count); the whole span must fit inside the 16-bit # Modbus address space (0x0000-0xFFFF). @@ -107,10 +135,12 @@ CONFIG_SCHEMA = cv.All( cv.Optional( CONF_REGISTERS, ): cv.ensure_list(ModbusServerRegisterSchema), + cv.Optional(CONF_BITS): cv.ensure_list(ModbusServerBitSchema), } ).extend(modbus.modbus_device_schema(0x01, role="server")), _validate_register_ranges, _validate_no_overlapping_registers, + _validate_unique_bit_addresses, ) @@ -152,7 +182,7 @@ async def to_code(config): await cg.process_lambda( server_register[CONF_READ_LAMBDA], [(cg.uint16, "address")], - return_type=cpp_type, + return_type=cg.optional.template(cpp_type), ), ) ) @@ -170,5 +200,27 @@ async def to_code(config): if server_register[CONF_ALLOW_PARTIAL_READ]: cg.add(server_register_var.set_allow_partial_read(True)) cg.add(var.add_server_register(server_register_var)) + for server_bit in config.get(CONF_BITS, []): + server_bit_var = cg.new_Pvariable(server_bit[CONF_ID], server_bit[CONF_ADDRESS]) + cg.add( + server_bit_var.set_read_lambda( + await cg.process_lambda( + server_bit[CONF_READ_LAMBDA], + [(cg.uint16, "address")], + return_type=cg.optional.template(cg.bool_), + ) + ) + ) + if (write_lambda := server_bit.get(CONF_WRITE_LAMBDA)) is not None: + cg.add( + server_bit_var.set_write_lambda( + await cg.process_lambda( + write_lambda, + parameters=[(cg.uint16, "address"), (cg.bool_, "x")], + return_type=cg.bool_, + ) + ) + ) + cg.add(var.add_server_bit(server_bit_var)) await cg.register_component(var, config) return await modbus.register_modbus_server_device(var, config) diff --git a/esphome/components/modbus_server/const.py b/esphome/components/modbus_server/const.py index f2a8c53f45..86366c7ce0 100644 --- a/esphome/components/modbus_server/const.py +++ b/esphome/components/modbus_server/const.py @@ -5,4 +5,5 @@ CONF_COURTESY_RESPONSE = "courtesy_response" CONF_READ_LAMBDA = "read_lambda" CONF_WRITE_LAMBDA = "write_lambda" CONF_REGISTERS = "registers" +CONF_BITS = "bits" CONF_ALLOW_PARTIAL_READ = "allow_partial_read" diff --git a/esphome/components/modbus_server/modbus_server.cpp b/esphome/components/modbus_server/modbus_server.cpp index e63495cb25..feb0e67725 100644 --- a/esphome/components/modbus_server/modbus_server.cpp +++ b/esphome/components/modbus_server/modbus_server.cpp @@ -33,6 +33,12 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u "Received read holding/input registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%X.", this->address_, start_address, number_of_registers); + // No registers configured (e.g. a bits-only server) and no courtesy default: this device does not implement + // the register-read function, so answer ILLEGAL_FUNCTION. A populated map with a wrong address answers + // ILLEGAL_DATA_ADDRESS below. + if (this->server_registers_.empty() && !this->server_courtesy_response_.enabled) + return ExceptionCode::ILLEGAL_FUNCTION; + const uint32_t end_address = static_cast(start_address) + number_of_registers; uint32_t current_address = start_address; while (current_address < end_address) { @@ -75,7 +81,13 @@ modbus::ResponseStatus ModbusServer::on_read_registers(uint16_t start_address, u return ExceptionCode::ILLEGAL_DATA_ADDRESS; } - int64_t value = server_register->read_lambda(); + const optional read_value = server_register->read_lambda(); + if (!read_value.has_value()) { + ESP_LOGW(TAG, "Register read at 0x%04X declined to produce a value. Sending exception response.", + server_register->address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + const int64_t value = *read_value; char value_buf[ServerRegister::FORMAT_VALUE_BUF_SIZE]; ESP_LOGV(TAG, "Matched register. Address: 0x%02X. Value type: %zu. Register count: %u. Value: %s.", server_register->address, static_cast(server_register->value_type), @@ -106,6 +118,11 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, ESP_LOGV(TAG, "Received write registers for device 0x%X. Start address: 0x%X. Number of registers: 0x%zX.", this->address_, start_address, registers.size()); + // No registers configured (e.g. a bits-only server): this device does not implement the register-write + // function, so answer ILLEGAL_FUNCTION rather than ILLEGAL_DATA_ADDRESS. + if (this->server_registers_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + auto for_each_register = [this, start_address, ®isters](const std::function &callback) -> bool { @@ -167,6 +184,83 @@ modbus::ResponseStatus ModbusServer::on_write_registers(uint16_t start_address, return {}; } +ServerBit *ModbusServer::find_bit_(uint16_t address) const { + for (auto *server_bit : this->server_bits_) { + if (server_bit->address == address) { + return server_bit; + } + } + return nullptr; +} + +modbus::ResponseStatus ModbusServer::on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) { + ESP_LOGV(TAG, "Received read coils/discrete inputs for device 0x%X. Start address: 0x%X. Count: 0x%X.", + this->address_, start_address, bits.size()); + + // No bits configured: this device does not implement the coil/discrete-input function, so answer + // ILLEGAL_FUNCTION. A populated table with a wrong address answers ILLEGAL_DATA_ADDRESS below. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); // range pre-checked by the hub + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->read_lambda) { + ESP_LOGW(TAG, "No readable bit at 0x%04X. Sending exception response.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + const optional value = server_bit->read_lambda(address); + if (!value.has_value()) { + ESP_LOGW(TAG, "Bit read at 0x%04X declined to produce a value. Sending exception response.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + bits.set(i, *value); + } + return {}; +} + +modbus::ResponseStatus ModbusServer::on_write_coils(uint16_t start_address, modbus::PackedBits bits) { + ESP_LOGV(TAG, "Received write coils for device 0x%X. Start address: 0x%X. Count: 0x%X.", this->address_, + start_address, bits.size()); + + // No bits configured: this device does not implement the coil function, so answer ILLEGAL_FUNCTION rather + // than ILLEGAL_DATA_ADDRESS. + if (this->server_bits_.empty()) + return ExceptionCode::ILLEGAL_FUNCTION; + + // Pre-flight: every targeted bit must exist and be writable, so we never apply a partial write + // before discovering a problem (mirrors the register write's two passes). + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + // Only VERBOSE: one handler serves both addressed and broadcast writes, and rejecting a broadcast for + // bits this device does not map is routine. The hub logs the outcome with the context it has. + ESP_LOGV(TAG, "No writable bit at 0x%04X; write request rejected before applying any bit.", address); + return ExceptionCode::ILLEGAL_DATA_ADDRESS; + } + } + + // Commit: the pre-flight above proved every address resolves to a writable bit. Re-resolve here rather + // than caching up to MAX_NUM_OF_COILS_TO_WRITE pointers (a per-request heap allocation), matching the + // register write's two-pass shape -- but guard the pointer anyway, so a future change to the pre-flight + // can never turn this into a silent null dereference. The only expected failure is a write callback + // rejecting the value at runtime, which cannot be rolled back. + for (uint16_t i = 0; i < bits.size(); i++) { + const uint16_t address = static_cast(start_address + i); + ServerBit *server_bit = this->find_bit_(address); + if (server_bit == nullptr || !server_bit->write_lambda) { + ESP_LOGE(TAG, "Bit at 0x%04X unresolved between pre-flight and commit; aborting write.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + if (!server_bit->write_lambda(address, bits[i])) { + ESP_LOGW(TAG, "Bit write callback failed at 0x%04X mid-sequence; earlier writes were already applied.", address); + return ExceptionCode::SERVICE_DEVICE_FAILURE; + } + } + return {}; +} + void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, "ModbusServer:\n" @@ -184,6 +278,11 @@ void ModbusServer::dump_config() { ESP_LOGCONFIG(TAG, " Address=0x%02X value_type=%u register_count=%u", r->address, static_cast(r->value_type), r->register_count); } + ESP_LOGCONFIG(TAG, "server bits"); + for (auto &b : this->server_bits_) { + ESP_LOGCONFIG(TAG, " Address=0x%04X readable=%s writable=%s", b->address, b->read_lambda ? "true" : "false", + b->write_lambda ? "true" : "false"); + } #endif } diff --git a/esphome/components/modbus_server/modbus_server.h b/esphome/components/modbus_server/modbus_server.h index f6484d8e6b..22903abfad 100644 --- a/esphome/components/modbus_server/modbus_server.h +++ b/esphome/components/modbus_server/modbus_server.h @@ -20,7 +20,7 @@ struct ServerCourtesyResponse { }; class ServerRegister { - using ReadLambda = std::function; + using ReadLambda = std::function()>; using WriteLambda = std::function; public: @@ -30,13 +30,18 @@ class ServerRegister { this->register_count = register_count; } - template void set_read_lambda(const std::function &&user_read_lambda) { - this->read_lambda = [this, user_read_lambda]() -> int64_t { - T user_value = user_read_lambda(this->address); + /// The user lambda returns optional: an empty optional declines the read, answering the whole + /// request with a SERVICE_DEVICE_FAILURE exception. Plain values convert implicitly. + template void set_read_lambda(const std::function(uint16_t address)> &&user_read_lambda) { + this->read_lambda = [this, user_read_lambda]() -> optional { + const optional user_value = user_read_lambda(this->address); + if (!user_value.has_value()) { + return {}; + } if constexpr (std::is_same_v) { - return bit_cast(user_value); + return bit_cast(*user_value); } else { - return static_cast(user_value); + return static_cast(*user_value); } }; } @@ -97,17 +102,43 @@ class ServerRegister { WriteLambda write_lambda; }; +/// A single bit in the server's coil/discrete-input table. Coils (0x01/0x05/0x0F) and discrete +/// inputs (0x02) share one bit address space, mirroring how holding and input registers share the +/// register table: both read function codes are served from the same bits. +class ServerBit { + /// Returning an empty optional declines the read: the whole request is answered with a + /// SERVICE_DEVICE_FAILURE exception. `return true;`/`return false;` convert implicitly. + using ReadLambda = std::function(uint16_t address)>; + using WriteLambda = std::function; + + public: + explicit ServerBit(uint16_t address) : address(address) {} + void set_read_lambda(ReadLambda &&read_lambda) { this->read_lambda = std::move(read_lambda); } + void set_write_lambda(WriteLambda &&write_lambda) { this->write_lambda = std::move(write_lambda); } + + uint16_t address{0}; + ReadLambda read_lambda; + WriteLambda write_lambda; +}; + class ModbusServer final : public Component, public modbus::ModbusServerDevice { public: void dump_config() override; /// Registers a server register with the controller. Called by esphomes code generator void add_server_register(ServerRegister *server_register) { server_registers_.push_back(server_register); } + /// Registers a server bit with the controller. Called by esphomes code generator + void add_server_bit(ServerBit *server_bit) { server_bits_.push_back(server_bit); } /// called when a modbus request (function code 0x03 or 0x04) was parsed without errors modbus::ResponseStatus on_read_registers(uint16_t start_address, uint16_t number_of_registers, modbus::RegisterValues ®isters) final; /// called when a modbus request (function code 0x06 or 0x10) was parsed without errors modbus::ResponseStatus on_write_registers(uint16_t start_address, const modbus::RegisterValues ®isters) final; + /// called when a modbus request (function code 0x01 or 0x02) was parsed without errors; both are + /// served from the same bit table (see ServerBit) + modbus::ResponseStatus on_read_bits(uint16_t start_address, modbus::MutablePackedBits bits) final; + /// called when a modbus request (function code 0x05 or 0x0F) was parsed without errors + modbus::ResponseStatus on_write_coils(uint16_t start_address, modbus::PackedBits bits) final; /// Called by esphome generated code to set the server courtesy response object void set_server_courtesy_response(const ServerCourtesyResponse &server_courtesy_response) { this->server_courtesy_response_ = server_courtesy_response; @@ -118,8 +149,12 @@ class ModbusServer final : public Component, public modbus::ModbusServerDevice { protected: /// Find the registered value whose register span contains address, or nullptr if none does. ServerRegister *find_containing_register_(uint32_t address) const; + /// Find the registered bit at address, or nullptr if none is. + ServerBit *find_bit_(uint16_t address) const; /// Collection of all server registers for this component std::vector server_registers_{}; + /// Collection of all server bits (coils/discrete inputs) for this component + std::vector server_bits_{}; /// Server courtesy response ServerCourtesyResponse server_courtesy_response_{ .enabled = false, .register_last_address = 0xFFFF, .register_value = 0}; diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 24e9aa45e1..3544fb2647 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -39,6 +39,12 @@ KEY_NETWORK_PRIORITY = "network_priority" # NETWORK_PLAN.md for the full multi-interface roadmap. VALID_NETWORK_TYPES = ["ethernet", "wifi"] +# Interfaces NetworkComponent::loop() knows how to arbitrate the default route +# for. Deliberately NOT derived from VALID_NETWORK_TYPES: extending that list +# without extending the C++ arbitration (and then this set) is caught in +# _final_validate() as a config error instead of a silently mis-routed interface. +ARBITRATED_NETWORK_TYPES = frozenset({"ethernet", "wifi"}) + # Setup priority base values — first in list gets the highest priority. # # The base equals the historical setup_priority::WIFI / ::ETHERNET default @@ -310,7 +316,8 @@ CONFIG_SCHEMA = cv.All( def _final_validate(config: ConfigType) -> None: """Check that every interface named in 'priority' has a corresponding component block.""" full = fv.full_config.get() - for entry in config.get(CONF_PRIORITY, []): + priority_list = config.get(CONF_PRIORITY, []) + for entry in priority_list: iface = entry["interface"] if iface not in full: raise cv.Invalid( @@ -319,6 +326,24 @@ def _final_validate(config: ConfigType) -> None: [CONF_PRIORITY], ) + # Tripwire for future interface types (openthread, modem): the C++ default-route + # arbitration pivots on USE_NETWORK_PRIMARY_INTERFACE_WIFI and only knows + # ethernet and wifi. Extend NetworkComponent::loop() before allowing another + # type here. Unreachable until VALID_NETWORK_TYPES grows. + if ( + len(priority_list) > 1 + and ( + unsupported := {e["interface"] for e in priority_list} + - ARBITRATED_NETWORK_TYPES + ) + and CORE.is_esp32 + ): + raise cv.Invalid( + "Default-route arbitration does not support: " + f"{', '.join(sorted(unsupported))}", + [CONF_PRIORITY], + ) + FINAL_VALIDATE_SCHEMA = _final_validate @@ -337,10 +362,22 @@ async def to_code(config): # network/util.cpp resolves the reported address (get_use_address_to, # get_ip_addresses) in a fixed ethernet-first order; a wifi-first priority # list is the only case that deviates from it, so it is the only case that - # needs a define. Runtime (active-interface) selection is a planned follow-up. + # needs a define. if priority_list[0]["interface"] == "wifi": cg.add_define("USE_NETWORK_PRIMARY_INTERFACE_WIFI") + # With more than one interface, NetworkComponent::loop() arbitrates the + # default route (ESP-IDF's fixed route_prio values would always favor + # WiFi). ESP32 only: the arbitration needs esp_netif, which both + # frameworks build from source. + # The ethernet/wifi-only assumption behind the arbitration is enforced in + # _final_validate() so a future unsupported type fails as a config error. + if len(priority_list) > 1 and CORE.is_esp32: + cg.add_define("USE_NETWORK_DEFAULT_ROUTE") + # Have lwIP switch to the DNS servers of the netif that owns the + # default route whenever the arbitration changes it. + add_idf_sdkconfig_option("CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF", True) + _LOGGER.info( "Network interface priority: %s", " > ".join(entry["interface"] for entry in priority_list), diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index 40cf64906c..cf457bb661 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -6,6 +6,20 @@ #include "esp_err.h" #include "esp_netif.h" #include "esp_event.h" + +#ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esp_netif_net_stack.h" +#include "lwip/netif.h" +#ifdef USE_ETHERNET +#include "esphome/components/ethernet/ethernet_component.h" +#endif +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif +#endif + namespace esphome::network { static const char *const TAG = "network"; @@ -29,5 +43,81 @@ void NetworkComponent::setup() { } } +#ifdef USE_NETWORK_DEFAULT_ROUTE +static esp_netif_t *connected_wifi_netif() { +#ifdef USE_WIFI + auto *wifi = wifi::global_wifi_component; + if (wifi != nullptr && wifi->is_connected()) + return wifi->get_esp_netif_sta(); +#endif + return nullptr; +} + +static esp_netif_t *connected_ethernet_netif() { +#ifdef USE_ETHERNET + auto *eth = ethernet::global_eth_component; + if (eth != nullptr && eth->is_connected()) + return eth->get_esp_netif(); +#endif + return nullptr; +} + +void NetworkComponent::loop() { + // Pin the default route to the first connected interface in the user's priority + // order; ESP-IDF's own route_prio selection would always favor WiFi. + // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; + // it selects the reported address in util.cpp and doubles as the route-order + // pivot here — the two uses must stay in sync. + esp_netif_t *best; +#ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI + best = connected_wifi_netif(); + if (best == nullptr) + best = connected_ethernet_netif(); +#else + best = connected_ethernet_netif(); + if (best == nullptr) + best = connected_wifi_netif(); +#endif + if (best == nullptr) { + // Forget the last winner: stopping its netif cleared lwIP's default route and + // IDF's manual override suppresses re-election, so reconnect must re-assert it. + this->default_netif_ = nullptr; + return; + } + if (best == this->default_netif_) { + // Same winner as the last assert. Still re-assert if lwIP's default route is + // not the winner's netif: a winner whose netif bounced down and up between two + // polls would otherwise stay routeless (stopping a netif nulls lwIP's + // netif_default). Checking lwIP directly keeps this independent of IDF's + // re-election bookkeeping (esp_netif_get_default_netif() cannot detect it). + // Throttled: LwIPLock is the global lwIP core mutex, and this branch runs on + // every pass once the route has settled. + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_route_check_ < ROUTE_CHECK_INTERVAL_MS) + return; + this->last_route_check_ = now; + bool route_is_ours; + { + LwIPLock lock; + route_is_ours = static_cast(netif_default) == esp_netif_get_netif_impl(best); + } + if (route_is_ours) + return; + } + esp_err_t err = esp_netif_set_default_netif(best); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to set default interface: (%d) %s", err, esp_err_to_name(err)); + // Cache the intent anyway: subsequent passes take the same-winner branch + // above, so retries are throttled to ROUTE_CHECK_INTERVAL_MS and the lwIP + // verification keeps re-attempting until the route is actually ours. + this->default_netif_ = best; + this->last_route_check_ = App.get_loop_component_start_time(); + return; + } + this->default_netif_ = best; + ESP_LOGI(TAG, "Default interface: %s", esp_netif_get_desc(best)); +} +#endif // USE_NETWORK_DEFAULT_ROUTE + } // namespace esphome::network #endif diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 2e76a95673..8d4866d4f0 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -3,12 +3,30 @@ #if defined(USE_NETWORK) && defined(USE_ESP32) #include "esphome/core/component.h" +#ifdef USE_NETWORK_DEFAULT_ROUTE +// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h +// into this header. +using esp_netif_t = struct esp_netif_obj; +#endif + namespace esphome::network { class NetworkComponent final : public Component { public: void setup() override; // AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance. float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; } + +#ifdef USE_NETWORK_DEFAULT_ROUTE + void loop() override; + + protected: + // Verify-lwIP-route interval for the settled state; keeps the global lwIP core + // mutex off the hot loop path. + static constexpr uint32_t ROUTE_CHECK_INTERVAL_MS = 1000; + // Last netif this component made the default; avoids redundant esp_netif calls. + esp_netif_t *default_netif_{nullptr}; + uint32_t last_route_check_{0}; +#endif }; } // namespace esphome::network #endif diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index d90c28801e..11485fdcf0 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -10,16 +10,33 @@ namespace esphome::network { // an AP that uses a previous interface for NAT). bool is_disabled() { + // The network is disabled only when every configured interface with a + // disable() lifecycle is disabled; one enabled interface means traffic can flow. + bool disabled = false; #ifdef USE_MODEM - if (modem::global_modem_component != nullptr) - return modem::global_modem_component->is_disabled(); + if (modem::global_modem_component != nullptr) { + if (!modem::global_modem_component->is_disabled()) + return false; + disabled = true; + } #endif #ifdef USE_WIFI - if (wifi::global_wifi_component != nullptr) - return wifi::global_wifi_component->is_disabled(); + if (wifi::global_wifi_component != nullptr) { + if (!wifi::global_wifi_component->is_disabled()) + return false; + disabled = true; + } #endif - return false; + +#ifdef USE_ETHERNET + if (ethernet::global_eth_component != nullptr) { + if (!ethernet::global_eth_component->is_disabled()) + return false; + disabled = true; + } +#endif + return disabled; } const char *get_use_address_to(std::span buf) { diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index df7e164bda..65a578c22f 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -52,7 +52,8 @@ ESPHOME_ALWAYS_INLINE inline bool is_connected() { return false; } -/// Return whether the network is disabled (only wifi for now) +/// Return whether the network is disabled: every configured interface with a +/// disable() lifecycle (modem, wifi, ethernet) is disabled. bool is_disabled(); /// Buffer size for get_use_address_to(): 63-char DNS label + ".local" + null terminator static constexpr size_t USE_ADDRESS_BUFFER_SIZE = 70; diff --git a/esphome/components/rp2/__init__.py b/esphome/components/rp2/__init__.py index 1bf01e6828..87e78003ed 100644 --- a/esphome/components/rp2/__init__.py +++ b/esphome/components/rp2/__init__.py @@ -351,6 +351,11 @@ async def to_code(config): ], ) + # newlib-nano is the default libc for the arduino-pico toolchain and its + # printf silently drops %f unless _printf_float is force-linked. Components + # use %f widely in logging, so pull it in. + cg.add_build_flag("-Wl,-u,_printf_float") + # Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r # (~9.2 KB). See printf_stubs.cpp for implementation. if config.get(CONF_ENABLE_FULL_PRINTF): @@ -383,6 +388,74 @@ async def to_code(config): _configure_lwip() +# --- lwIP sizing. See _configure_lwip() for the platform comparison table. --- + +# TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. +# ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. +LWIP_TCP_SND_BUF = "(4*TCP_MSS)" + +# TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. +LWIP_TCP_WND = "(4*TCP_MSS)" + +# TCP_SND_QUEUELEN: max pbufs queued per PCB for the send buffer +# ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS +# With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 +LWIP_TCP_SND_QUEUELEN = 17 + +# MEMP_NUM_TCP_SEG: pool shared by every PCB, so it must not be the per-PCB +# queue length — lwIP's sanity check only demands >=, the floor for a single +# connection. 2× lets two PCBs fill up before the rest see ERR_MEM. Measured +# at 20 bytes per entry, so under 700 bytes total. +LWIP_MEMP_NUM_TCP_SEG = 2 * LWIP_TCP_SND_QUEUELEN + +# PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. +# 16 matches ESP32 (vs arduino-pico's 24). Receive side only; the send path +# copies into PBUF_RAM out of MEM_SIZE. +LWIP_PBUF_POOL_SIZE = 16 + +# MEM_SIZE: lwIP heap backing PBUF_RAM, where tcp_write() copies outgoing +# data. TCP_OVERSIZE defaults to TCP_MSS, so each queued segment takes a full +# MSS block whatever was written (pbuf 16 + PBUF_TRANSPORT 54 + MSS 1460 + +# block header ≈ 1.5KB); a PCB at a full TCP_SND_BUF holds four, ~6KB. +# +# Two of those is ~12KB of arduino-pico's 16KB heap and already fails: mem.c +# is first-fit, so a *contiguous* 1.5KB block must be free, and at 75% +# occupancy interleaved with ARP/DHCP/DNS/mDNS the largest run collapses well +# before the total does — hence the intermittent failures. With rp2's +# max_connections of 4, a third sender has nothing left. +# +# 32KB is arduino-pico's own next tier (__LWIP_MEMMULT=2 boards). +# Must stay under 64000 or lwIP widens mem_size_t to u32_t. +LWIP_MEM_SIZE = 32768 + + +def build_lwip_defines( + tcp_sockets: int, udp_sockets: int, listening_tcp: int +) -> dict[str, str]: + """Render the lwIP override values for the Jinja2 template. + + The template uses #include_next to chain to the framework's original + lwipopts.h, then #undef/#define only these. Split out from + _configure_lwip() so the values that actually reach the generated header + can be checked without standing up CORE. + + Both malloc flags stay 0 (framework defaults); see _configure_lwip(). The + static pools are the only IRQ-safe allocator on this platform, so the fix + is to size them correctly rather than to make them dynamic. + """ + return { + "TCP_SND_BUF": LWIP_TCP_SND_BUF, + "TCP_WND": LWIP_TCP_WND, + "TCP_SND_QUEUELEN": str(LWIP_TCP_SND_QUEUELEN), + "MEM_SIZE": str(LWIP_MEM_SIZE), + "MEMP_NUM_TCP_SEG": str(LWIP_MEMP_NUM_TCP_SEG), + "PBUF_POOL_SIZE": str(LWIP_PBUF_POOL_SIZE), + "MEMP_NUM_TCP_PCB": str(tcp_sockets), + "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), + "MEMP_NUM_UDP_PCB": str(udp_sockets), + } + + def _configure_lwip() -> None: """Configure lwIP options for RP2040 by generating a custom lwipopts.h. @@ -402,25 +475,36 @@ def _configure_lwip() -> None: ──────────────────────────────────────────────────────────────── TCP_SND_BUF 2×MSS 4×MSS 8×MSS 4×MSS TCP_WND 4×MSS 4×MSS 8×MSS 4×MSS + TCP_SND_QUEUELEN ~8 17 32 17 MEM_LIBC_MALLOC 1 1 0 0* MEMP_MEM_MALLOC 1 1 0 0** - MEM_SIZE N/A*** N/A*** 16KB 16KB + MEM_SIZE N/A*** N/A*** 16KB 32KB PBUF_POOL_SIZE 10 16 24 16 - MEMP_NUM_TCP_SEG 10 16 32 17 + MEMP_NUM_TCP_SEG 10 16 32 34**** MEMP_NUM_TCP_PCB 5 16 5 dynamic - MEMP_NUM_TCP_PCB_LISTEN 4 16 8**** dynamic + MEMP_NUM_TCP_PCB_LISTEN 4 16 8***** dynamic MEMP_NUM_UDP_PCB 4 16 7 dynamic - TCP_SND_QUEUELEN ~8 17 32 17 * MEM_LIBC_MALLOC must stay 0: arduino-pico uses PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from a low-priority pendsv IRQ. The pico-sdk explicitly blocks MEM_LIBC_MALLOC=1 because libc malloc uses mutexes (unsafe in IRQ). - ** MEMP_MEM_MALLOC must stay 0: the dedicated lwIP heap (MEM_SIZE=16KB) - is too small to hold all pools dynamically. The PBUF_POOL alone needs - ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate BSS savings. - *** ESP8266/ESP32 use MEM_LIBC_MALLOC=1 (system heap, no dedicated pool). - **** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. + ** MEMP_MEM_MALLOC must stay 0 for IRQ safety, not size. memp_malloc() + pops the pool free list inside SYS_ARCH_PROTECT, but lwIP's heap takes + its protection from LWIP_ALLOW_MEM_FREE_FROM_OTHER_CONTEXT (default 0), + so under NO_SYS=1 mem_malloc()/mem_free() are unprotected — and memp.c + calls mem_malloc() outside the guard anyway. RX pbufs would then be + allocated from the pendsv IRQ on the same unguarded free list the main + loop uses for tcp_write(). Tried on hardware: faults within seconds on + CYW43. Ethernet survives only because it polls from the main loop. + *** ESP8266/ESP32 ship MEMP_MEM_MALLOC=1, so their pool entries come from + the heap on demand and MEMP_NUM_*/PBUF_POOL_SIZE are labels, not caps + (MEM_LIBC_MALLOC=1 points that heap at the system heap). Both flags are + 0 here, so ours are hard limits; don't copy their numbers. + **** MEMP_NUM_TCP_SEG is *global* while TCP_SND_QUEUELEN is *per-PCB*, so + sizing it to the per-PCB value lets one busy connection drain it for + every other. 2× covers two PCBs; MEM_SIZE is the real limit past that. + ***** opt.h default; arduino-pico doesn't override MEMP_NUM_TCP_PCB_LISTEN. "dynamic" = auto-calculated from component socket registrations via socket.get_socket_counts() with minimums of 8 TCP / 6 UDP / 2 TCP_LISTEN. """ @@ -439,48 +523,7 @@ def _configure_lwip() -> None: # UDP PCBs (2) are absorbed by the generous minimum of 6. listening_tcp = max(MIN_TCP_LISTEN_SOCKETS, sc.tcp_listen) - # TCP_SND_BUF: 4×MSS=5,840 matches ESP32. Down from arduino-pico's 8×MSS. - # ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per response chunk. - tcp_snd_buf = "(4*TCP_MSS)" - - # TCP_WND: receive window. 4×MSS matches ESP32. Down from arduino-pico's 8×MSS. - tcp_wnd = "(4*TCP_MSS)" - - # TCP_SND_QUEUELEN: max pbufs queued for send buffer - # ESP-IDF formula: (4 * TCP_SND_BUF + (TCP_MSS - 1)) / TCP_MSS - # With 4×MSS: (4*5840 + 1459) / 1460 = 17 — match ESP32 - tcp_snd_queuelen = 17 - # MEMP_NUM_TCP_SEG: segment pool, must be >= TCP_SND_QUEUELEN (lwIP sanity check) - memp_num_tcp_seg = tcp_snd_queuelen - - # PBUF_POOL_SIZE: RP2040 has 264KB RAM, more generous than LibreTiny. - # 16 matches ESP32 (vs arduino-pico's 24). With MEMP_MEM_MALLOC=1, - # this is a max count (allocated on demand from heap). - pbuf_pool_size = 16 - - # Build the lwIP override defines for the Jinja2 template. - # The template uses #include_next to chain to the framework's original - # lwipopts.h, then #undef/#define only the values we need to change. - # - # Note: MEMP_MEM_MALLOC stays 0 (framework default). While the memp - # allocations use the dedicated lwIP heap (IRQ-safe), the 16KB MEM_SIZE - # is too small to hold all pools dynamically under stress. The PBUF_POOL - # alone needs ~24KB (16 × 1524 bytes). Increasing MEM_SIZE would negate - # the BSS savings. - # - # MEM_LIBC_MALLOC stays 0 (framework default): arduino-pico uses - # PICO_CYW43_ARCH_THREADSAFE_BACKGROUND which runs lwIP callbacks from - # a low-priority pendsv IRQ where libc malloc (mutex-based) is unsafe. - lwip_defines: dict[str, str] = { - "TCP_SND_BUF": tcp_snd_buf, - "TCP_WND": tcp_wnd, - "TCP_SND_QUEUELEN": str(tcp_snd_queuelen), - "MEMP_NUM_TCP_SEG": str(memp_num_tcp_seg), - "PBUF_POOL_SIZE": str(pbuf_pool_size), - "MEMP_NUM_TCP_PCB": str(tcp_sockets), - "MEMP_NUM_TCP_PCB_LISTEN": str(listening_tcp), - "MEMP_NUM_UDP_PCB": str(udp_sockets), - } + lwip_defines = build_lwip_defines(tcp_sockets, udp_sockets, listening_tcp) # Store for copy_files() to generate the header CORE.data[KEY_RP2][KEY_LWIP_OPTS] = lwip_defines @@ -495,7 +538,8 @@ def _configure_lwip() -> None: udp_min = " (min)" if udp_sockets > sc.udp else "" listen_min = " (min)" if listening_tcp > sc.tcp_listen else "" _LOGGER.info( - "Configuring lwIP: TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + "Configuring lwIP: %d byte heap; TCP=%d%s [%s], UDP=%d%s [%s], TCP_LISTEN=%d%s [%s]", + LWIP_MEM_SIZE, tcp_sockets, tcp_min, sc.tcp_details, @@ -516,7 +560,7 @@ def _generate_lwipopts_h() -> None: in the build directory, and a pre-build script injects this directory into the compiler include path before the framework's own include dir. """ - from jinja2 import Environment + from jinja2 import Environment, StrictUndefined lwip_defines = CORE.data[KEY_RP2].get(KEY_LWIP_OPTS) if not lwip_defines: @@ -529,7 +573,10 @@ def _generate_lwipopts_h() -> None: template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text( encoding="utf-8" ) - jinja_env = Environment(keep_trailing_newline=True) + # StrictUndefined: a placeholder with no value would otherwise render + # empty, emitting a bare #define that compiles and silently means + # something else in lwIP's config. + jinja_env = Environment(keep_trailing_newline=True, undefined=StrictUndefined) template = jinja_env.from_string(template_text) content = template.render(**lwip_defines) diff --git a/esphome/components/rp2/boards.jinja2 b/esphome/components/rp2/boards.jinja2 index 9223009c26..6e5e55d771 100644 --- a/esphome/components/rp2/boards.jinja2 +++ b/esphome/components/rp2/boards.jinja2 @@ -14,6 +14,10 @@ RP2_BOARD_PINS = { {%- endfor %} } +# RP2350 boards carry a {{ rp2350_die_key | repr }} key holding the die letter: +# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B +# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu +# choice and so is not known here. The key is absent on non-RP2350 boards. BOARDS = { {%- for name, info in boards %} {{ name | repr }}: { diff --git a/esphome/components/rp2/boards.py b/esphome/components/rp2/boards.py index d2502b8fb8..4b2f9769b0 100644 --- a/esphome/components/rp2/boards.py +++ b/esphome/components/rp2/boards.py @@ -1533,6 +1533,10 @@ RP2_BOARD_PINS = { }, } +# RP2350 boards carry a 'die' key holding the die letter: +# 'A' for the RP2350A (GPIO 0-29, 5 ADC channels), 'B' for the RP2350B +# (GPIO 0-47, 9 ADC channels), and None when the die is a build-time menu +# choice and so is not known here. The key is absent on non-RP2350 boards. BOARDS = { "0xcb_helios": { "name": "0xCB Helios", @@ -1548,6 +1552,7 @@ BOARDS = { "name": "MyMakers RP2350B", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "MyRP_bot": { "name": "MyMakers RP2040", @@ -1588,11 +1593,13 @@ BOARDS = { "name": "Adafruit Feather RP2350 Adalogger", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "adafruit_feather_rp2350_hstx": { "name": "Adafruit Feather RP2350 HSTX", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "adafruit_feather_scorpio": { "name": "Adafruit Feather RP2040 SCORPIO", @@ -1618,6 +1625,7 @@ BOARDS = { "name": "Adafruit Fruit Jam RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "adafruit_itsybitsy": { "name": "Adafruit ItsyBitsy RP2040", @@ -1643,6 +1651,7 @@ BOARDS = { "name": "Adafruit Metro RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "adafruit_qtpy": { "name": "Adafruit QT Py RP2040", @@ -1763,16 +1772,19 @@ BOARDS = { "name": "iLabs Challenger 2350 BConnect", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_2350_nbiot": { "name": "iLabs Challenger 2350 NB-IoT", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_2350_wifi6_ble5": { "name": "iLabs Challenger 2350 WiFi/BLE", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "challenger_nb_2040_wifi": { "name": "iLabs Challenger NB 2040 WiFi", @@ -1788,6 +1800,7 @@ BOARDS = { "name": "Cytron IRIV IO Controller", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "cytron_maker_nano_rp2040": { "name": "Cytron Maker Nano RP2040", @@ -1808,6 +1821,7 @@ BOARDS = { "name": "Cytron Motion 2350 Pro", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "datanoisetv_picoadk": { "name": "DatanoiseTV PicoADK", @@ -1818,6 +1832,7 @@ BOARDS = { "name": "DatanoiseTV PicoADK v2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "degz_suibo": { "name": "Degz Robotics Suibo RP2040", @@ -1863,6 +1878,7 @@ BOARDS = { "name": "Generic RP2350", "mcu": "rp2350", "max_pin": 47, + "die": None, }, "groundstudio_marble_pico": { "name": "GroundStudio Marble Pico", @@ -1873,6 +1889,7 @@ BOARDS = { "name": "iLabs CPico 2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "ilabs_rpico32": { "name": "iLabs RPICO32", @@ -1888,6 +1905,7 @@ BOARDS = { "name": "Architeuthis Flux Jumperless V5", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "melopero_cookie_rp2040": { "name": "Melopero Cookie RP2040", @@ -1928,16 +1946,19 @@ BOARDS = { "name": "Olimex Pico2BB48", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_pico2xl": { "name": "Olimex Pico2XL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_pico2xxl": { "name": "Olimex Pico2XXL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "olimex_rp2040pico30": { "name": "Olimex RP2040-Pico30", @@ -1963,6 +1984,7 @@ BOARDS = { "name": "Pimoroni Explorer", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pga2040": { "name": "Pimoroni PGA2040", @@ -1973,16 +1995,19 @@ BOARDS = { "name": "Pimoroni PGA2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pico_plus_2": { "name": "Pimoroni PicoPlus2", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "pimoroni_pico_plus_2w": { "name": "Pimoroni PicoPlus2W", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, "max_virtual_pin": 64, }, @@ -1995,11 +2020,13 @@ BOARDS = { "name": "Pimoroni Plasma2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "pimoroni_plasma2350w": { "name": "Pimoroni Plasma2350W", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, }, "pimoroni_servo2040": { @@ -2016,6 +2043,7 @@ BOARDS = { "name": "Pimoroni Tiny2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "pintronix_pinmax": { "name": "Pintronix PinMax", @@ -2046,11 +2074,13 @@ BOARDS = { "name": "Raspberry Pi Pico 2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "rpipico2w": { "name": "Raspberry Pi Pico 2W", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, "max_virtual_pin": 64, }, @@ -2085,6 +2115,7 @@ BOARDS = { "name": "Seeed XIAO RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "silicognition_rp2040_shim": { "name": "Silicognition RP2040-Shim", @@ -2100,6 +2131,7 @@ BOARDS = { "name": "Soldered Electronics NULA RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "solderparty_rp2040_stamp": { @@ -2111,21 +2143,25 @@ BOARDS = { "name": "Solder Party RP2350 Stamp", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "solderparty_rp2350_stamp_xl": { "name": "Solder Party RP2350 Stamp XL", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "sparkfun_iotnode_lorawanrp2350": { "name": "SparkFun IoT Node LoRaWAN", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "sparkfun_iotredboard_rp2350": { "name": "SparkFun IoT RedBoard RP2350", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "sparkfun_micromodrp2040": { @@ -2142,6 +2178,7 @@ BOARDS = { "name": "SparkFun ProMicro RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "sparkfun_thingplusrp2040": { "name": "SparkFun Thing Plus RP2040", @@ -2152,6 +2189,7 @@ BOARDS = { "name": "SparkFun Thing Plus RP2350", "mcu": "rp2350", "max_pin": 29, + "die": "A", "wifi": True, "max_virtual_pin": 64, }, @@ -2159,6 +2197,7 @@ BOARDS = { "name": "SparkFun XRP Controller", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, "max_virtual_pin": 64, }, @@ -2233,32 +2272,38 @@ BOARDS = { "name": "Waveshare RP2350 LCD 0.96", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350_pizero": { "name": "Waveshare RP2350 PiZero", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "waveshare_rp2350_plus": { "name": "Waveshare RP2350 Plus", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350_zero": { "name": "Waveshare RP2350 Zero", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "waveshare_rp2350b_plus_w": { "name": "Waveshare RP2350B Plus W", "mcu": "rp2350", "max_pin": 47, + "die": "B", "wifi": True, }, "weact_rp2350b": { "name": "WeAct Studio RP2350B Core Board", "mcu": "rp2350", "max_pin": 47, + "die": "B", }, "wiznet_5100s_evb_pico": { "name": "WIZnet W5100S-EVB-Pico", @@ -2269,6 +2314,7 @@ BOARDS = { "name": "WIZnet W5100S-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_5500_evb_pico": { "name": "WIZnet W5500-EVB-Pico", @@ -2279,6 +2325,7 @@ BOARDS = { "name": "WIZnet W5500-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_55rp20_evb_pico": { "name": "WIZnet W55RP20-EVB-Pico", @@ -2294,6 +2341,7 @@ BOARDS = { "name": "WIZnet W6300-EVB-Pico2", "mcu": "rp2350", "max_pin": 29, + "die": "A", }, "wiznet_wizfi360_evb_pico": { "name": "WIZnet WizFi360-EVB-Pico", diff --git a/esphome/components/rp2/generate_boards.py b/esphome/components/rp2/generate_boards.py index 5618287cce..cd3f50182c 100644 --- a/esphome/components/rp2/generate_boards.py +++ b/esphome/components/rp2/generate_boards.py @@ -37,13 +37,23 @@ MCU_MAX_PIN = { "rp2350": 47, # GPIO 0-47 (RP2350B; A-die boards are narrowed to 29 below) } DEFAULT_MAX_PIN = 29 -# The RP2350 comes in two die variants: RP2350A exposes GPIO 0-29, RP2350B -# GPIO 0-47. Variant headers declare the die via PICO_RP2350A (1 = A, 0 = B). +# The RP2350 currently comes in two die variants: RP2350A exposes GPIO 0-29, +# RP2350B GPIO 0-47. Variant headers declare the die via PICO_RP2350A +# (1 = A, 0 = B). +RP2350_DIE_A = "A" +RP2350_DIE_B = "B" RP2350A_MAX_PIN = 29 +# Key recording the die letter on RP2350 board entries. Holds a letter rather +# than a bool so a future die can be named instead of forced into "not A". +RP2350_DIE_KEY = "die" PIN_DEFINE_RE = re.compile(r"#define\s+PIN_(\w+)\s+\((\d+)u\)") # Accepts the literal forms seen in these headers: 1, (1), 1u, (1u) RP2350A_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350A\s+(\S+)") +# Only PICO_RP2350A exists today. A define for any other die letter means the +# A/B assumption below no longer holds. The trailing \b keeps this from +# matching unrelated names such as PICO_RP2350_A2_SUPPORTED. +OTHER_DIE_DEFINE_RE = re.compile(r"#define\s+PICO_RP2350(?!A\b)([B-Z])\b") RP2350A_MENU_PLACEHOLDER = "__PICO_RP2350A" @@ -62,23 +72,30 @@ def parse_variant_pins(variant_dir: Path) -> dict[str, int]: return pins -def parse_variant_is_rp2350a(variant_dir: Path) -> bool: - """Return True if the variant declares an RP2350A die (GPIO 0-29 only). +def parse_variant_rp2350_die(variant_dir: Path) -> str | None: + """Return the RP2350 die letter the variant declares, or None if unknown. Generic boards leave the die a build-time menu choice (PICO_RP2350A is set - to a __PICO_RP2350A placeholder rather than a literal); those return False - so they keep the permissive B-die pin range. + to a __PICO_RP2350A placeholder rather than a literal); those return None, + meaning the die is genuinely unknown at code generation time. They keep the + permissive B-die pin range, but that is a fallback and must not be recorded + as a known die. A missing or unrecognized define raises: silently treating it as B-die would widen pin validation back to GPIO 47 on A-die boards, so a framework - bump that changes the header format must fail loudly here instead. + bump that changes the header format must fail loudly here instead. The same + goes for a die beyond A and B: PICO_RP2350A is a yes/no answer about the A + die, so "not A" can only be read as B while A and B are the whole family. """ header = variant_dir / "pins_arduino.h" - match = ( - RP2350A_DEFINE_RE.search(header.read_text(encoding="utf-8")) - if header.exists() - else None - ) + text = header.read_text(encoding="utf-8") if header.exists() else "" + if other_die := OTHER_DIE_DEFINE_RE.search(text): + raise ValueError( + f"{header}: found a PICO_RP2350{other_die.group(1)} define; the " + "RP2350 gained a die beyond A and B, so PICO_RP2350A being 0 no " + "longer means the B die" + ) + match = RP2350A_DEFINE_RE.search(text) if match is None: raise ValueError( f"{header}: no PICO_RP2350A define found; cannot classify the " @@ -86,14 +103,14 @@ def parse_variant_is_rp2350a(variant_dir: Path) -> bool: ) value = match.group(1) if value == RP2350A_MENU_PLACEHOLDER: - return False + return None literal = value.strip("()u") if not literal.isdigit(): raise ValueError( f"{header}: unrecognized PICO_RP2350A value {value!r}; cannot " "classify the RP2350 die (A exposes GPIO 0-29, B exposes GPIO 0-47)" ) - return int(literal) == 1 + return RP2350_DIE_A if int(literal) == 1 else RP2350_DIE_B def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: @@ -104,7 +121,7 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: board_pins = {} boards = {} variant_pins_cache: dict[str, dict[str, int]] = {} - variant_rp2350a_cache: dict[str, bool] = {} + variant_die_cache: dict[str, str | None] = {} for json_file in sorted(json_dir.glob("*.json")): board_name = json_file.stem @@ -123,12 +140,14 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: has_wifi = "PICO_CYW43_SUPPORTED=1" in extra_flags max_pin = MCU_MAX_PIN.get(mcu, DEFAULT_MAX_PIN) + die: str | None = None if mcu == "rp2350": - if variant not in variant_rp2350a_cache: - variant_rp2350a_cache[variant] = parse_variant_is_rp2350a( + if variant not in variant_die_cache: + variant_die_cache[variant] = parse_variant_rp2350_die( variants_dir / variant ) - if variant_rp2350a_cache[variant]: + die = variant_die_cache[variant] + if die == RP2350_DIE_A: max_pin = RP2350A_MAX_PIN board_entry: dict = { @@ -136,6 +155,10 @@ def load_boards(arduino_pico_path: Path) -> tuple[dict, dict]: "mcu": mcu, "max_pin": max_pin, } + if mcu == "rp2350": + # Recorded explicitly because max_pin cannot express the die: + # 29 also means RP2040, and 47 also means "die not known yet". + board_entry[RP2350_DIE_KEY] = die if has_wifi: board_entry["wifi"] = True boards[board_name] = board_entry @@ -218,6 +241,7 @@ def generate(arduino_pico_path: Path) -> str: cyw43_gpio_offset=CYW43_GPIO_OFFSET, cyw43_max_gpio=CYW43_GPIO_OFFSET + CYW43_GPIO_COUNT - 1, default_max_pin=DEFAULT_MAX_PIN, + rp2350_die_key=RP2350_DIE_KEY, board_pins=sorted(board_pins.items()), boards=sorted(boards.items()), ) diff --git a/esphome/components/rp2/hal.cpp b/esphome/components/rp2/hal.cpp index 8eb1b469bc..ac1467e5e6 100644 --- a/esphome/components/rp2/hal.cpp +++ b/esphome/components/rp2/hal.cpp @@ -7,6 +7,7 @@ #include "crash_handler.h" #endif +#include "hardware/clocks.h" #include "hardware/watchdog.h" // Empty rp2 namespace block to satisfy ci-custom's lint_namespace check. @@ -33,7 +34,8 @@ void arch_init() { #endif } -uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); } +// clock_get_hz(clk_sys) is the SDK query for the current system clock frequency in Hz. +uint32_t arch_get_cpu_freq_hz() { return clock_get_hz(clk_sys); } } // namespace esphome diff --git a/esphome/components/rp2/lwipopts.h.jinja b/esphome/components/rp2/lwipopts.h.jinja index 36d7d4da14..2da4f467a9 100644 --- a/esphome/components/rp2/lwipopts.h.jinja +++ b/esphome/components/rp2/lwipopts.h.jinja @@ -20,13 +20,24 @@ #undef TCP_WND #define TCP_WND {{ TCP_WND }} -// Queued segment limits: derived from 4xMSS buffer size, matching ESP32 +// Per-PCB send queue: derived from 4xMSS buffer size, matching ESP32 #undef TCP_SND_QUEUELEN #define TCP_SND_QUEUELEN {{ TCP_SND_QUEUELEN }} +// Segment pool: global across every PCB, so it is sized above the per-PCB +// queue length rather than equal to it. lwIP's sanity check only requires +// >= TCP_SND_QUEUELEN, which is the floor for a single connection. #undef MEMP_NUM_TCP_SEG #define MEMP_NUM_TCP_SEG {{ MEMP_NUM_TCP_SEG }} +// lwIP heap backing PBUF_RAM, which is what tcp_write() copies into. +// Raised from arduino-pico's 16KB: TCP_OVERSIZE is TCP_MSS, so a single PCB +// at a full TCP_SND_BUF pins about 6KB. Two of those left the 16KB heap at +// 75%, and mem.c is first-fit, so the largest contiguous run ran out well +// before the total did. +#undef MEM_SIZE +#define MEM_SIZE {{ MEM_SIZE }} + // Packet buffer pool: 16 matches ESP32 (down from 24) #undef PBUF_POOL_SIZE #define PBUF_POOL_SIZE {{ PBUF_POOL_SIZE }} diff --git a/esphome/components/rp2040_ble/__init__.py b/esphome/components/rp2040_ble/__init__.py index e49dceb000..332ea73a61 100644 --- a/esphome/components/rp2040_ble/__init__.py +++ b/esphome/components/rp2040_ble/__init__.py @@ -1,6 +1,9 @@ +from collections.abc import Callable, MutableMapping + import esphome.codegen as cg import esphome.config_validation as cv from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID +from esphome.core import CORE from esphome.types import ConfigType DEPENDENCIES = ["rp2"] @@ -8,6 +11,15 @@ CODEOWNERS = ["@bdraco"] CONF_RP2040_BLE_ID = "rp2040_ble_id" +KEY_RP2040_BLE = "rp2040_ble" +KEY_USED_CONNECTION_SLOTS = "used_connection_slots" + +# Hard platform cap on concurrent GATT connections: the BTstack pool overrides +# in btstack_memory.cpp are sized from ESPHOME_BLE_GATT_CLIENT_COUNT with this +# as the ceiling. 3 matches the esp32 default and stays within the +# controller's resources (MAX_NR_CONTROLLER_ACL_BUFFERS 3). +MAX_CONNECTIONS = 3 + rp2040_ble_ns = cg.esphome_ns.namespace("rp2040_ble") RP2040BLE = rp2040_ble_ns.class_("RP2040BLE", cg.Component) @@ -30,13 +42,67 @@ def _validate_board(config: ConfigType) -> ConfigType: return config -FINAL_VALIDATE_SCHEMA = _validate_board +def consume_connection_slots( + value: int, consumer: str +) -> Callable[[MutableMapping], MutableMapping]: + """Reserve BLE connection slots for a component (the esp32_ble pattern); + the total is checked against MAX_CONNECTIONS in final validation.""" + + def _consume_connection_slots(config: MutableMapping) -> MutableMapping: + data: dict = CORE.data.setdefault(KEY_RP2040_BLE, {}) + slots: list[str] = data.setdefault(KEY_USED_CONNECTION_SLOTS, []) + slots.extend([consumer] * value) + return config + + return _consume_connection_slots + + +def validate_connection_slots() -> None: + """Fail when consumers claimed more slots than the platform cap.""" + # Skip in testing mode to allow component grouping (esp32_ble parity). + if CORE.testing_mode: + return + used = CORE.data.get(KEY_RP2040_BLE, {}).get(KEY_USED_CONNECTION_SLOTS, []) + if len(used) > MAX_CONNECTIONS: + raise cv.Invalid( + f"BLE components require {len(used)} connection slots but the " + f"rp2 maximum is {MAX_CONNECTIONS}. " + f"Components: {', '.join(used)}" + ) + + +def _final_validate(config: ConfigType) -> ConfigType: + _validate_board(config) + validate_connection_slots() + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate # Once per registered scan listener; sizes the controller's StaticVector # listener storage. request_scan_listener_slot = cg.slot_counter("RP2040_BLE_SCAN_LISTENER_COUNT") +# The four btstack_memory accessors whose static pools are baked into the +# prebuilt liblwip-bt.a; every internal use crosses an object boundary in the +# archive, so --wrap intercepts them all (see btstack_memory.cpp). +_BTSTACK_POOL_SYMBOLS = ( + "btstack_memory_gatt_client_get", + "btstack_memory_gatt_client_free", + "btstack_memory_hci_connection_get", + "btstack_memory_hci_connection_free", +) + + +def add_btstack_pool_overrides() -> None: + """Emit the --wrap flags that swap the prebuilt BTstack pools for the + ESPHOME_BLE_GATT_CLIENT_COUNT-sized ones in btstack_memory.cpp. Called by + bluetooth_connection when a second GATT backend registers; idempotent + (build flags are a set).""" + for symbol in _BTSTACK_POOL_SYMBOLS: + cg.add_build_flag(f"-Wl,--wrap={symbol}") + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) diff --git a/esphome/components/rp2040_ble/btstack_memory.cpp b/esphome/components/rp2040_ble/btstack_memory.cpp new file mode 100644 index 0000000000..8af57924a2 --- /dev/null +++ b/esphome/components/rp2040_ble/btstack_memory.cpp @@ -0,0 +1,118 @@ +// Replaces the gatt_client / hci_connection static pools baked into +// arduino-pico's prebuilt liblwip-bt.a (built with MAX_NR_GATT_CLIENTS 1, +// MAX_NR_HCI_CONNECTIONS 2) with pools sized from ESPHOME_BLE_GATT_CLIENT_COUNT. +// add_btstack_pool_overrides() in this component's codegen emits the matching +// -Wl,--wrap flags, requested by bluetooth_connection when more than one GATT +// backend registers; single-backend builds emit no flags and this file +// compiles to nothing, leaving the prebuilt pools in charge. Layout safety: +// the framework defines ENABLE_CLASSIC / ENABLE_BLE for every user TU +// whenever PIO_FRAMEWORK_ARDUINO_ENABLE_BLUETOOTH is set (this component +// always sets it), so sizeof() here matches the archive. + +#include "esphome/core/defines.h" + +#if defined(USE_RP2040_BLE) && defined(USE_BLE_GATT_CLIENT) && (ESPHOME_BLE_GATT_CLIENT_COUNT > 1) + +#include + +#include + +namespace esphome::rp2040_ble { +namespace { + +// Pinned against arduino-pico 6.0.0's prebuilt archives: a framework bump (or +// a changed ENABLE_* macro) shifting the struct layout must fail the build +// here, not overrun the pool blocks at runtime. Sizes differ per core +// architecture (measured from each archive's own storage symbols). GCC only: +// the clang-tidy frontend lays these structs out differently, and the guard +// targets the real link. +#ifndef __clang__ +#ifdef __riscv +static_assert(sizeof(gatt_client_t) == 140 && sizeof(hci_connection_t) == 3740, "BTstack layout changed"); +#else +static_assert(sizeof(gatt_client_t) == 128 && sizeof(hci_connection_t) == 3688, "BTstack layout changed"); +#endif +#endif // __clang__ + +// One gatt_client_t per configured connection slot. An hci_connection_t is +// held from gap_connect() to DISCONNECTION_COMPLETE (scanning holds none); +// +1 mirrors the prebuilt library's own headroom (2 connections for 1 GATT +// client) so a teardown/re-connect overlap can never starve a slot. +constexpr int HCI_CONNECTION_POOL_SIZE = ESPHOME_BLE_GATT_CLIENT_COUNT + 1; + +// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp) +gatt_client_t gatt_client_storage[ESPHOME_BLE_GATT_CLIENT_COUNT]; +btstack_memory_pool_t gatt_client_pool; +hci_connection_t hci_connection_storage[HCI_CONNECTION_POOL_SIZE]; +btstack_memory_pool_t hci_connection_pool; + +// Static init: pool_create only links a free list through its own storage, +// and BTstack first allocates long after static construction. +struct PoolInit { + PoolInit() { + btstack_memory_pool_create(&gatt_client_pool, gatt_client_storage, ESPHOME_BLE_GATT_CLIENT_COUNT, + sizeof(gatt_client_t)); + btstack_memory_pool_create(&hci_connection_pool, hci_connection_storage, HCI_CONNECTION_POOL_SIZE, + sizeof(hci_connection_t)); + } +} pool_init; +// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables,cert-err58-cpp) + +} // namespace + +// Exact semantics of btstack_memory.c's static-pool arm: zeroed block on +// success, NULL when exhausted; free returns the block to the pool. The +// prebuilt pools stay resident in .bss (~7.4 KB, kept live by +// btstack_memory_init in the archive) — dead weight here, not a leak. +// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) +extern "C" gatt_client_t *__real_btstack_memory_gatt_client_get(void); +extern "C" void __real_btstack_memory_gatt_client_free(gatt_client_t *gatt_client); +extern "C" hci_connection_t *__real_btstack_memory_hci_connection_get(void); +extern "C" void __real_btstack_memory_hci_connection_free(hci_connection_t *hci_connection); + +namespace { +// Fails the link if the corresponding --wrap flag is missing: __real_* only +// exists while --wrap is in effect, and each wrap function anchors its own +// symbol so dropping any single flag fails loudly. A code reference is used +// because the framework links with --gc-sections, which discards an +// unreferenced data anchor regardless of [[gnu::used]] (and this toolchain +// does not emit SHF_GNU_RETAIN for [[gnu::retain]]). +template void anchor_wrap(T *symbol) { asm volatile("" ::"r"(symbol)); } +} // namespace + +extern "C" { + +gatt_client_t *__wrap_btstack_memory_gatt_client_get(void) { + anchor_wrap(&__real_btstack_memory_gatt_client_get); + void *buffer = btstack_memory_pool_get(&gatt_client_pool); + if (buffer != nullptr) { + memset(buffer, 0, sizeof(gatt_client_t)); + } + return static_cast(buffer); +} + +void __wrap_btstack_memory_gatt_client_free(gatt_client_t *gatt_client) { + anchor_wrap(&__real_btstack_memory_gatt_client_free); + btstack_memory_pool_free(&gatt_client_pool, gatt_client); +} + +hci_connection_t *__wrap_btstack_memory_hci_connection_get(void) { + anchor_wrap(&__real_btstack_memory_hci_connection_get); + void *buffer = btstack_memory_pool_get(&hci_connection_pool); + if (buffer != nullptr) { + memset(buffer, 0, sizeof(hci_connection_t)); + } + return static_cast(buffer); +} + +void __wrap_btstack_memory_hci_connection_free(hci_connection_t *hci_connection) { + anchor_wrap(&__real_btstack_memory_hci_connection_free); + btstack_memory_pool_free(&hci_connection_pool, hci_connection); +} + +} // extern "C" +// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming) + +} // namespace esphome::rp2040_ble + +#endif // USE_RP2040_BLE && USE_BLE_GATT_CLIENT && ESPHOME_BLE_GATT_CLIENT_COUNT > 1 diff --git a/esphome/components/rp2040_ble/rp2040_ble.cpp b/esphome/components/rp2040_ble/rp2040_ble.cpp index 7dd84d9c31..80e8bf9415 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.cpp +++ b/esphome/components/rp2040_ble/rp2040_ble.cpp @@ -2,6 +2,7 @@ #ifdef USE_RP2040_BLE +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include @@ -180,7 +181,7 @@ void RP2040BLE::packet_handler(uint8_t type, uint16_t channel, uint8_t *packet, // ESPHome main loop: bounded copy into the lock-free queue only. bd_addr_t addr; // accessor returns printable (MSB-first) order gap_event_advertising_report_get_address(packet, addr); - uint8_t mac_lsb[6]; + uint8_t mac_lsb[MAC_ADDRESS_SIZE]; reverse_bd_addr(addr, mac_lsb); // LSB-first, the BLE convention consumers expect global_ble->enqueue_scan_report_(mac_lsb, static_cast(gap_event_advertising_report_get_rssi(packet)), gap_event_advertising_report_get_address_type(packet), @@ -206,7 +207,7 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, this->report_queue_.increment_dropped_count(); return; } - memcpy(report->mac, mac_lsb_first, 6); + memcpy(report->mac, mac_lsb_first, MAC_ADDRESS_SIZE); report->rssi = rssi; report->addr_type = addr_type; report->adv_event_type = adv_event_type; @@ -217,7 +218,9 @@ void RP2040BLE::enqueue_scan_report_(const uint8_t *mac_lsb_first, int8_t rssi, } // NOLINTEND(clang-analyzer-unix.Malloc) -void RP2040BLE::get_mac_msb_first(uint8_t out[6]) const { memcpy(out, this->ble_mac_, 6); } +void RP2040BLE::get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const { + memcpy(out, this->ble_mac_, MAC_ADDRESS_SIZE); +} bool RP2040BLE::scan_start(uint16_t interval, uint16_t window, bool active) { if (!this->is_active()) { diff --git a/esphome/components/rp2040_ble/rp2040_ble.h b/esphome/components/rp2040_ble/rp2040_ble.h index 99eb8cd88a..263a32106b 100644 --- a/esphome/components/rp2040_ble/rp2040_ble.h +++ b/esphome/components/rp2040_ble/rp2040_ble.h @@ -25,8 +25,8 @@ enum class BLEComponentState : uint8_t { /// One advertisement report from the controller. struct BLEScanReport { - uint8_t mac[6]; // LSB-first, as the controller delivers it - int8_t rssi; // signed dBm + uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it + int8_t rssi; // signed dBm uint8_t addr_type; uint8_t adv_event_type; // GAP advertising event type (ADV_IND .. SCAN_RSP); lets a merger tell the two apart uint8_t data_len; // bytes valid in data[] @@ -77,7 +77,7 @@ class RP2040BLE final : public Component { /// (LSB-first) order, hence the explicit names. All zeros until the stack /// reports ACTIVE (BTstack reads the address from the controller during /// power-up). - void get_mac_msb_first(uint8_t out[6]) const; + void get_mac_msb_first(uint8_t out[MAC_ADDRESS_SIZE]) const; #ifdef RP2040_BLE_SCAN_LISTENER_COUNT /// Register a consumer for scan reports (delivered on the main loop via loop()). @@ -135,7 +135,7 @@ class RP2040BLE final : public Component { btstack_packet_callback_registration_t hci_event_callback_registration_{}; btstack_packet_callback_registration_t sm_event_callback_registration_{}; - uint8_t ble_mac_[6]{0}; // printable (MSB-first) order; zeros until ACTIVE + uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // printable (MSB-first) order; zeros until ACTIVE BLEComponentState state_{BLEComponentState::STATE_OFF}; bool enable_on_boot_{true}; bool btstack_initialized_{false}; diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp index 2a87d617f8..06beb186ae 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.cpp @@ -165,7 +165,8 @@ bool RP2BLETracker::request_scan_mode(bool active) { if (this->scan_active_ == active) return true; this->scan_active_ = active; - ESP_LOGD(TAG, "Scan mode %s", active ? "active" : "passive"); + // V: the proxy's "Setting scanner mode" line already narrates this at D. + ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive"); // Apply to a running scan by restarting the CONTROLLER scan with the new // mode, bypassing the tracker's stop/start bookkeeping: no on_scan_end (the // scan logically continues, only the request mode changes), no period reset. diff --git a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h index 02bd7dc145..431f2daec7 100644 --- a/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h +++ b/esphome/components/rp2_ble_tracker/rp2_ble_tracker.h @@ -71,7 +71,7 @@ class RP2BLETracker : public Component, } // The controller stores the address in printable (MSB-first) order, which is // exactly what the contract wants. - void get_adapter_mac(uint8_t out[6]) { this->parent_->get_mac_msb_first(out); } + void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) { this->parent_->get_mac_msb_first(out); } bool scan_running() { return this->scan_running_; } bool scan_active() { return this->scan_active_; } bool request_scan_mode(bool active); diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index d0c2112ba9..bd889c2c92 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from esphome import automation import esphome.codegen as cg @@ -6,9 +6,13 @@ from esphome.components import esp32, network, psram, socket, wifi import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, + CONF_FORMAT, + CONF_HEIGHT, CONF_ID, CONF_SAMPLE_RATE, + CONF_SOURCE, CONF_TASK_STACK_IN_PSRAM, + CONF_WIDTH, ) from esphome.core import CORE, ID from esphome.cpp_generator import TemplateArgsType @@ -20,12 +24,16 @@ CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["network"] DOMAIN = "sendspin" +CONF_DISPLAY_OFFSET = "display_offset" CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +# Matches ARTWORK_MAX_SLOTS in sendspin-cpp. +MAX_ARTWORK_SLOTS = 4 + # sendspin-cpp library lives in the global `sendspin` namespace. sendspin_library_ns = cg.global_ns.namespace("sendspin") @@ -36,9 +44,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) +IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") +IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") +IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP") + +SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True) +IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM") +IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST") + # Library Structs AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject") PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig") +ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig") +ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference") # MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement # preference for the player role's transfer buffers. @@ -76,6 +95,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False + artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -110,6 +130,22 @@ def request_visualizer_support() -> None: _get_data().visualizer_support = True +def register_artwork_preference(config: ConfigType) -> int: + """Register an artwork slot preference and return the slot it was given. + + A slot is a preference's position in the list, which is also the order the roles are + advertised to the server in. + """ + request_artwork_support() + preferences = _get_data().artwork_preferences + if len(preferences) >= MAX_ARTWORK_SLOTS: + raise cv.Invalid( + f"Too many Sendspin image slots. Maximum is {MAX_ARTWORK_SLOTS}." + ) + preferences.append(config) + return len(preferences) - 1 + + def register_player_config(config: ConfigType) -> None: """Register the player role config from the media source subcomponent.""" data = _get_data() @@ -211,6 +247,29 @@ async def to_code(config: ConfigType) -> None: # and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*). if data.artwork_support: cg.add_define("USE_SENDSPIN_ARTWORK", True) + + # require_frame_done is always on: SendspinImageSlot always acks a delivery, either + # immediately or from the transition_finished action. + preference_structs = [ + cg.StructInitializer( + ImageSlotPreference, + ("source", pref[CONF_SOURCE]), + ("format", pref[CONF_FORMAT]), + ("width", pref[CONF_WIDTH]), + ("height", pref[CONF_HEIGHT]), + ("require_frame_done", True), + ("display_offset_ms", pref[CONF_DISPLAY_OFFSET]), + ) + for pref in data.artwork_preferences + ] + + artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM)) + artwork_config = cg.StructInitializer( + ArtworkRoleConfig, + ("preferred_formats", preference_structs), + ("psram_stack", artwork_psram_stack), + ) + cg.add(var.set_artwork_config(artwork_config)) else: esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False) diff --git a/esphome/components/sendspin/image/__init__.py b/esphome/components/sendspin/image/__init__.py new file mode 100644 index 0000000000..94d6e7cfca --- /dev/null +++ b/esphome/components/sendspin/image/__init__.py @@ -0,0 +1,228 @@ +"""Sendspin image platform.""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import runtime_image +from esphome.components.image import CONF_TRANSPARENCY, Image_, add_metadata +import esphome.config_validation as cv +from esphome.const import ( + CONF_FORMAT, + CONF_HEIGHT, + CONF_ID, + CONF_RESIZE, + CONF_SOURCE, + CONF_TYPE, + CONF_WIDTH, +) +from esphome.core import ID +from esphome.cpp_generator import TemplateArgsType +from esphome.types import ConfigType + +from .. import ( + CONF_DISPLAY_OFFSET, + CONF_SENDSPIN_ID, + IMAGE_FORMAT_BMP, + IMAGE_FORMAT_JPEG, + IMAGE_FORMAT_PNG, + IMAGE_SOURCE_ALBUM, + IMAGE_SOURCE_ARTIST, + SendspinHub, + register_artwork_preference, + sendspin_ns, +) + +AUTO_LOAD = ["runtime_image"] +CODEOWNERS = ["@kahrendt"] +DEPENDENCIES = ["sendspin"] + +# runtime_image refuses to size a buffer beyond this, so anything larger fails at setup rather +# than at validation. The library's ImageSlotPreference width/height fields are uint16_t, which +# is the looser of the two bounds. +MAX_IMAGE_DIMENSION = 32767 + +# Sanity bound for display_offset; the library field is int32_t milliseconds and offsets beyond +# a few seconds around the track boundary are meaningless. +MAX_DISPLAY_OFFSET = cv.TimePeriod(seconds=60) +MIN_DISPLAY_OFFSET = cv.TimePeriod(seconds=-60) + +CONF_SLOT = "slot" +CONF_CURRENT_IMAGE = "current_image" +CONF_TRANSITION_IMAGE = "transition_image" +CONF_ON_IMAGE_DISPLAY = "on_image_display" +CONF_ON_IMAGE_CLEAR = "on_image_clear" +CONF_ON_IMAGE_ERROR = "on_image_error" + +# Map runtime_image's validated format string to the sendspin library's SendspinImageFormat enum. +# runtime_image accepts "JPG" as an alias for JPEG, so both keys map to the JPEG enum. +_FORMAT_TO_SENDSPIN_ENUM = { + "JPEG": IMAGE_FORMAT_JPEG, + "JPG": IMAGE_FORMAT_JPEG, + "PNG": IMAGE_FORMAT_PNG, + "BMP": IMAGE_FORMAT_BMP, +} + +# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it +# would never receive artwork while still paying for two frame buffers, so it is not offered here. +IMAGE_SOURCES = { + "ALBUM": IMAGE_SOURCE_ALBUM, + "ARTIST": IMAGE_SOURCE_ARTIST, +} + +# The platform entry configures an artwork slot; the images it shows are declared inside it. The +# slot itself is the automation target (triggers and the transition_finished action). +SendspinImageSlot = sendspin_ns.class_( + "SendspinImageSlot", + cg.Component, + cg.Parented.template(SendspinHub), +) +ArtworkImageView = sendspin_ns.class_("ArtworkImageView", Image_) + +# A dict rather than a bare ID so per-image options can be added later without a new top-level key. +_IMAGE_SCHEMA = cv.Schema({cv.Required(CONF_ID): cv.declare_id(ArtworkImageView)}) + +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_IMAGE_DISPLAY, + "add_on_image_display_callback", + [(cg.uint32, "lateness_ms")], + ), + automation.CallbackAutomation(CONF_ON_IMAGE_CLEAR, "add_on_image_clear_callback"), + automation.CallbackAutomation(CONF_ON_IMAGE_ERROR, "add_on_image_error_callback"), +) + + +def _assign_slot_and_register(config: ConfigType) -> ConfigType: + """Register the artwork preference with the hub and record the slot it was given.""" + width, height = config[CONF_RESIZE] + if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION: + raise cv.Invalid( + f"'{CONF_RESIZE}' width and height must be {MAX_IMAGE_DIMENSION} or less", + path=[CONF_RESIZE], + ) + + config[CONF_SLOT] = register_artwork_preference( + { + CONF_SOURCE: config[CONF_SOURCE], + CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]], + CONF_WIDTH: width, + CONF_HEIGHT: height, + CONF_DISPLAY_OFFSET: config[CONF_DISPLAY_OFFSET].total_milliseconds, + } + ) + return config + + +# The format, type, resize, transparency, byte order and placeholder keys all describe the slot: +# they set what is requested from the server and how it is decoded, not either individual image. +# Only the IDs are per-image, so runtime_image_schema declares the slot itself. +CONFIG_SCHEMA = cv.All( + runtime_image.runtime_image_schema(SendspinImageSlot).extend( + { + cv.GenerateID(): cv.declare_id(SendspinImageSlot), + cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub), + # Narrow runtime_image's format list to what the library can request, so the + # accepted set and the enum map below cannot drift apart. + cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True), + cv.Required(CONF_RESIZE): cv.dimensions, + cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA, + cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum( + IMAGE_SOURCES, upper=True + ), + # Positive fires on_image_display before the server's display timestamp (negative + # delays it), so a cross-fade can straddle the track boundary. + cv.Optional(CONF_DISPLAY_OFFSET, default="0ms"): cv.All( + cv.time_period, + # The library field is whole milliseconds; reject finer values rather than + # silently rounding them down to zero. + cv.time_period_in_milliseconds_, + cv.Range(min=MIN_DISPLAY_OFFSET, max=MAX_DISPLAY_OFFSET), + ), + cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_CLEAR): automation.validate_automation({}), + cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation({}), + } + ), + runtime_image.validate_runtime_image_settings, + cv.only_on_esp32, + _assign_slot_and_register, +) + + +async def to_code(config: ConfigType) -> None: + settings = await runtime_image.process_runtime_image_config(config) + + def make_view(view_id: ID) -> cg.MockObj: + # Views start with no frame; the slot points them at its buffers in setup(). The size is + # given up front so the view is well formed before then. LVGL picks it up from the first + # lvgl.image.update in on_image_display, not from the widget's initial src: at that point + # the view still has no frame, so its descriptor is empty. + view = cg.new_Pvariable( + view_id, + cg.nullptr, + settings.width, + settings.height, + settings.image_type_enum, + settings.transparent, + ) + add_metadata( + view_id, + settings.width, + settings.height, + config[CONF_TYPE], + config[CONF_TRANSPARENCY], + ) + return view + + current_image = make_view(config[CONF_CURRENT_IMAGE][CONF_ID]) + if settings.placeholder is not None: + cg.add(current_image.set_placeholder(settings.placeholder)) + + var = cg.new_Pvariable( + config[CONF_ID], + config[CONF_SLOT], + current_image, + settings.width, + settings.height, + settings.format_enum, + settings.image_type_enum, + settings.transparent, + settings.byte_order_big_endian, + ) + await cg.register_component(var, config) + await cg.register_parented(var, config[CONF_SENDSPIN_ID]) + + if (transition_image := config.get(CONF_TRANSITION_IMAGE)) is not None: + cg.add(var.set_transition_image(make_view(transition_image[CONF_ID]))) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + + +SendspinImageTransitionFinishedAction = sendspin_ns.class_( + "SendspinImageTransitionFinishedAction", + automation.Action, + cg.Parented.template(SendspinImageSlot), +) + + +@automation.register_action( + "sendspin.image.transition_finished", + SendspinImageTransitionFinishedAction, + automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinImageSlot), + } + ) + ), + synchronous=True, +) +async def sendspin_image_transition_finished_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> cg.MockObj: + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/sendspin/image/automation.h b/esphome/components/sendspin/image/automation.h new file mode 100644 index 0000000000..154e62a4b2 --- /dev/null +++ b/esphome/components/sendspin/image/automation.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/automation.h" +#include "sendspin_image.h" + +namespace esphome::sendspin_ { + +template +class SendspinImageTransitionFinishedAction final : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->transition_finished(); } +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.cpp b/esphome/components/sendspin/image/sendspin_image.cpp new file mode 100644 index 0000000000..626d7966b7 --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.cpp @@ -0,0 +1,261 @@ +#include "sendspin_image.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/core/log.h" + +#include + +namespace esphome::sendspin_ { + +static const char *const TAG = "sendspin.image"; + +// How long a displayed frame may wait for sendspin.image.transition_finished before a warning +// names the missing ack. Generous next to a typical fade of a second or two. +static constexpr uint32_t TRANSITION_ACK_WARNING_MS = 10000; + +// THREAD CONTEXT: Main loop. Children set up after the hub, so the artwork role already exists. +void SendspinImageSlot::setup() { + const size_t frame_size = this->decode_sink_.get_buffer_size(this->width_, this->height_); + if (frame_size == 0) { + // The sink would refuse a buffer of these dimensions, so every decode would fall back to + // allocating one of its own. Fail here instead, where the dimensions are already known. + ESP_LOGE(TAG, "Cannot decode artwork at %dx%d", this->width_, this->height_); + this->mark_failed(); + return; + } + + RAMAllocator allocator; + for (uint8_t *&buffer : this->buffers_) { + buffer = allocator.allocate(frame_size); + if (buffer == nullptr) { + ESP_LOGE(TAG, "Could not allocate %zu bytes for an artwork frame. Largest free block: %zu", frame_size, + allocator.get_max_free_block_size()); + for (uint8_t *&allocated : this->buffers_) { + allocator.deallocate(allocated, frame_size); + allocated = nullptr; + } + this->mark_failed(); + return; + } + // Both buffers start black, so a transition has something to fade from before any artwork + // has arrived. + memset(buffer, 0, frame_size); + } + + // Point both views at buffers_[current_index_] rather than the buffer the first decode writes + // into, so they name a frame that stays black until artwork arrives. + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + } + + this->parent_->add_image_decode_callback( + [this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat) { + if (slot == this->slot_) + this->on_decode_(data, length); + }); + this->parent_->add_image_display_callback([this](uint8_t slot, uint32_t lateness_ms) { + if (slot == this->slot_) + this->on_display_(lateness_ms); + }); + this->parent_->add_image_clear_callback([this](uint8_t slot) { + if (slot == this->slot_) + this->on_clear_(); + }); +} + +// THREAD CONTEXT: Dedicated artwork decode thread. The data pointer is valid only for this call. +void SendspinImageSlot::on_decode_(const uint8_t *data, size_t length) { + uint8_t *target; + { + // The lock makes the main loop's last swap of current_index_ visible here. The frame_done gate + // is what guarantees the buffer it picks out is not still needed by the main loop. + LockGuard lock(this->pending_mutex_); + target = this->buffers_[this->current_index_ ^ 1]; + } + + // The server letterboxes artwork onto a canvas of exactly the requested dimensions, so the sink + // is pinned to them: a decode that asks for anything else is a malformed payload and drops the + // frame. + if (!this->decode_sink_.set_external_buffer(target, this->width_, this->height_)) { + // setup() rules this out, but decoding without the handover would allocate a frame-sized + // buffer on this thread, which is exactly what the permanent buffers exist to avoid. + this->report_error_(); + return; + } + + const bool decoded = this->decode_frame_(data, length, target); + // Drops any half-finished decoder. An external buffer is let go of rather than freed, so this is + // safe on every path. + this->decode_sink_.release(); + + if (!decoded) { + // The buffer keeps whatever the failed decode painted into it, but no view names it while a + // decode can run, so nothing shows it. + this->report_error_(); + return; + } + + LockGuard lock(this->pending_mutex_); + this->frame_pending_ = true; +} + +// THREAD CONTEXT: Artwork decode thread, with target already handed to the sink. +bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const uint8_t *target) { + if (!this->decode_sink_.begin_decode(length)) { + ESP_LOGE(TAG, "Could not start decode"); + return false; + } + + size_t total_consumed = 0; + while (total_consumed < length) { + int consumed = this->decode_sink_.feed_data(const_cast(data) + total_consumed, length - total_consumed); + if (consumed <= 0) { + // <0 is a decode error; 0 means the decoder cannot make progress (truncated/corrupt data). + ESP_LOGE(TAG, "Decode failed at offset %zu (result %d)", total_consumed, consumed); + return false; + } + total_consumed += consumed; + } + + if (!this->decode_sink_.end_decode()) { + ESP_LOGE(TAG, "Could not finalize decode"); + return false; + } + + // A decode that asked for other dimensions had the buffer taken away from it, so it painted + // nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on + // regardless, so the frame is dropped here. + return this->decode_sink_.decoded_into(target); +} + +// THREAD CONTEXT: Main loop (fired once the slot's offset-shifted display deadline is reached). +void SendspinImageSlot::on_display_(uint32_t lateness_ms) { + bool frame_ready; + { + LockGuard lock(this->pending_mutex_); + frame_ready = this->frame_pending_; + this->frame_pending_ = false; + if (frame_ready) { + // The decoded frame becomes the current one; the frame it replaces becomes the outgoing + // frame, and the next decode target once the transition is acked. + this->current_index_ ^= 1; + } + } + if (!frame_ready) { + // The decode for this display failed, so there is nothing new to show. The delivery still owes + // its ack or the library would withhold every later frame for this slot. + this->parent_->artwork_frame_done(this->slot_); + return; + } + + // The frame this display replaces is only real artwork if something was already on screen. + const bool outgoing_is_artwork = this->showing_artwork_; + this->showing_artwork_ = true; + this->apply_frames_(outgoing_is_artwork); + + // Armed before the trigger fires so an automation that acks synchronously still counts, and armed + // for the first frame too so the contract stays uniform: one transition_finished per display. + this->transition_pending_ = this->transition_image_ != nullptr; + if (this->transition_pending_) { + // The library holds back further deliveries until the ack, with no timeout, so an automation + // that never reaches the action stalls the slot with nothing in the log. Name the cause after + // a generous wait. Arming again replaces the previous timeout, so it cannot fire for a frame + // that was already acked and superseded. + this->set_timeout("transition_ack", TRANSITION_ACK_WARNING_MS, [this]() { + if (this->transition_pending_) { + ESP_LOGW(TAG, + "Slot %u: displayed artwork was never acknowledged; no new artwork will arrive until " + "sendspin.image.transition_finished runs or the stream is cleared", + this->slot_); + } + }); + } + this->image_display_callback_.call(lateness_ms); + if (this->transition_image_ == nullptr) { + this->finish_transition_(); + } +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::finish_transition_() { + this->transition_pending_ = false; + if (this->transition_image_ != nullptr) { + // Move it off the buffer the next decode writes into. What it shows does not change: the + // buffer it moves to holds the artwork the transition just settled on. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(this->showing_artwork_); + } + // The ack wakes the decode thread, which may start writing buffers_[current_index_ ^ 1] straight + // away, so nothing may still name that buffer by the time this runs. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.image.transition_finished action). +void SendspinImageSlot::transition_finished() { + if (!this->transition_pending_) { + return; + } + this->finish_transition_(); +} + +// THREAD CONTEXT: Main loop (fired on stream end or clear for this slot). +void SendspinImageSlot::on_clear_() { + { + LockGuard lock(this->pending_mutex_); + // Drop a frame that was decoded but never displayed; its buffer stays the decode target. + this->frame_pending_ = false; + } + // No pixels are touched and the views keep naming the frames they had: a widget goes on drawing + // the last artwork until the automation points it elsewhere or hides it. Only the display lambda + // path stops drawing the artwork, falling back to the placeholder. + this->current_image_->set_showing_artwork(false); + if (this->transition_image_ != nullptr) { + // Point it away from the decode target, as at setup, so it cannot show a frame being decoded. + this->transition_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->transition_image_->set_showing_artwork(false); + } + this->showing_artwork_ = false; + // Drops a running transition. Its automation cannot be cancelled here, so a late + // transition_finished() can ack the next stream's first frame early, showing it without its + // transition. The ack count stays right. + this->transition_pending_ = false; + this->image_clear_callback_.call(); + // A clear is itself a delivery owing exactly one ack, and it supersedes any un-acked frame -- + // including one whose transition never signalled transition_finished(), so a stalled slot + // recovers here. + this->parent_->artwork_frame_done(this->slot_); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::dump_config() { + ESP_LOGCONFIG(TAG, + "Artwork slot %u:\n" + " Dimensions: %dx%d\n" + " Frame buffers: 2 x %zu bytes\n" + " Transition image: %s", + this->slot_, this->width_, this->height_, + this->decode_sink_.get_buffer_size(this->width_, this->height_), + YESNO(this->transition_image_ != nullptr)); +} + +// THREAD CONTEXT: Main loop. +void SendspinImageSlot::apply_frames_(bool transition_is_artwork) { + this->current_image_->set_frame(this->buffers_[this->current_index_], this->width_, this->height_); + this->current_image_->set_showing_artwork(true); + if (this->transition_image_ != nullptr) { + this->transition_image_->set_frame(this->buffers_[this->current_index_ ^ 1], this->width_, this->height_); + this->transition_image_->set_showing_artwork(transition_is_artwork); + } +} + +// THREAD CONTEXT: Artwork decode thread. Triggers must run on the main loop; defer() is thread-safe +// here because the hub enables wake_loop_threadsafe support. +void SendspinImageSlot::report_error_() { + this->defer([this]() { this->image_error_callback_.call(); }); +} + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/image/sendspin_image.h b/esphome/components/sendspin/image/sendspin_image.h new file mode 100644 index 0000000000..2f6f4e8a4d --- /dev/null +++ b/esphome/components/sendspin/image/sendspin_image.h @@ -0,0 +1,185 @@ +#pragma once + +#include "esphome/core/defines.h" + +#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK) + +#include "esphome/components/image/image.h" +#include "esphome/components/runtime_image/runtime_image.h" +#include "esphome/components/sendspin/sendspin_hub.h" + +#include "esphome/core/helpers.h" + +#include + +#include +#include + +namespace esphome::sendspin_ { + +/// @brief Decode-only RuntimeImage that decodes into a buffer owned by SendspinImageSlot. +/// +/// Runs exclusively on the sendspin library's artwork decode thread. RuntimeImage's decode path +/// overwrites the fields the display reads (data_start_/width_/height_), so it must never be the +/// object shown on screen. +class ArtworkDecodeSink : public runtime_image::RuntimeImage { + public: + using runtime_image::RuntimeImage::RuntimeImage; + + /// @brief True when the decode ended with the given buffer still in place. + /// + /// An external buffer is dropped rather than resized, so a decode that wanted other dimensions + /// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but + /// the PNG decoder ignores it and reports success, so the outcome is checked here as well. + bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; } +}; + +/// @brief A non-owning image::Image view over a buffer owned by SendspinImageSlot. +/// +/// Each slot publishes its frames through these: one for the artwork on screen, and optionally a +/// second for the outgoing frame during a cross-fade. A view always names a frame, black to begin +/// with, so LVGL can be given it as a widget source before any artwork exists. Main loop only. +class ArtworkImageView : public image::Image { + public: + using image::Image::Image; + + void set_frame(const uint8_t *data, int width, int height) { + this->data_start_ = data; + this->width_ = width; + this->height_ = height; +#ifdef USE_LVGL + // Keep the descriptor LVGL is handed in step with the frame. This does not redraw anything: + // only setting a widget's source invalidates it. + this->get_lv_image_dsc(); +#endif + } + + /// @brief Records whether the frame on show is real artwork rather than the black it starts as. + /// + /// Only changes what the display lambda path draws. The frame itself is left alone, so anything + /// reading the pixels directly (an LVGL widget) keeps drawing the last artwork until it is + /// pointed elsewhere. + void set_showing_artwork(bool showing_artwork) { this->showing_artwork_ = showing_artwork; } + + void set_placeholder(image::Image *placeholder) { this->placeholder_ = placeholder; } + + void draw(int x, int y, display::Display *display, Color color_on, Color color_off) override { + if (!this->showing_artwork_) { + // Nothing worth showing yet: the placeholder if there is one, otherwise leave the area be + // rather than paint a blank frame over it. + if (this->placeholder_ != nullptr) { + this->placeholder_->draw(x, y, display, color_on, color_off); + } + return; + } + image::Image::draw(x, y, display, color_on, color_off); + } + + protected: + image::Image *placeholder_{nullptr}; + bool showing_artwork_{false}; +}; + +/// @brief A single artwork slot: owns the frame buffers and publishes them to its image views. +/// +/// BUFFERS: two buffers, allocated zeroed at setup and never freed. One holds the frame the current +/// image shows; the other holds the outgoing frame a transition shows, and is where the next +/// artwork is decoded. Each display swaps their roles. +/// +/// THREADING: the sendspin library decodes on a dedicated thread and fires display/clear on the +/// main loop. Decoding runs into decode_sink_, which writes into the buffer the current image is +/// not showing; the swap that puts it on screen happens on the main loop. Every slot enables the +/// library's require_frame_done gate, which withholds further deliveries for the slot (buffering +/// the newest payload, latest wins) until the hub's artwork_frame_done() runs. That gate is what +/// makes two buffers enough: no decode starts while the main loop still needs the outgoing frame. +/// +/// LVGL: publishing a frame to a view updates the descriptor LVGL was handed but does not +/// invalidate the widget, so every widget's source must be set again on each display. +class SendspinImageSlot : public SendspinChild { + public: + SendspinImageSlot(uint8_t slot, ArtworkImageView *current_image, int width, int height, + runtime_image::ImageFormat format, image::ImageType type, image::Transparency transparency, + bool is_big_endian) + : decode_sink_(format, type, transparency, nullptr, is_big_endian, width, height), + current_image_(current_image), + width_(width), + height_(height), + slot_(slot) {} + + void setup() override; + void dump_config() override; + + template void add_on_image_display_callback(F &&callback) { + this->image_display_callback_.add(std::forward(callback)); + } + template void add_on_image_clear_callback(F &&callback) { + this->image_clear_callback_.add(std::forward(callback)); + } + template void add_on_image_error_callback(F &&callback) { + this->image_error_callback_.add(std::forward(callback)); + } + + /// @brief Sets the optional view a transition draws the outgoing artwork from. + /// + /// It holds the outgoing frame while a transition is running and the current frame at any other + /// time, so it always names a picture and never the frame being decoded. + /// + /// Setting it is also what defers the library ack to transition_finished(): the ack releases the + /// outgoing frame to be decoded over, and this view is the only thing that still names it. + void set_transition_image(ArtworkImageView *transition_image) { this->transition_image_ = transition_image; } + + /// @brief Signals that the display transition for the last frame has finished. + /// + /// Acks the library so the next artwork can be delivered, which also hands the outgoing frame's + /// buffer over to be decoded into. Safe no-op when no transition is pending (e.g. no transition + /// image is configured, a clear already ended the transition, or the call is a duplicate). Must + /// run on the main loop thread; exposed as the sendspin.image.transition_finished action. + void transition_finished(); + + protected: + void on_decode_(const uint8_t *data, size_t length); + bool decode_frame_(const uint8_t *data, size_t length, const uint8_t *target); + void on_display_(uint32_t lateness_ms); + void on_clear_(); + void finish_transition_(); + void apply_frames_(bool transition_is_artwork); + void report_error_(); + + ArtworkDecodeSink decode_sink_; + + // The two frame buffers, allocated in setup() and never freed. Their contents are written on the + // decode thread and read by whatever draws the views, so only their roles are swapped, never the + // pointers themselves. + std::array buffers_{}; + + // pending_mutex_ guards the two fields below, the only state shared across threads. Everything + // after them is touched on the main loop only. + Mutex pending_mutex_; + // Index into buffers_ of the frame the current image shows. buffers_[current_index_ ^ 1] holds + // the outgoing frame and is the next decode target. Written on the main loop, read on the + // decode thread. + uint8_t current_index_{0}; + // Set on the decode thread once a frame is waiting in buffers_[current_index_ ^ 1]. + bool frame_pending_{false}; + + // True once artwork has been displayed, until the next clear; decides whether the outgoing frame + // is real artwork or the black the buffers start as. Main loop only. + bool showing_artwork_{false}; + // True while a displayed frame awaits transition_finished(); gates duplicate or stray calls + // so exactly one ack reaches the library per delivery. Main loop only. + bool transition_pending_{false}; + + ArtworkImageView *current_image_; + ArtworkImageView *transition_image_{nullptr}; + int width_; + int height_; + uint8_t slot_; + + LazyCallbackManager image_display_callback_{}; + LazyCallbackManager image_clear_callback_{}; + LazyCallbackManager image_error_callback_{}; +}; + +} // namespace esphome::sendspin_ + +#endif diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.cpp b/esphome/components/sendspin/media_player/sendspin_media_player.cpp index beb2028689..fe0bda6f42 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.cpp +++ b/esphome/components/sendspin/media_player/sendspin_media_player.cpp @@ -34,11 +34,7 @@ void SendspinMediaPlayer::setup() { new_state = media_player::MEDIA_PLAYER_STATE_IDLE; break; } - if (this->state != new_state) { - this->state = new_state; - this->publish_state(); - ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); - } + this->set_playback_state_(new_state); } }); @@ -52,11 +48,27 @@ void SendspinMediaPlayer::setup() { } }); + // The connection dropped, so nothing is playing. The server never gets to send a final "stopped" group update, so + // without this the entity keeps reporting playing indefinitely. Volume and mute keep their last values, since + // media_player has no way to express an unknown volume. + this->parent_->add_controller_state_clear_callback( + [this]() { this->set_playback_state_(media_player::MEDIA_PLAYER_STATE_IDLE); }); + // Publish an initial state this->state = media_player::MEDIA_PLAYER_STATE_IDLE; this->publish_state(); } +// THREAD CONTEXT: Main loop (called from the callbacks registered in setup()) +void SendspinMediaPlayer::set_playback_state_(media_player::MediaPlayerState new_state) { + if (this->state == new_state) { + return; + } + this->state = new_state; + this->publish_state(); + ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); +} + // THREAD CONTEXT: Main loop (invoked by the media_player framework) media_player::MediaPlayerTraits SendspinMediaPlayer::get_traits() { auto traits = media_player::MediaPlayerTraits(); diff --git a/esphome/components/sendspin/media_player/sendspin_media_player.h b/esphome/components/sendspin/media_player/sendspin_media_player.h index 651e1562be..ff76473189 100644 --- a/esphome/components/sendspin/media_player/sendspin_media_player.h +++ b/esphome/components/sendspin/media_player/sendspin_media_player.h @@ -25,6 +25,9 @@ class SendspinMediaPlayer final : public SendspinChild, public media_player::Med // Receives commands from HA void control(const media_player::MediaPlayerCall &call) override; + /// @brief Publishes @p new_state if it differs from the current state. + void set_playback_state_(media_player::MediaPlayerState new_state); + float volume_increment_{0.05f}; bool muted_{false}; }; diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 04dbab0080..028491284a 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -21,6 +21,12 @@ namespace esphome::sendspin_ { static const char *const TAG = "sendspin.hub"; +#ifdef USE_SENDSPIN_ARTWORK +// Indexed by the library enums, which start at zero and are contiguous. +static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"}; +static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"}; +#endif + void SendspinHub::setup() { auto config = this->build_client_config_(); this->client_ = std::make_unique(std::move(config)); @@ -37,6 +43,11 @@ void SendspinHub::setup() { this->client_->set_network_provider(this); this->client_->set_persistence_provider(this); +#ifdef USE_SENDSPIN_ARTWORK + this->artwork_role_ = &this->client_->add_artwork(this->artwork_config_); + this->artwork_role_->set_listener(this); +#endif + #ifdef USE_SENDSPIN_CONTROLLER this->controller_role_ = &this->client_->add_controller(); this->controller_role_->set_listener(this); @@ -67,6 +78,18 @@ void SendspinHub::dump_config() { " Client ID: %s\n" " Task stack in PSRAM: %s", get_client_id_into_buffer(mac_buf), YESNO(this->task_stack_in_psram_)); + +#ifdef USE_SENDSPIN_ARTWORK + // Slot indices come from the order the image platform entries were declared, so the log is the + // only place the mapping from a slot to the artwork it asked for can be read back. + uint8_t slot = 0; + for (const auto &preference : this->artwork_config_.preferred_formats) { + ESP_LOGCONFIG(TAG, " Artwork slot %u: %s as %s, %ux%u, display offset %" PRId32 " ms", slot++, + IMAGE_SOURCE_NAMES[static_cast(preference.source)], + IMAGE_FORMAT_NAMES[static_cast(preference.format)], preference.width, preference.height, + preference.display_offset_ms); + } +#endif } // --- Delegating methods --- @@ -174,6 +197,30 @@ std::optional SendspinHub::load_last_server_hash() { // --- Sendspin role specific methods/overrides --- +#ifdef USE_SENDSPIN_ARTWORK +// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too +void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length, + sendspin::SendspinImageFormat format) { + this->artwork_image_decode_callbacks_.call(slot, data, length, format); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop() once the slot's offset-shifted display +// deadline is reached; lateness_ms reports how far past the deadline the display slipped) +void SendspinHub::on_image_display(uint8_t slot, uint32_t lateness_ms) { + this->artwork_image_display_callbacks_.call(slot, lateness_ms); +} + +// THREAD CONTEXT: Main loop (fired from client_->loop()) +void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); } + +// THREAD CONTEXT: Main loop (invoked from SendspinImageSlot once a delivery is fully presented) +void SendspinHub::artwork_frame_done(uint8_t slot) { + if (this->artwork_role_ != nullptr) { + this->artwork_role_->frame_done(slot); + } +} +#endif + #ifdef USE_SENDSPIN_CONTROLLER // THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components) void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional volume, @@ -192,6 +239,12 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) { this->controller_state_callbacks_.call(state); } + +// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop()) +// Unlike metadata, this cannot be fanned out as a default-constructed state object: volume and muted are plain values +// rather than optionals, so children would read a real-looking 0% volume where we mean no value at all. A separate +// callback lets each child clear only what it can represent. +void SendspinHub::on_controller_state_clear() { this->controller_state_clear_callbacks_.call(); } #endif #ifdef USE_SENDSPIN_METADATA @@ -200,6 +253,12 @@ void SendspinHub::on_metadata(const sendspin::ServerMetadataStateObject &metadat this->metadata_update_callbacks_.call(metadata); } +// THREAD CONTEXT: Main loop (MetadataRoleListener override, fired from client_->loop()) +// The cached metadata was dropped because the connection to the server was lost, so what the children now mirror is +// the empty state. Fanning that out as a default-constructed state object rather than through a separate callback +// keeps one code path in the children: every field is nullopt, which they already publish as empty/unknown. +void SendspinHub::on_metadata_clear() { this->metadata_update_callbacks_.call(sendspin::ServerMetadataStateObject{}); } + // THREAD CONTEXT: Main loop (invoked from Sendspin components) uint32_t SendspinHub::get_track_progress_ms() const { if (this->is_ready()) { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index c6b1ed97f7..7c50c3eb80 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -13,6 +13,9 @@ #include #include +#ifdef USE_SENDSPIN_ARTWORK +#include +#endif #ifdef USE_SENDSPIN_CONTROLLER #include #endif @@ -69,6 +72,9 @@ struct StaticDelayPref { /// (for services the library pulls; e.g., persistence, network readiness). /// - User -> library communication uses exposed functions on the client and role objects that the user calls. class SendspinHub final : public Component, +#ifdef USE_SENDSPIN_ARTWORK + public sendspin::ArtworkRoleListener, +#endif #ifdef USE_SENDSPIN_CONTROLLER public sendspin::ControllerRoleListener, #endif @@ -121,6 +127,27 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods --- +#ifdef USE_SENDSPIN_ARTWORK + void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; } + + /// @brief Acknowledges the most recent artwork delivery (display or clear) for a slot. + /// + /// Every slot is configured with the library's require_frame_done gate, which withholds the + /// next delivery for the slot until this is called. Exactly one ack is owed per delivery; a + /// redundant call is a safe no-op in the library. Must be called from the main loop thread. + void artwork_frame_done(uint8_t slot); + + template void add_image_decode_callback(F &&callback) { + this->artwork_image_decode_callbacks_.add(std::forward(callback)); + } + template void add_image_display_callback(F &&callback) { + this->artwork_image_display_callbacks_.add(std::forward(callback)); + } + template void add_image_clear_callback(F &&callback) { + this->artwork_image_clear_callbacks_.add(std::forward(callback)); + } +#endif + #ifdef USE_SENDSPIN_CONTROLLER void send_client_command(sendspin::SendspinControllerCommand command, std::optional volume = std::nullopt, std::optional mute = std::nullopt); @@ -128,9 +155,18 @@ class SendspinHub final : public Component, template void add_controller_state_callback(F &&callback) { this->controller_state_callbacks_.add(std::forward(callback)); } + + /// @brief Registers a callback that fires when the connection is lost and the cached controller state is dropped. + template void add_controller_state_clear_callback(F &&callback) { + this->controller_state_clear_callbacks_.add(std::forward(callback)); + } #endif #ifdef USE_SENDSPIN_METADATA + /// @brief Registers a callback that fires when the server sends metadata. + /// + /// Also fires when the connection is lost, with an all-empty state object (every field nullopt, timestamp 0) meaning + /// the cached metadata was dropped. Subscribers must treat an absent field as cleared, not as no update. template void add_metadata_update_callback(F &&callback) { this->metadata_update_callbacks_.add(std::forward(callback)); } @@ -171,13 +207,34 @@ class SendspinHub final : public Component, // --- Sendspin role specific methods/overrides/member variables --- +#ifdef USE_SENDSPIN_ARTWORK + void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override; + + void on_image_display(uint8_t slot, uint32_t lateness_ms) override; + + void on_image_clear(uint8_t slot) override; + + sendspin::ArtworkRoleConfig artwork_config_{}; + sendspin::ArtworkRole *artwork_role_{nullptr}; + + // Callback fan-out to child components; they filter by slot as needed. + CallbackManager + artwork_image_decode_callbacks_{}; + CallbackManager artwork_image_display_callbacks_{}; + CallbackManager artwork_image_clear_callbacks_{}; +#endif + #ifdef USE_SENDSPIN_CONTROLLER sendspin::ControllerRole *controller_role_{nullptr}; void on_controller_state(const sendspin::ServerStateControllerObject &state) override; - // Callback fan-out to child components; they filter as needed - CallbackManager controller_state_callbacks_{}; + void on_controller_state_clear() override; + + // Callback fan-out to child components; they filter as needed. Only a media_player subscribes, while the switch + // action and the media source enable the controller role without one, so keep the idle cost to a single pointer. + LazyCallbackManager controller_state_callbacks_{}; + LazyCallbackManager controller_state_clear_callbacks_{}; #endif #ifdef USE_SENDSPIN_METADATA @@ -185,6 +242,8 @@ class SendspinHub final : public Component, void on_metadata(const sendspin::ServerMetadataStateObject &metadata) override; + void on_metadata_clear() override; + // Callback fan-out to child components; they filter as needed CallbackManager metadata_update_callbacks_{}; #endif diff --git a/esphome/components/sendspin/sensor/sendspin_sensor.cpp b/esphome/components/sendspin/sensor/sendspin_sensor.cpp index 68848a6f3e..dcbab75b65 100644 --- a/esphome/components/sendspin/sensor/sendspin_sensor.cpp +++ b/esphome/components/sendspin/sensor/sendspin_sensor.cpp @@ -4,6 +4,8 @@ #include +#include + namespace esphome::sendspin_ { static const char *const TAG = "sendspin.sensor"; @@ -20,6 +22,13 @@ void SendspinTrackProgressSensor::dump_config() { void SendspinTrackProgressSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { if (!metadata.progress.has_value()) { + // Progress is unknown: the server has not reported it, or it was cleared (e.g. on disconnect). Stop polling and + // report unknown rather than leaving the last position frozen on the frontend. Only the transition is published; + // NAN never compares equal to itself, so an unguarded publish would repeat on every metadata update. + this->stop_poller(); + if (!std::isnan(this->get_raw_state())) { + this->publish_state(NAN); + } return; } const auto &progress = metadata.progress.value(); @@ -34,6 +43,11 @@ void SendspinTrackProgressSensor::setup() { this->start_poller(); } }); + + // PollingComponent starts the poller before setup(), but there is nothing to interpolate yet: + // get_track_progress_ms() returns 0 until the server reports a position, so polling now would publish 0 every tick + // from boot until the first metadata arrives. The callback above starts it once playback is running. + this->stop_poller(); } // THREAD CONTEXT: Main loop. @@ -80,15 +94,19 @@ std::optional SendspinMetadataSensor::extract_value_(const sendspin::Serv // (SendspinHub dispatches metadata from client_->loop()). void SendspinMetadataSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (auto value = this->extract_value_(metadata)) { - this->publish_if_changed_(*value); - } + // A field the server has not provided, or has explicitly cleared, is published as NAN (the sensor convention for + // unknown) rather than skipped, so a value that goes away does not linger from the previous track. + this->publish_if_changed_(this->extract_value_(metadata).value_or(NAN)); }); } // Dedup to avoid frontend churn; Sensor::publish_state always notifies without checking for changes. void SendspinMetadataSensor::publish_if_changed_(float value) { - if (this->get_raw_state() != value) { + const float current = this->get_raw_state(); + // The raw state starts as NAN, so a field that is already cleared when the first update arrives is suppressed here + // as well: the frontend still shows the sensor as unknown, which is what a clear means. NAN never compares equal to + // itself, so a field that stays cleared would republish on every metadata update without the second check. + if (current != value && !(std::isnan(current) && std::isnan(value))) { this->publish_state(value); } } diff --git a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp index 9843fb966e..554e01cf88 100644 --- a/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp +++ b/esphome/components/sendspin/text_sensor/sendspin_text_sensor.cpp @@ -12,40 +12,40 @@ static const char *const TAG = "sendspin.text_sensor"; void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); } +// A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to +// show, so return the empty string and let the caller publish it; returning early would leave the previous track's +// value on display. +// +// The empty string is not the same as unknown. A text sensor reports unknown through the API's missing_state flag, +// which follows has_state(), and has_state() is only ever set, never cleared. Once a real value has been published, +// an empty state is the closest we can get. The numeric sensors publish NAN, which does read as unknown. const char *SendspinTextSensor::extract_value_(const sendspin::ServerMetadataStateObject &metadata) const { switch (this->metadata_type_) { case SendspinTextMetadataTypes::TITLE: - if (metadata.title.has_value()) - return metadata.title.value().c_str(); - return nullptr; + return metadata.title.has_value() ? metadata.title.value().c_str() : ""; case SendspinTextMetadataTypes::ARTIST: - if (metadata.artist.has_value()) - return metadata.artist.value().c_str(); - return nullptr; + return metadata.artist.has_value() ? metadata.artist.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM: - if (metadata.album.has_value()) - return metadata.album.value().c_str(); - return nullptr; + return metadata.album.has_value() ? metadata.album.value().c_str() : ""; case SendspinTextMetadataTypes::ALBUM_ARTIST: - if (metadata.album_artist.has_value()) - return metadata.album_artist.value().c_str(); - return nullptr; + return metadata.album_artist.has_value() ? metadata.album_artist.value().c_str() : ""; } - return nullptr; + return ""; } // THREAD CONTEXT: Main loop. The registered metadata callback also fires on the main loop // (SendspinHub dispatches metadata from client_->loop()). void SendspinTextSensor::setup() { this->parent_->add_metadata_update_callback([this](const sendspin::ServerMetadataStateObject &metadata) { - if (const char *value = this->extract_value_(metadata)) { - this->publish_if_changed_(value); - } + this->publish_if_changed_(this->extract_value_(metadata)); }); } // Dedup to avoid frontend churn; TextSensor::publish_state already dedups the string assign but still notifies. void SendspinTextSensor::publish_if_changed_(const char *value) { + // The state starts empty, so a field that is already cleared when the first update arrives is suppressed here: the + // entity stays unknown rather than being dropped out of it for good by an empty publish. Later clears do publish the + // empty string and fire on_value with it. if (this->get_raw_state() != value) { this->publish_state(value); } diff --git a/esphome/components/shelly_dimmer/light.py b/esphome/components/shelly_dimmer/light.py index cd6d858067..dd99fcbc90 100644 --- a/esphome/components/shelly_dimmer/light.py +++ b/esphome/components/shelly_dimmer/light.py @@ -2,9 +2,7 @@ import hashlib from pathlib import Path import re -import requests - -from esphome import pins +from esphome import external_files, pins import esphome.codegen as cg from esphome.components import light, sensor, uart from esphome.components.const import CONF_SHA256 @@ -28,8 +26,9 @@ from esphome.const import ( UNIT_VOLT, UNIT_WATT, ) -from esphome.core import CORE, HexInt -from esphome.happy_eyeballs import ensure_happy_eyeballs +from esphome.core import HexInt +from esphome.external_files import RemoteFile +from esphome.types import ConfigType DOMAIN = "shelly_dimmer" AUTO_LOAD = ["sensor"] @@ -76,46 +75,85 @@ def parse_firmware_version(value): return major, minor -def get_firmware(value): +def _firmware_cache_path(name: str) -> Path: + return external_files.compute_local_file_dir(DOMAIN) / f"{name}_fw_stm.bin" + + +def _firmware_path(url: str, sha: str | None) -> Path: + """Cache path for a firmware blob: sha-keyed when verifiable, else + URL-keyed. Shared by the validator and the prefetch hook.""" + return _firmware_cache_path( + sha.lower() if sha else external_files.url_cache_key(url) + ) + + +def get_firmware(value: ConfigType) -> list[HexInt] | None: if not value[CONF_UPDATE]: return None - def dl(url): - try: - ensure_happy_eyeballs() - req = requests.get(url, timeout=30) - req.raise_for_status() - except requests.exceptions.RequestException as e: - raise cv.Invalid(f"Could not download firmware file ({url}): {e}") from e - - h = hashlib.new("sha256") - h.update(req.content) - return req.content, h.hexdigest() - url = value[CONF_URL] - if CONF_SHA256 in value: # we have a hash, enable caching - path = Path(CORE.data_dir) / DOMAIN / (value[CONF_SHA256] + "_fw_stm.bin") - - if not path.is_file(): - firmware_data, dl_hash = dl(url) - - if dl_hash != value[CONF_SHA256]: - raise cv.Invalid( - f"Hash mismatch for {url}: {dl_hash} != {value[CONF_SHA256]}" - ) - - path.parent.mkdir(exist_ok=True, parents=True) - path.write_bytes(firmware_data) - - else: + if expected := value.get(CONF_SHA256): + expected = expected.lower() + path = _firmware_path(url, expected) + if path.is_file(): firmware_data = path.read_bytes() - else: # no caching, download every time - firmware_data, dl_hash = dl(url) + if hashlib.sha256(firmware_data).hexdigest() == expected: + return [HexInt(x) for x in firmware_data] + # A corrupted or foreign cache entry must never be trusted just + # because the file exists; discard it and download again. + path.unlink() + firmware_data = external_files.download_content(url, path) + if (actual := hashlib.sha256(firmware_data).hexdigest()) != expected: + path.unlink(missing_ok=True) + raise cv.Invalid(f"Hash mismatch for {url}: {actual} != {expected}") + else: + # No hash to verify the bytes, so an unrevalidated copy is an + # error rather than a silent fallback. + firmware_data = external_files.download_content( + url, + _firmware_path(url, None), + allow_stale=False, + ) return [HexInt(x) for x in firmware_data] +def _extract_firmware_ref(entry: ConfigType) -> RemoteFile | None: + firmware = entry.get(CONF_FIRMWARE) + if not isinstance(firmware, dict): + return None + try: + # cv.boolean, not truthiness: `update: "false"` is a valid False. + if not cv.boolean(firmware.get(CONF_UPDATE, False)): + return None + except cv.Invalid: + return None + url = firmware.get(CONF_URL) + sha = firmware.get(CONF_SHA256) + if url is None and (known := KNOWN_FIRMWARE.get(str(firmware.get(CONF_VERSION)))): + url, sha = known + if not isinstance(url, str): + return None + if sha is not None: + # Reject anything but a well-formed hash; a raw string would + # otherwise become a path component before validation runs. + try: + sha = validate_sha256(sha) + except (cv.Invalid, ValueError, TypeError): + return None + path = _firmware_path(url, sha) + if sha is not None and path.is_file(): + # Content-addressed and already on disk; get_firmware verifies it + # by hash, so there is nothing to revalidate. + return None + # No hash means no stale copies, matching the validator's policy. + return RemoteFile(url, path, allow_stale=sha is not None) + + +PREFETCH_FILES = external_files.single_stage_prefetch(_extract_firmware_ref) + + def validate_firmware(value): config = value.copy() if CONF_URL not in config: diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index b748959571..c8c36f0ffb 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -12,7 +12,7 @@ static const char *const TAG = "tinyusb"; void TinyUSB::setup() { // Use the device's MAC address as its serial number if no serial number is defined if (this->string_descriptor_[SERIAL_NUMBER] == nullptr) { - static char mac_addr_buf[13]; + static char mac_addr_buf[MAC_ADDRESS_BUFFER_SIZE]; get_mac_address_into_buffer(mac_addr_buf); this->string_descriptor_[SERIAL_NUMBER] = mac_addr_buf; } diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index 76ae145b16..dba9b925d0 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -248,7 +248,13 @@ void VoiceAssistant::stream_api_audio_() { msg.data2_len = available2; } - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + // Keep the chunk exposed and retry next pass, the same shape as + // APIConnection::try_send_camera_image_(): the slice is only lost if + // the ring buffer overflows before the TCP buffer clears, instead of + // on every refusal. The api layer already reports the refusal at V. + return; + } this->audio_source_->consume(available); if (this->audio_source2_ != nullptr) { @@ -477,7 +483,9 @@ void VoiceAssistant::loop() { api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Announce-finished"); + } break; } } @@ -741,7 +749,9 @@ void VoiceAssistant::signal_stop_() { ESP_LOGD(TAG, "Signaling stop"); api::VoiceAssistantRequest msg; msg.start = false; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Stop request"); + } } void VoiceAssistant::start_playback_timeout_() { @@ -753,7 +763,9 @@ void VoiceAssistant::start_playback_timeout_() { return; api::VoiceAssistantAnnounceFinished msg; msg.success = true; - this->api_client_->send_message(msg); + if (!this->api_client_->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Announce-finished"); + } }); } diff --git a/esphome/components/wake_on_lan/wake_on_lan.h b/esphome/components/wake_on_lan/wake_on_lan.h index ddf3433e7d..cef60c54f8 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.h +++ b/esphome/components/wake_on_lan/wake_on_lan.h @@ -3,6 +3,7 @@ #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) #include "esphome/components/button/button.h" #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) #include "esphome/components/socket/socket.h" #else @@ -27,7 +28,7 @@ class WakeOnLanButton final : public button::Button, public Component { #endif void press_action() override; uint16_t port_{9}; - uint8_t macaddr_[6]; + uint8_t macaddr_[MAC_ADDRESS_SIZE]; }; } // namespace esphome::wake_on_lan diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 2587d13b9e..c1887cc3fc 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import gzip import logging import re @@ -406,10 +407,21 @@ async def to_code(config): # The scheme is fixed at build time so the unused Basic/Digest code path is compiled # out. Basic is the current default (the absence of this define); an explicit # 'type: digest' opts in early. Default changes to digest in 2027.1.0. - if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST: + is_digest = auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST + if is_digest: cg.add_define("USE_WEBSERVER_AUTH_DIGEST") - cg.add(paren.set_auth_username(auth[CONF_USERNAME])) - cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) + if is_digest or CORE.is_esp32: + cg.add(paren.set_auth_username(auth[CONF_USERNAME])) + cg.add(paren.set_auth_password(auth[CONF_PASSWORD])) + else: + # Every non-ESP32 basic auth build takes this path. The ESP8266 and RP2040 + # core base64 encoders wrap output every 72 chars, which breaks + # ESPAsyncWebServer's basic auth compare for long credentials. + # Precompute the hash here and let C++ compare the raw header payload. + basic_hash = base64.b64encode( + f"{auth[CONF_USERNAME]}:{auth[CONF_PASSWORD]}".encode() + ).decode() + cg.add(paren.set_auth_basic_hash(basic_hash)) if CONF_CSS_INCLUDE in config: cg.add_define("USE_WEBSERVER_CSS_INCLUDE") path = CORE.relative_config_path(config[CONF_CSS_INCLUDE]) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3fe3979a8b..9e50b7a394 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -510,7 +510,7 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { response->addHeader(ESPHOME_F("Access-Control-Allow-Origin"), origin.empty() ? "*" : origin.c_str()); response->addHeader(ESPHOME_F("Access-Control-Allow-Private-Network"), ESPHOME_F("true")); response->addHeader(ESPHOME_F("Private-Network-Access-Name"), App.get_name().c_str()); - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; response->addHeader(ESPHOME_F("Private-Network-Access-ID"), get_mac_address_pretty_into_buffer(mac_s)); request->send(response); } diff --git a/esphome/components/web_server_base/web_server_base.cpp b/esphome/components/web_server_base/web_server_base.cpp index ccfc04f674..873c5b5a49 100644 --- a/esphome/components/web_server_base/web_server_base.cpp +++ b/esphome/components/web_server_base/web_server_base.cpp @@ -7,7 +7,7 @@ WebServerBase *global_web_server_base = nullptr; // NOLINT(cppcoreguidelines-av void WebServerBase::add_handler(AsyncWebHandler *handler) { #ifdef USE_WEBSERVER_AUTH - if (!credentials_.username.empty()) { + if (credentials_.is_set()) { handler = new internal::AuthMiddlewareHandler(handler, &credentials_); } #endif diff --git a/esphome/components/web_server_base/web_server_base.h b/esphome/components/web_server_base/web_server_base.h index 9657853a73..c647a13b50 100644 --- a/esphome/components/web_server_base/web_server_base.h +++ b/esphome/components/web_server_base/web_server_base.h @@ -1,7 +1,6 @@ #pragma once #include "esphome/core/defines.h" #if defined(USE_NETWORK) && !defined(USE_ZEPHYR) -#include #include #include "esphome/core/progmem.h" @@ -46,9 +45,20 @@ class MiddlewareHandler : public AsyncWebHandler { }; #ifdef USE_WEBSERVER_AUTH +// All fields point to string literals in generated code; nothing is copied. struct Credentials { - std::string username; - std::string password; +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + const char *username{nullptr}; + const char *password{nullptr}; + bool is_set() const { return username != nullptr; } +#else + // base64("username:password"), precomputed at codegen time. Used by every non-ESP32 basic + // auth build. The ESP8266 and RP2040 core libb64 wraps base64 output every 72 chars, so + // letting the library encode and compare fails for long credentials; instead the header + // payload is compared against this hash. + const char *basic_auth_hash{nullptr}; + bool is_set() const { return basic_auth_hash != nullptr; } +#endif }; class AuthMiddlewareHandler : public MiddlewareHandler { @@ -57,10 +67,14 @@ class AuthMiddlewareHandler : public MiddlewareHandler { : MiddlewareHandler(next), credentials_(credentials) {} bool check_auth(AsyncWebServerRequest *request) { - bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str()); + // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is + // compiled out. On ESP32 our own server picks the scheme internally. +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + bool success = request->authenticate(credentials_->username, credentials_->password); +#else + bool success = request->authenticate(credentials_->basic_auth_hash); +#endif if (!success) { - // The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is - // compiled out. On ESP32 our own server picks the scheme internally. #if USE_ESP32 request->requestAuthentication(); #elif defined(USE_WEBSERVER_AUTH_DIGEST) @@ -125,8 +139,12 @@ class WebServerBase final { AsyncWebServer *get_server() const { return this->server_; } #ifdef USE_WEBSERVER_AUTH - void set_auth_username(std::string auth_username) { credentials_.username = std::move(auth_username); } - void set_auth_password(std::string auth_password) { credentials_.password = std::move(auth_password); } +#if USE_ESP32 || defined(USE_WEBSERVER_AUTH_DIGEST) + void set_auth_username(const char *auth_username) { credentials_.username = auth_username; } + void set_auth_password(const char *auth_password) { credentials_.password = auth_password; } +#else + void set_auth_basic_hash(const char *hash) { credentials_.basic_auth_hash = hash; } +#endif #endif void add_handler(AsyncWebHandler *handler); diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9e78e7c48e..127eb50df1 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -1117,7 +1117,7 @@ void WiFiComponent::connect_soon_() { void WiFiComponent::start_connecting(const WiFiAP &ap) { // Log connection attempt at INFO level with priority - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; int8_t priority = 0; if (ap.has_bssid()) { @@ -2068,7 +2068,7 @@ void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { (old_priority > std::numeric_limits::min()) ? (old_priority - 1) : std::numeric_limits::min(); this->set_sta_priority(failed_bssid.value(), new_priority); } - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(failed_bssid.value().data(), bssid_s); ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "", bssid_s, old_priority, new_priority); diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index a851ea4015..ea043fd5c6 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -65,6 +65,12 @@ extern "C" { #include #endif +#ifdef USE_ESP32 +// Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h +// into this widely-included header. +using esp_netif_t = struct esp_netif_obj; +#endif + namespace esphome::wifi { /// Sentinel value for RSSI when WiFi is not connected @@ -469,6 +475,12 @@ class WiFiComponent final : public Component { bool is_connected() const { return this->connected_; } +#ifdef USE_ESP32 + /// esp_netif handle of the station interface, used by network for default-route + /// arbitration. nullptr until wifi_lazy_init_() has run. + esp_netif_t *get_esp_netif_sta(); +#endif + void set_power_save_mode(WiFiPowerSaveMode power_save); void set_min_auth_mode(WifiMinAuthMode min_auth_mode) { min_auth_mode_ = min_auth_mode; } void set_output_power(float output_power) { output_power_ = output_power; } diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index e082b2c8c1..719a276bf9 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -516,7 +516,7 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { (const char *) it.ssid); global_wifi_component->sta_state_ = static_cast(ESP8266WiFiSTAState::ERROR_NOT_FOUND); } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, LOG_STR_ARG(get_disconnect_reason_str(it.reason))); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 783c000f7b..245390b097 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -140,7 +140,7 @@ void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, voi } void WiFiComponent::wifi_pre_setup_() { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; if (has_custom_mac_address()) { get_mac_address_raw(mac); set_mac_address(mac); @@ -620,6 +620,8 @@ bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { return true; } +esp_netif_t *WiFiComponent::get_esp_netif_sta() { return s_sta_netif; } + network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { if (!this->has_sta()) return {}; @@ -860,7 +862,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGI(TAG, "Disconnected ssid='%.*s' reason='Station Roaming'", it.ssid_len, (const char *) it.ssid); return; } else { - char bssid_s[18]; + char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); ESP_LOGW(TAG, "Disconnected ssid='%.*s' bssid=" LOG_SECRET("%s") " reason='%s'", it.ssid_len, (const char *) it.ssid, bssid_s, get_disconnect_reason_str(it.reason)); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index ce9c4eb6ce..66c397a8ad 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -81,7 +81,7 @@ struct LTWiFiEvent { uint8_t scan_id; } scan_done; struct { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; int rssi; } ap_probe_req; } data; @@ -391,7 +391,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_AP_PROBEREQRECVED: { auto &it = info.wifi_ap_probereqrecved; - memcpy(to_send->data.ap_probe_req.mac, it.mac, 6); + memcpy(to_send->data.ap_probe_req.mac, it.mac, MAC_ADDRESS_SIZE); to_send->data.ap_probe_req.rssi = it.rssi; break; } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index b5ebfd7390..5d4e77eaad 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -1,5 +1,6 @@ #include "wifi_info_text_sensor.h" #ifdef USE_WIFI +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #ifdef USE_ESP8266 @@ -125,7 +126,7 @@ void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_lis void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } void BSSIDWiFiInfo::on_wifi_connect_state(StringRef ssid, std::span bssid) { - char buf[18] = "unknown"; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE] = "unknown"; if (mac_address_is_valid(bssid.data())) { format_mac_addr_upper(bssid.data(), buf); } diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 7ade170c02..eecedee133 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -87,7 +87,7 @@ class PowerSaveModeWiFiInfo final : public Component, class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { public: void setup() override { - char mac_s[18]; + char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; this->publish_state(get_mac_address_pretty_into_buffer(mac_s)); } void dump_config() override; diff --git a/esphome/components/xiaomi_ble/xiaomi_ble.cpp b/esphome/components/xiaomi_ble/xiaomi_ble.cpp index 0a05950c5a..06c3a7ab7a 100644 --- a/esphome/components/xiaomi_ble/xiaomi_ble.cpp +++ b/esphome/components/xiaomi_ble/xiaomi_ble.cpp @@ -293,7 +293,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c return false; } - uint8_t mac_reverse[6] = {0}; + uint8_t mac_reverse[MAC_ADDRESS_SIZE] = {0}; mac_reverse[5] = (uint8_t) (address >> 40); mac_reverse[4] = (uint8_t) (address >> 32); mac_reverse[3] = (uint8_t) (address >> 24); @@ -358,7 +358,7 @@ bool decrypt_xiaomi_payload(std::vector &raw, const uint8_t *bindkey, c #endif if (!decrypt_ok) { - uint8_t mac_address[6] = {0}; + uint8_t mac_address[MAC_ADDRESS_SIZE] = {0}; memcpy(mac_address, mac_reverse + 5, 1); memcpy(mac_address + 1, mac_reverse + 4, 1); memcpy(mac_address + 2, mac_reverse + 3, 1); diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index 5f56861e6d..6e3f109ca1 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -166,7 +166,9 @@ void ZWaveProxy::process_uart_slow_() { // If this is a data frame, use frame length indicator + 2 (for SoF + checksum), else assume 1 for ACK/NAK/CAN this->outgoing_proto_msg_.data_len = this->buffer_[0] == ZWAVE_FRAME_TYPE_START ? this->buffer_[1] + 2 : 1; } - this->api_connection_->send_message(this->outgoing_proto_msg_); + if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) { + ESP_LOGV(TAG, "Frame dropped, TCP buffer full"); + } } } } while (this->available()); @@ -328,7 +330,9 @@ void ZWaveProxy::send_homeid_changed_msg_(api::APIConnection *conn) { msg.data_len = this->home_id_.size(); if (conn != nullptr) { // Send to specific connection - conn->send_message(msg); + if (!conn->send_message(msg)) { + API_LOG_MSG_DROPPED(TAG, "Home ID notification"); + } } else if (api::global_api_server != nullptr) { // We could add code to manage a second subscription type, but, since this message is // very infrequent and small, we simply send it to all clients @@ -483,7 +487,9 @@ void ZWaveProxy::parse_start_(uint8_t byte) { this->buffer_[0] = byte; this->outgoing_proto_msg_.data = this->buffer_.data(); this->outgoing_proto_msg_.data_len = 1; - this->api_connection_->send_message(this->outgoing_proto_msg_); + if (!this->api_connection_->send_message(this->outgoing_proto_msg_)) { + ESP_LOGV(TAG, "Frame dropped, TCP buffer full"); + } } } diff --git a/esphome/config.py b/esphome/config.py index b747c69b3a..987bb9c96a 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1,14 +1,15 @@ from __future__ import annotations import abc -from contextlib import contextmanager +from collections.abc import Iterator +from contextlib import contextmanager, suppress import contextvars import copy import functools import heapq import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -40,6 +41,9 @@ from esphome.util import OrderedDict, safe_print from esphome.voluptuous_schema import ExtraKeysInvalid from esphome.yaml_util import ESPHomeDataBase, ESPLiteralValue, is_secret +if TYPE_CHECKING: + from esphome.external_files import RemoteFile + _LOGGER = logging.getLogger(__name__) @@ -717,6 +721,125 @@ class AutoLoadValidationStep(ConfigValidationStep): ) +# Backstop against a runaway PREFETCH_FILES generator; no real component +# needs anywhere near this many stages (font, the deepest, uses two). +_MAX_PREFETCH_STAGES = 10 + + +class PrefetchRemoteFilesValidationStep(ConfigValidationStep): + """Batch-download remote files referenced by the raw config. + + Each round, the batches yielded by every ``PREFETCH_FILES`` hook (see + ``ComponentManifest.prefetch_files``) download in one parallel pass, so + per-entry schema validators find a warm cache. Must run between + AutoLoadValidationStep (-1.0) and MetadataValidationStep (-2.0): + metadata steps push priority-0 schema steps that pop immediately, so + this is the last point where every raw entry list is intact. Best + effort: failures are logged and memoized per run; the per-entry + validators stay authoritative. + """ + + priority = -1.5 + + def run(self, result: Config) -> None: + active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + + def warn_hook_failed(name: str, err: Exception) -> None: + # A broken hook must not fail validation; it only loses the + # batching speedup. + _LOGGER.warning("Remote file prefetch for %s failed: %s", name, err) + _LOGGER.debug("Prefetch hook traceback", exc_info=err) + + def start_hook( + name: str, manifest: ComponentManifest, entries: list[ConfigType] + ) -> None: + if (hook := manifest.prefetch_files) is None: + return + try: + active.append((name, iter(hook(entries)))) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + + for domain, conf in result.items(): + if not isinstance(domain, str) or domain.startswith("."): + continue + if (component := get_component(domain)) is None: + continue + if component.prefetch_files is None and not component.is_platform_component: + continue + if conf is None or isinstance(conf, core.AutoLoad): + continue + entries = [ + entry + for entry in (conf if isinstance(conf, list) else [conf]) + if isinstance(entry, dict) + ] + if not entries: + continue + # A domain-level hook on a platform component receives every + # entry; overlap with per-platform hooks dedupes by path. + start_hook(domain, component, entries) + if not component.is_platform_component: + continue + by_platform: dict[str, list[ConfigType]] = {} + for entry in entries: + if isinstance(p_name := entry.get(CONF_PLATFORM), str): + by_platform.setdefault(p_name, []).append(entry) + for p_name, p_entries in by_platform.items(): + if (platform := get_platform(domain, p_name)) is not None: + start_hook(f"{domain}.{p_name}", platform, p_entries) + + # One stage per round; later stages can read what earlier ones + # fetched. + for _ in range(_MAX_PREFETCH_STAGES): + if not active: + break + items: list[RemoteFile] = [] + still_active: list[tuple[str, Iterator[list[RemoteFile]]]] = [] + for name, generator in active: + try: + batch = list(next(generator)) + except StopIteration: + continue + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + warn_hook_failed(name, err) + continue + items.extend(batch) + still_active.append((name, generator)) + active = still_active + self._download(items) + for name, generator in active: + # A tripped backstop means a broken hook. + _LOGGER.warning( + "Remote file prefetch for %s stopped after %d stages", + name, + _MAX_PREFETCH_STAGES, + ) + if (close := getattr(generator, "close", None)) is not None: + # close() runs hook code too; it must not fail validation. + with suppress(Exception): + close() + + @staticmethod + def _download(items: list[RemoteFile]) -> None: + if not items: + return + # Imported lazily: requests is a heavy import (~85ms) and is only + # needed when a config actually references remote files. + from esphome import external_files + + try: + external_files.download_content_many(items, description="remote file(s)") + except cv.Invalid as err: + # INFO: the trace if an extractor's cache path ever drifts from + # its validator's, hiding the memoized failure replay. + _LOGGER.info("Remote file prefetch download failed: %s", err) + except Exception as err: # noqa: BLE001 # pylint: disable=broad-except + # The batch downloader itself broke; make it visible. + _LOGGER.warning("Remote file prefetch failed: %s", err) + _LOGGER.debug("Prefetch download traceback", exc_info=err) + + class MetadataValidationStep(ConfigValidationStep): """Validate component metadata @@ -1259,6 +1382,7 @@ def validate_config( for domain, conf in config.items(): result.add_validation_step(LoadValidationStep(domain, conf)) + result.add_validation_step(PrefetchRemoteFilesValidationStep()) result.add_validation_step(IDPassValidationStep()) result.add_validation_step(CoreFinalValidateStep()) result.add_validation_step(PinUseValidationCheck()) diff --git a/esphome/const.py b/esphome/const.py index a3e9f47909..0dd948544f 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2026.8.0-dev" +__version__ = "2026.9.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 1a5f4f2cf5..534b740a5d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -885,6 +885,11 @@ class EsphomeCore: return self.relative_build_path("build", "bootloader", "bootloader.bin") return self.relative_pioenvs_path(self.name, "bootloader.bin") + @property + def is_configured(self) -> bool: + """Whether anything has set this CORE up for a target.""" + return KEY_CORE in self.data + @property def target_platform(self): return self.data[KEY_CORE][KEY_TARGET_PLATFORM] diff --git a/esphome/core/alloc_helpers.cpp b/esphome/core/alloc_helpers.cpp index 27c50ebb2a..d9cfad70b9 100644 --- a/esphome/core/alloc_helpers.cpp +++ b/esphome/core/alloc_helpers.cpp @@ -144,7 +144,7 @@ std::vector base64_decode(const std::string &encoded_string) { // --- Hex/binary formatting helpers --- std::string format_mac_address_pretty(const uint8_t *mac) { - char buf[18]; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(mac, buf); return std::string(buf); } @@ -206,9 +206,9 @@ std::string format_bin(const uint8_t *data, size_t length) { // --- MAC address helpers --- std::string get_mac_address() { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); - char buf[13]; + char buf[MAC_ADDRESS_BUFFER_SIZE]; format_mac_addr_lower_no_sep(mac, buf); return std::string(buf); } diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d610b22601..2b5d767aff 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -138,6 +138,7 @@ #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE #define USE_NETWORK +#define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI #define USE_NEXTION_COMMAND_SPACING #define USE_NEXTION_CONF_START_UP_PAGE @@ -262,13 +263,15 @@ #define USE_BLUETOOTH_PROXY // Mirror the codegen values per platform: _to_code_esp32() emits the connection // count (default 3) and the scanner-state push slot, _to_code_ble_hub() emits -// the slot count (1 on rp2, 0 on advertisement-only hubs) — so static analysis +// the slot count (3 on rp2, 0 on advertisement-only hubs) — so static analysis // checks the same instantiations a real build produces. #ifdef USE_ESP32 #define USE_BLE_SCANNER_STATE_CALLBACK #define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define USE_BLUETOOTH_PROXY_CONNECTIONS #elif defined(USE_RP2) -#define BLUETOOTH_PROXY_MAX_CONNECTIONS 1 +#define BLUETOOTH_PROXY_MAX_CONNECTIONS 3 +#define USE_BLUETOOTH_PROXY_CONNECTIONS #else #define BLUETOOTH_PROXY_MAX_CONNECTIONS 0 #endif @@ -483,7 +486,7 @@ #define ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT 1 #define USE_BLE_SCAN_RESPONSE_MERGER #define USE_BLE_GATT_CLIENT -#define ESPHOME_BLE_GATT_CLIENT_COUNT 1 +#define ESPHOME_BLE_GATT_CLIENT_COUNT 3 #define ESPHOME_BLE_CLIENT_MAX_NODES 1 #define USE_RP2040_VARIANT_RP2040 #define USE_SPI diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index c8cf85d7d6..8c4442f1b2 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -808,13 +808,13 @@ void HighFrequencyLoopRequester::stop() { // get_mac_address, get_mac_address_pretty moved to alloc_helpers.cpp void get_mac_address_into_buffer(std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_lower_no_sep(mac, buf.data()); } const char *get_mac_address_pretty_into_buffer(std::span buf) { - uint8_t mac[6]; + uint8_t mac[MAC_ADDRESS_SIZE]; get_mac_address_raw(mac); format_mac_addr_upper(mac, buf.data()); return buf.data(); diff --git a/esphome/espidf/framework.py b/esphome/espidf/framework.py index 39bf0465d5..0f6ef873b8 100644 --- a/esphome/espidf/framework.py +++ b/esphome/espidf/framework.py @@ -458,11 +458,16 @@ def _clone_idf_with_submodules( key = f"{git_url}@{ref}" if ref else git_url _LOGGER.info("Cloning ESP-IDF from %s", key) - run_git_command(["git", "clone", "--depth=1", "--", git_url, str(framework_path)]) + run_git_command( + ["git", "clone", "--depth=1", "--", git_url, str(framework_path)], + network=True, + retry_cleanup=framework_path, + ) if ref: run_git_command( ["git", "fetch", "--depth=1", "--", "origin", ref], git_dir=framework_path, + network=True, ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], diff --git a/esphome/espidf/runner.py b/esphome/espidf/runner.py index 7c568db7be..7ed11d7554 100644 --- a/esphome/espidf/runner.py +++ b/esphome/espidf/runner.py @@ -90,6 +90,7 @@ def main() -> int: sys.path.pop(0) # ---- end sys.path fix-up ----------------------------------------------- + import contextlib import os from pathlib import Path import re @@ -143,12 +144,14 @@ def main() -> int: * ``isatty()`` unconditionally returns True, tricking downstream code into emitting TTY-format output. - * Input is split on ``\\n`` / ``\\r`` via - ``str.splitlines(keepends=True)`` and any complete line whose + * Input is split with ``str.splitlines(keepends=True)``, which + breaks on more than ``\\n`` and ``\\r``; form feed and a few + other control characters count too. Any piece whose ANSI-stripped, right-stripped form matches one of ``filter_lines`` is dropped. - * Incomplete trailing chunks are held in a buffer until a - terminator arrives. + * Only the final piece can still be waiting for more text, so + that one is held until a ``\\n`` or ``\\r`` arrives. A piece + that ended on one of the other breaks goes out as it is. Mirrors the matching semantics of ``esphome.util.RedirectText`` so filter patterns behave identically in both the PlatformIO @@ -179,6 +182,44 @@ def main() -> int: def flush(self) -> None: self._stream.flush() + def _emit(self, line: str) -> None: + if self._filter_pattern is not None: + stripped = ansi_escape.sub("", line).rstrip() + if self._filter_pattern.match(stripped) is not None: + return + self._stream.write(line) + + def drain(self) -> None: + """Write out a held-back line that never got its terminator. + + idf.py and CMake do not always end their last line with a + newline, and a build that dies part way through can stop mid + line. Without this the user is left staring at a build that + ended with no explanation. + """ + if not self._line_buffer: + return + line, self._line_buffer = self._line_buffer, "" + try: + # Add the terminator the line never got, so whatever ESPHome + # prints next does not run onto the same line. + self._emit(line + "\n") + self._stream.flush() + except (OSError, ValueError) as err: + # We are called from cleanup, so raising would replace the + # build's real exit code. Saying so must not raise either: + # under the dashboard our stdout and stderr are the same + # pipe, so whatever broke the write has most likely broken + # the report, and ``sys.__stderr__`` is None on some + # interpreters. Carry the line along; it is usually the + # message saying why the build failed. + if (real_stderr := sys.__stderr__) is not None: + with contextlib.suppress(OSError, ValueError): + print( + f"Could not write out remaining output ({err}): {line}", + file=real_stderr, + ) + def write(self, data) -> int: # Text streams normally hand us ``str``; decode in case # somebody writes bytes directly. @@ -186,21 +227,32 @@ def main() -> int: data = data.decode(errors="replace") if self._filter_pattern is None: - self._stream.write(data) - return len(data) + # Nothing to match against, so no need to wait for a full line. + self._emit(data) + else: + lines = (self._line_buffer + data).splitlines(keepends=True) + # Every piece but the last ends with something + # ``str.splitlines`` treats as a break, so only the last one + # can still be waiting for more text. Hold that one, write + # out the rest. + # + # Some of those breaks are not line endings to us, a form + # feed for one, so a piece can go out without ending in a + # newline. That beats what we did before, which was to stop + # at the first such piece and drop every complete line + # behind it. + if lines and not lines[-1].endswith(("\n", "\r")): + self._line_buffer = lines.pop() + else: + self._line_buffer = "" + for line in lines: + self._emit(line) - self._line_buffer += data - for line in self._line_buffer.splitlines(keepends=True): - if "\n" not in line and "\r" not in line: - # Incomplete — hold until we see a terminator. - self._line_buffer = line - break - self._line_buffer = "" - - stripped = ansi_escape.sub("", line).rstrip() - if self._filter_pattern.match(stripped) is not None: - continue - self._stream.write(line) + # We tell idf.py it is talking to a terminal, so it sends progress + # bars and cursor moves. Our own stdout is usually a pipe, which is + # block buffered, so without this the build looks frozen until + # 8 KiB of output piles up. + self._stream.flush() return len(data) if len(sys.argv) < 2: @@ -217,8 +269,8 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[2:]) filter_lines = None if is_verbose else FILTER_IDF_LINES or None - sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] - sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] + stdout_shim = sys.stdout = _FilteringTTYStream(sys.stdout, filter_lines) # type: ignore[assignment] + stderr_shim = sys.stderr = _FilteringTTYStream(sys.stderr, filter_lines) # type: ignore[assignment] # Shift argv so the target script sees its own path as argv[0] and # its own arguments starting at argv[1]. runpy.run_path does not @@ -236,8 +288,19 @@ def main() -> int: # If idf.py calls sys.exit(), SystemExit propagates out of run_path # and carries the exit code back to our caller. For normal returns, - # fall through and exit with 0. - runpy.run_path(script_path, run_name="__main__") + # fall through and exit with 0. Either way the streams get a chance to + # release a last line that never got its terminator. Drain the shims we + # made rather than sys.stdout, which the script is free to replace, and + # report instead of raising so cleanup cannot bury the real exit code. + try: + runpy.run_path(script_path, run_name="__main__") + finally: + # Drain stderr from a finally so a surprise from the first one cannot + # strand the second. + try: + stdout_shim.drain() + finally: + stderr_shim.drain() return 0 diff --git a/esphome/external_files.py b/esphome/external_files.py index 160a2b6c29..f30d429425 100644 --- a/esphome/external_files.py +++ b/esphome/external_files.py @@ -1,16 +1,16 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Iterator from concurrent.futures import ThreadPoolExecutor import contextlib +from dataclasses import dataclass, field from datetime import UTC, datetime +import hashlib import logging import os from pathlib import Path import time -import requests - import esphome.config_validation as cv from esphome.const import CONF_FILE, CONF_TYPE, CONF_URL, __version__ from esphome.core import CORE, EsphomeError, TimePeriodSeconds @@ -21,8 +21,54 @@ from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@landonr"] +DOMAIN = "external_files" + NETWORK_TIMEOUT = 30 + +@dataclass(frozen=True, slots=True) +class RemoteFile: + """A remote file to prefetch, yielded in stages by ``PREFETCH_FILES`` + hooks. A dataclass rather than a tuple so fields can be added later.""" + + url: str + path: Path + # False when nothing downstream can verify the bytes; a copy that + # cannot be revalidated is then an error, not a silent fallback. + allow_stale: bool = True + + +@dataclass(frozen=True, slots=True) +class FailedDownload: + """What went wrong for a cache path this run, kept for fast replay.""" + + url: str + message: str + cause: BaseException + + +@dataclass +class ExternalFilesRunData: + """Per-run download state, cleared by ``CORE.reset()`` between runs.""" + + # Verified fresh this run; later touches skip even the conditional HEAD. + fresh_paths: set[Path] = field(default_factory=set) + # Served from disk without revalidation; strict callers reject these. + stale_paths: set[Path] = field(default_factory=set) + # Served under skip_external_update, deliberately unchecked; skips the + # network like fresh_paths but never counts as verified. + unchecked_paths: set[Path] = field(default_factory=set) + # Failed with no usable copy; later touches replay the error fast. + failed_paths: dict[Path, FailedDownload] = field(default_factory=dict) + + +def _run_data() -> ExternalFilesRunData: + if (data := CORE.data.get(DOMAIN)) is not None: + return data + # setdefault: first touch may race on download_content_many's workers. + return CORE.data.setdefault(DOMAIN, ExternalFilesRunData()) + + IF_MODIFIED_SINCE = "If-Modified-Since" IF_NONE_MATCH = "If-None-Match" ETAG = "ETag" @@ -93,6 +139,9 @@ def _write_etag(local_file_path: Path, etag: str | None) -> None: def has_remote_file_changed( url: str, local_file_path: Path, timeout: int = NETWORK_TIMEOUT ) -> bool: + # Deferred so configs with no remote files skip the heavy import. + import requests + ensure_happy_eyeballs() if local_file_path.exists(): _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) @@ -127,6 +176,9 @@ def has_remote_file_changed( ) if (new_etag := response.headers.get(ETAG)) and new_etag != etag: _write_etag(local_file_path, new_etag) + # A confirmed 304 supersedes any earlier failed + # revalidation of this file. + _run_data().stale_paths.discard(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File modified") return True @@ -136,6 +188,9 @@ def has_remote_file_changed( url, e, ) + # The copy is a fallback, not a verified 304; record that so + # callers that must not use unverified bytes can reject it. + _run_data().stale_paths.add(local_file_path) return False _LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path) @@ -159,14 +214,81 @@ def compute_local_file_dir(domain: str) -> Path: return base_directory -def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> bytes: +def url_cache_key(url: str) -> str: + """Short stable cache key for a URL.""" + return hashlib.sha256(url.encode()).hexdigest()[:8] + + +def compute_local_file_path(domain: str, url: str) -> Path: + """Cache path for a URL-keyed download under the domain's cache dir. + + Pure (no mkdir); parent directories are created at write time. + """ + return Path(CORE.data_dir) / domain / url_cache_key(url) + + +def is_fresh_this_run(path: Path) -> bool: + """Whether `path` was verified or downloaded during this run.""" + return path in _run_data().fresh_paths + + +def download_content( + url: str, + path: Path, + timeout: int = NETWORK_TIMEOUT, + allow_stale: bool = True, + return_content: bool = True, +) -> bytes: + """Download `url` into `path` and return the bytes, using the cache. + + On network failure an on-disk copy is served with a warning, unless + ``allow_stale=False``. ``CORE.skip_external_update`` always serves the + copy. ``return_content=False`` skips the disk read on cache hits. + """ + + # Deferred so configs with no remote files skip the heavy import. + import requests + + def _cached() -> bytes: + return path.read_bytes() if return_content else b"" + + # Memoized paths skip the network entirely; concurrent access is safe + # because download_content_many dedupes by path before fanning out. + run_data = _run_data() + fresh_paths = run_data.fresh_paths + if (path in fresh_paths or path in run_data.unchecked_paths) and path.exists(): + return _cached() + if allow_stale and path in run_data.stale_paths and path.exists(): + # Strict callers fall through to try the network themselves. + _LOGGER.info("Using cached copy of %s that could not be revalidated", url) + return _cached() + if (failure := run_data.failed_paths.get(path)) is not None: + if not path.exists(): + if failure.url == url: + raise cv.Invalid(failure.message) from failure.cause + raise cv.Invalid( + f"Could not download from {url}: an earlier download of " + f"{failure.url} to the same cache file failed: {failure.cause}" + ) from failure.cause + # The file appeared since the failure; revalidate normally. + del run_data.failed_paths[path] ensure_happy_eyeballs() if CORE.skip_external_update and path.exists(): _LOGGER.debug("Skipping update for %s (refresh disabled)", url) - return path.read_bytes() + run_data.unchecked_paths.add(path) + return _cached() if not has_remote_file_changed(url, path, timeout): + if path in run_data.stale_paths: + # The HEAD fell back to the copy without confirming it. + if not allow_stale: + raise cv.Invalid( + f"Could not check {url} for updates due to a network error " + f"and the cached copy cannot be verified" + ) + return _cached() _LOGGER.debug("Remote file has not changed %s", url) - return path.read_bytes() + fresh_paths.add(path) + return _cached() _LOGGER.info("Downloading %s", url) _LOGGER.debug("Saving to %s", path) @@ -185,16 +307,24 @@ def download_content(url: str, path: Path, timeout: int = NETWORK_TIMEOUT) -> by data = req.content except requests.exceptions.RequestException as e: if path.exists(): + # Memoized so a flaky host warns once per run, not per consumer. + run_data.stale_paths.add(path) + if not allow_stale: + raise cv.Invalid(f"Could not download from {url}: {e}") from e _LOGGER.warning( "Could not download from %s due to network error (%s), using cached file", url, e, ) - return path.read_bytes() - raise cv.Invalid(f"Could not download from {url}: {e}") from e + return _cached() + message = f"Could not download from {url}: {e}" + run_data.failed_paths[path] = FailedDownload(url, message, e) + raise cv.Invalid(message) from e write_file(path, data) _write_etag(path, req.headers.get(ETAG)) + fresh_paths.add(path) + run_data.stale_paths.discard(path) return data @@ -207,50 +337,47 @@ DEFAULT_DOWNLOAD_WORKERS = 8 def download_content_many( - items: Iterable[tuple[str, Path]], + items: Iterable[RemoteFile], timeout: int = NETWORK_TIMEOUT, max_workers: int = DEFAULT_DOWNLOAD_WORKERS, description: str = "remote file(s)", ) -> None: - """Run `download_content` for each (url, path) pair concurrently. + """Run `download_content` for each `RemoteFile` concurrently. - `description` names the kind of files in the progress log line, e.g. - "wake word manifest(s)". - - Wall time drops from `sum(latency)` to roughly `max(latency)` for cached - files where the HEAD round-trip dominates. All workers run to - completion before this returns; every `cv.Invalid` raised by a worker - is collected and surfaced together as `cv.MultipleInvalid` so the user - sees every broken file in a single validation pass instead of fixing - them one round-trip at a time. - - Items are de-duplicated by `path` -- two callers asking for the same - cache file (e.g. the same URL referenced twice in a config) would - otherwise race on `download_content`'s non-atomic write. When the - same `path` appears more than once, the last URL wins (standard dict - comprehension semantics); in practice duplicate paths only arise when - the URL is duplicated, so the choice doesn't matter. + `description` names the files in the progress log line. All workers run + to completion; every `cv.Invalid` raised is surfaced together as + `cv.MultipleInvalid`. Items dedupe by `path` (avoiding write races on + the same cache file); the last URL wins and a strict + `allow_stale=False` from any duplicate is kept. """ - seen: dict[Path, str] = {path: url for url, path in items} - if not seen: + seen: dict[Path, RemoteFile] = {} + for file in items: + if (prior := seen.get(file.path)) is not None and not prior.allow_stale: + file = RemoteFile(file.url, file.path, allow_stale=False) + seen[file.path] = file + unique = list(seen.values()) + if not unique: return ensure_happy_eyeballs() - _LOGGER.info("Checking %d %s for updates", len(seen), description) - if len(seen) == 1: - path, url = next(iter(seen.items())) - download_content(url, path, timeout) + _LOGGER.info("Checking %d %s for updates", len(unique), description) + + def _download_one(file: RemoteFile) -> None: + download_content( + file.url, + file.path, + timeout, + allow_stale=file.allow_stale, + return_content=False, + ) + + if len(unique) == 1: + _download_one(unique[0]) return - def _download_one(path_url: tuple[Path, str]) -> None: - # `seen` stores entries as (path, url) so the dict can dedupe by - # path; flip them back to download_content's (url, path) order. - path, url = path_url - download_content(url, path, timeout) - - workers = max(1, min(max_workers, len(seen))) + workers = max(1, min(max_workers, len(unique))) errors: list[cv.Invalid] = [] with ThreadPoolExecutor(max_workers=workers) as ex: - futures = [ex.submit(_download_one, item) for item in seen.items()] + futures = [ex.submit(_download_one, file) for file in unique] for future in futures: try: future.result() @@ -263,6 +390,21 @@ def download_content_many( raise cv.MultipleInvalid(errors) +def single_stage_prefetch( + extract: Callable[[ConfigType], RemoteFile | None], +) -> Callable[[list[ConfigType]], Iterator[list[RemoteFile]]]: + """Build a one-batch ``PREFETCH_FILES`` hook from a per-entry extractor. + + Covers the common case of one remote file per raw config entry; + components with staged downloads write their own generator. + """ + + def prefetch_files(entries: list[ConfigType]) -> Iterator[list[RemoteFile]]: + yield [ref for entry in entries if (ref := extract(entry)) is not None] + + return prefetch_files + + # Each component that uses external_files defines its own local # `TYPE_WEB = "web"`; the string is repeated here rather than imported # because there is no canonical `TYPE_WEB` in `esphome.const` to share. @@ -282,7 +424,7 @@ def download_web_files_in_config( slotted directly into a `cv.All(...)` chain. """ download_content_many( - (conf_file[CONF_URL], path_for(conf_file)) + RemoteFile(conf_file[CONF_URL], path_for(conf_file)) for entry in config if (conf_file := entry.get(CONF_FILE, {})).get(CONF_TYPE) == WEB_TYPE ) diff --git a/esphome/git.py b/esphome/git.py index d1dca3b3ae..9815377f51 100644 --- a/esphome/git.py +++ b/esphome/git.py @@ -5,6 +5,7 @@ from enum import Enum, auto import errno import hashlib import logging +import math import os from pathlib import Path import re @@ -77,6 +78,45 @@ _GIT_REPO_SCOPING_ENV = frozenset( } ) +# Substrings (matched case-insensitively against git's full stderr) that +# identify transient network failures worth retrying. Auth failures, +# missing repositories, and bad refs must fail immediately. Patterns are +# phrase-anchored so a repository URL quoted back in stderr never matches. +_TRANSIENT_GIT_ERROR_PATTERNS: tuple[str, ...] = ( + "unable to access", + "could not resolve host", + "could not connect", + "failed to connect", + "timed out", + "connection reset", + "connection refused", + "early eof", + "rpc failed", + "certificate verification failed", + # Anchored to curl's diagnostic prefix so repository URLs containing + # "ssl_" tokens never classify as transient + "openssl ssl_", + "ssl routines", + "ssl connect error", + "gnutls recv error", + "gnutls_handshake", + "unexpected disconnect", + "remote end hung up unexpectedly", +) + +# git quotes HTTP failures in two forms: curl's "The requested URL returned +# error: " and smart-HTTP's "RPC failed; HTTP curl ". 4xx is +# permanent (rejected credentials, missing repository) except 429 rate +# limiting; 408/425 are also treated as permanent, a deliberate trade for a +# simple rule since git hosts rarely emit them. +_PERMANENT_HTTP_ERROR_RE = re.compile(r"(?:http |returned error: )4(?!29)\d\d") + +# Network commands get 3 attempts with 2s/4s backoff. Worst case is ~3x +# the command's own duration plus 6s of sleep, held under the cache entry +# lock; peers with a complete entry fall back to it after +# _COMPLETE_ENTRY_LOCK_TIMEOUT_SECONDS. +_NETWORK_MAX_ATTEMPTS = 3 + class GitException(cv.Invalid): """Base exception for git-related errors.""" @@ -87,7 +127,18 @@ class GitNotInstalledError(GitException): class GitCommandError(GitException): - """Exception raised when a git command fails.""" + """Exception raised when a git command fails. + + ``stderr`` holds git's full stderr output; the exception message is + usually only the last ``fatal:`` line, but transient network markers + (``RPC failed``, ``GnuTLS``, ...) often appear on earlier lines. + Empty when git produced no stderr, so classification never reads the + command line (which embeds the user-supplied repository URL). + """ + + def __init__(self, message: str, stderr: str = "") -> None: + super().__init__(message) + self.stderr = stderr class GitRepositoryError(GitException): @@ -103,8 +154,23 @@ def _redact_url_credentials(text: str) -> str: return re.sub(r"://[^/@\s]+@", "://***@", text) +def _is_transient_git_error(stderr: str) -> bool: + """Return True when git's stderr looks like a transient network failure.""" + lowered = stderr.lower() + if _PERMANENT_HTTP_ERROR_RE.search(lowered): + return False + if "authentication failed" in lowered: + return False + return any(pattern in lowered for pattern in _TRANSIENT_GIT_ERROR_PATTERNS) + + def run_git_command( - cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None + cmd: list[str], + git_dir: Path | None = None, + *, + cwd: Path | None = None, + network: bool = False, + retry_cleanup: Path | None = None, ) -> str: """Run a git command and return its stdout. @@ -113,7 +179,50 @@ def run_git_command( to that repository and runs the command there; ``cwd`` alone runs the command in that directory with GIT_CEILING_DIRECTORIES capping repository discovery at its parent. + + ``network=True`` marks a command that talks to a remote (clone, fetch, + submodule update): transient network failures (DNS, TLS, dropped + connections) are retried with a short backoff so a momentary blip does + not fail the whole build. Local-only commands must not set it. + ``retry_cleanup`` names a directory to remove before each retry, for + commands like clone that can leave a partial destination behind. """ + attempts = _NETWORK_MAX_ATTEMPTS if network else 1 + attempt = 0 + while True: + try: + return _run_git_command_once(cmd, git_dir, cwd=cwd) + except GitCommandError as err: + attempt += 1 + if attempt >= attempts or not _is_transient_git_error(err.stderr): + raise + if retry_cleanup is not None and retry_cleanup.is_dir(): + try: + rmtree(retry_cleanup) + except OSError as cleanup_err: + # A retry would fail on the leftover directory anyway; + # give up and keep the git error as the reported cause. + _LOGGER.warning( + "Could not remove %s before retry (%s); not retrying", + retry_cleanup, + cleanup_err, + ) + raise err from None + delay = 2**attempt + _LOGGER.warning( + "Git command failed: %s. Retrying in %d seconds... (attempt %d/%d)", + _redact_url_credentials(str(err)), + delay, + attempt, + attempts, + ) + time.sleep(delay) + + +def _run_git_command_once( + cmd: list[str], git_dir: Path | None = None, *, cwd: Path | None = None +) -> str: + """Single attempt of ``run_git_command``; see its docstring.""" # Every invocation starts from an environment with the repository-scoping # variables stripped (see _GIT_REPO_SCOPING_ENV) so a git hook or CI # wrapper invoking ESPHome can never redirect these commands to its own @@ -168,11 +277,15 @@ def run_git_command( if ret.returncode != 0: if ret.stderr: - err_str = ret.stderr.decode("utf-8") + # errors="replace": git can emit locale-encoded (non-UTF-8) bytes + # in stderr; the error path must never raise UnicodeDecodeError. + err_str = ret.stderr.decode("utf-8", errors="replace") lines = [x.strip() for x in err_str.splitlines()] if lines[-1].startswith("fatal:"): - raise GitCommandError(lines[-1][len("fatal: ") :]) - raise GitCommandError(err_str) + raise GitCommandError(lines[-1][len("fatal: ") :], stderr=err_str) + raise GitCommandError(err_str, stderr=err_str) + # No stderr (e.g. git killed by a signal): nothing to classify, + # never retried. raise GitCommandError( f"git exited with code {ret.returncode}: " f"{_redact_url_credentials(' '.join(cmd))}" @@ -409,6 +522,7 @@ def update_submodules(repo_dir: Path, key: str) -> None: run_git_command( ["git", "submodule", "update", "--init", "--recursive", "--depth=1"], cwd=repo_dir, + network=True, ) @@ -605,7 +719,7 @@ def _clone_or_update_locked( try: cmd = ["git", "clone", "--depth=1"] cmd += ["--", url, str(repo_dir)] - run_git_command(cmd) + run_git_command(cmd, network=True, retry_cleanup=repo_dir) if ref is not None: # We need to fetch the PR branch first, otherwise git will complain @@ -614,6 +728,7 @@ def _clone_or_update_locked( run_git_command( ["git", "fetch", "--depth=1", "--", "origin", ref], git_dir=repo_dir, + network=True, ) run_git_command( ["git", "reset", "--hard", "FETCH_HEAD"], git_dir=repo_dir @@ -684,7 +799,57 @@ def _clone_or_update_locked( cmd = ["git", "fetch", "--depth=1", "--", "origin"] if ref is not None: cmd.append(ref) - run_git_command(cmd, git_dir=repo_dir) + fetch_head = Path(repo_dir) / ".git" / "FETCH_HEAD" + try: + fetch_head_stat = fetch_head.stat() + except OSError: + # Missing (or unreadable): no pre-fetch FETCH_HEAD + fetch_head_stat = None + try: + run_git_command(cmd, git_dir=repo_dir, network=True) + except GitCommandError as err: + if not _is_transient_git_error(err.stderr): + raise + # Verified clone, untouched worktree, network-only + # failure: keep the clone instead of destroying it via + # recovery, which would re-clone on the same dead + # network. The marker must be restored or the next run + # removes the entry as an incomplete clone. + # + # A failed fetch still freshens FETCH_HEAD's mtime, + # which would suppress refresh attempts for the whole + # refresh window; restore it so the next run retries. + try: + if fetch_head_stat is not None: + os.utime( + fetch_head, + (fetch_head_stat.st_atime, fetch_head_stat.st_mtime), + ) + else: + fetch_head.unlink(missing_ok=True) + except OSError as stamp_err: + # Cannot keep the fallback honest; let the git error + # route through the recovery below instead. + _LOGGER.warning( + "Could not restore the refresh timestamp for %s (%s)", + safe_key, + stamp_err, + ) + raise err from None + _LOGGER.warning( + "Could not refresh %s (%s); using the existing clone " + "at %s (last updated %s ago)", + safe_key, + _redact_url_credentials(str(err)), + old_sha, + # age_seconds is inf when neither FETCH_HEAD nor HEAD + # could be stat'ed; format_duration would overflow + format_duration(age_seconds) + if math.isfinite(age_seconds) + else "unknown time", + ) + _write_clone_complete_marker(repo_dir, key, hash_dir_name, safe_key) + return repo_dir, None # Hard reset to FETCH_HEAD (short-lived git ref corresponding to most recent fetch) run_git_command( @@ -719,7 +884,7 @@ def _clone_or_update_locked( _LOGGER.warning( "Repository %s has issues (%s), attempting recovery", safe_key, - err, + _redact_url_credentials(str(err)), ) _LOGGER.info("Removing broken repository at %s", repo_dir) _remove_repo_dir(repo_dir) diff --git a/esphome/helpers.py b/esphome/helpers.py index 15d9797ce1..2731109164 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -357,6 +357,24 @@ def resolve_ip_address( return res +def format_ip_url(family: int, sockaddr: tuple, port: int, path: str) -> str: + """Build an ``http://host:port/path`` URL for a resolved address. + + ``family``/``sockaddr`` come from a :func:`resolve_ip_address` entry. IPv6 + literals must be wrapped in brackets in URLs; link-local addresses need a + percent-encoded zone index per RFC 6874. + """ + import socket + + ip = sockaddr[0] + if family == socket.AF_INET6: + scope = sockaddr[3] if len(sockaddr) >= 4 else 0 + host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" + else: + host_part = ip + return f"http://{host_part}:{port}{path}" + + def sort_ip_addresses(address_list: list[str]) -> list[str]: """Takes a list of IP addresses in string form, e.g. from mDNS or MQTT, and sorts them into the best order to actually try connecting to them. diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index 9448b93cc9..6a9d7171ec 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -8,7 +8,7 @@ dependencies: esphome/esp-micro-speech-features: version: 1.2.3 esphome/micro-decoder: - version: 0.2.0 + version: 0.4.0 esphome/micro-flac: version: 0.2.0 esphome/micro-mp3: diff --git a/esphome/loader.py b/esphome/loader.py index 22db8b156a..7a659aa0a8 100644 --- a/esphome/loader.py +++ b/esphome/loader.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Callable, Iterable from contextlib import AbstractContextManager from dataclasses import dataclass import importlib @@ -16,6 +16,7 @@ from esphome.types import ConfigType if TYPE_CHECKING: from esphome.cpp_generator import MockObjClass + from esphome.external_files import RemoteFile # `esphome.core.config` is imported lazily in `_lookup_module` when the # "esphome" pseudo-component is first resolved. It pulls in @@ -135,6 +136,21 @@ class ComponentManifest: """ return getattr(self.module, "FINAL_VALIDATE_SCHEMA", None) + @property + def prefetch_files( + self, + ) -> Callable[[list[ConfigType]], Iterable[list["RemoteFile"]]] | None: + """Optional `PREFETCH_FILES` hook for batched remote file downloads. + + A generator called once per run with the component's raw, pre-schema + config entries; each yield is a stage of ``RemoteFile`` downloaded in + one parallel pass before schema validation, so a later stage may + derive URLs from earlier files' content. Best effort: skip anything + unrecognized. On platform components, place it on the platform + sub-module; a domain-module hook receives every entry. + """ + return getattr(self.module, "PREFETCH_FILES", None) + @property def legacy_config_migrate(self) -> Callable[[ConfigType], ConfigType | None] | None: """Optional `LEGACY_CONFIG_MIGRATE` callable on a platform component module. diff --git a/esphome/platformio/runner.py b/esphome/platformio/runner.py index c49220a044..9bb2205a90 100644 --- a/esphome/platformio/runner.py +++ b/esphome/platformio/runner.py @@ -179,12 +179,24 @@ def main() -> int: is_verbose = any(arg in ("-v", "--verbose") for arg in sys.argv[1:]) filter_lines = None if is_verbose else FILTER_PLATFORMIO_LINES - sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) - sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) + stdout_redirect = sys.stdout = RedirectText(sys.stdout, filter_lines=filter_lines) + stderr_redirect = sys.stderr = RedirectText(sys.stderr, filter_lines=filter_lines) import platformio.__main__ - return platformio.__main__.main() or 0 + # PlatformIO exits through ``sys.exit``, so drain from a finally to give + # a last line without a terminator a chance to reach the user. Drain the + # wrappers we made rather than sys.stdout, which PlatformIO is free to + # replace while it runs. + try: + return platformio.__main__.main() or 0 + finally: + # Drain stderr from a finally so a surprise from the first one cannot + # strand the second. + try: + stdout_redirect.drain() + finally: + stderr_redirect.drain() if __name__ == "__main__": diff --git a/esphome/platformio/toolchain.py b/esphome/platformio/toolchain.py index 32e30290ac..0e7ffce939 100644 --- a/esphome/platformio/toolchain.py +++ b/esphome/platformio/toolchain.py @@ -19,7 +19,7 @@ from esphome.helpers import ( rmtree, write_file, ) -from esphome.util import FlashImage, run_external_process +from esphome.util import ESP32_ARDUINO_ENV, FlashImage, run_external_process if TYPE_CHECKING: from platformio.project.config import ProjectConfig @@ -342,6 +342,13 @@ def run_platformio_cli(*args, **kwargs) -> str | int: base_env = kwargs.pop("env", None) env = dict(os.environ if base_env is None else base_env) env.update(_ccache_env()) + # The runner offers the out-of-flash tip but has no configured CORE, so + # tell it. Ask CORE, not is_esp32_arduino_build(), which reads this same + # variable; clear an inherited one so it cannot reach the wrong build. + if CORE.is_configured and CORE.is_esp32 and CORE.using_arduino: + env[ESP32_ARDUINO_ENV] = "1" + else: + env.pop(ESP32_ARDUINO_ENV, None) return run_external_process(*cmd, env=env, **kwargs) diff --git a/esphome/util.py b/esphome/util.py index 136d6362f2..2fc34f3a69 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -3,6 +3,7 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass import io import logging +import os from pathlib import Path import re import sys @@ -87,8 +88,11 @@ def safe_print(message="", end="\n"): except UnicodeEncodeError: pass + # Always flush: stdout is block buffered when it is a pipe (the dashboard + # runs us that way), so live log lines would otherwise sit in the buffer + # for a long time instead of streaming out. try: - print(message, end=end) + print(message, end=end, flush=True) return except UnicodeEncodeError: pass @@ -104,6 +108,7 @@ def safe_print(message="", end="\n"): print( message.encode(encoding, "backslashreplace").decode(encoding), end=end, + flush=True, ) return except UnicodeEncodeError: @@ -113,9 +118,10 @@ def safe_print(message="", end="\n"): print( message.encode("ascii", "backslashreplace").decode("ascii"), end=end, + flush=True, ) except UnicodeEncodeError: - print("Cannot print line because of invalid locale!") + print("Cannot print line because of invalid locale!", flush=True) def safe_input(prompt=""): @@ -136,6 +142,10 @@ def shlex_quote(s: str | Path) -> str: return "'" + s.replace("'", "'\"'\"'") + "'" +# Tells the PlatformIO runner subprocess, which has no configured CORE, that +# this is an ESP32 Arduino build. +ESP32_ARDUINO_ENV = "ESPHOME_ESP32_ARDUINO_BUILD" + ANSI_ESCAPE = re.compile(r"\033[@-_][0-?]*[ -/]*[@-~]") @@ -169,6 +179,51 @@ class RedirectText: s = s.replace("\033", "\\033") self._out.write(s) + def _emit_line(self, line: str) -> None: + line_without_ansi = ANSI_ESCAPE.sub("", line) + line_without_end = line_without_ansi.rstrip() + if ( + self._filter_pattern is not None + and self._filter_pattern.match(line_without_end) is not None + ): + # Filter pattern matched, ignore the line + return + + self._write_color_replace(line) + # Check for flash size error and provide helpful guidance + if ( + "Error: The program size" in line + and "is greater than maximum allowed" in line + and (help_msg := get_esp32_arduino_flash_error_help()) + ): + self._write_color_replace(help_msg) + for callback in self._line_callbacks: + if msg := callback(line_without_end): + self._write_color_replace(msg) + + def drain(self) -> None: + """Write out a held-back line that never got its terminator. + + A tool that dies part way through a line, or ends its output without + a final newline, would otherwise have that text sit in the buffer + and never reach the user. + """ + if not self._line_buffer: + return + line, self._line_buffer = self._line_buffer, "" + try: + # Add the terminator the line never got, so whatever ESPHome + # prints next does not run onto the same line. + self._emit_line(line + "\n") + self._out.flush() + except (OSError, ValueError) as err: + # Every caller drains from a cleanup path, where the command's + # real result is already on its way out; raising here would + # replace it with an unrelated traceback. Carry the line into + # the warning, since the stream we were told to write it to is + # the one that just failed. + _LOGGER.warning("Could not write out remaining output (%s): %s", err, line) + def write(self, s: str | bytes) -> int: # s is usually a str already (self._out is of type TextIOWrapper) # However, s is sometimes also a bytes object in python3. Let's make sure it's a @@ -179,38 +234,30 @@ class RedirectText: s = s.decode() if self._filter_pattern is not None or self._line_callbacks: - self._line_buffer += s - lines = self._line_buffer.splitlines(True) - for line in lines: - if "\n" not in line and "\r" not in line: - # Not a complete line, set line buffer - self._line_buffer = line - break + lines = (self._line_buffer + s).splitlines(True) + # Every piece but the last ends with something + # ``str.splitlines`` treats as a break, so only the last one can + # still be waiting for more text. Hold that one, write out the + # rest. + # + # Some of those breaks are not line endings to us, a form feed + # for one, so a piece can go out without ending in a newline. + # That beats what we did before, which was to stop at the first + # such piece and drop every complete line behind it. + if lines and not lines[-1].endswith(("\n", "\r")): + self._line_buffer = lines.pop() + else: self._line_buffer = "" - - line_without_ansi = ANSI_ESCAPE.sub("", line) - line_without_end = line_without_ansi.rstrip() - if ( - self._filter_pattern is not None - and self._filter_pattern.match(line_without_end) is not None - ): - # Filter pattern matched, ignore the line - continue - - self._write_color_replace(line) - # Check for flash size error and provide helpful guidance - if ( - "Error: The program size" in line - and "is greater than maximum allowed" in line - and (help_msg := get_esp32_arduino_flash_error_help()) - ): - self._write_color_replace(help_msg) - for callback in self._line_callbacks: - if msg := callback(line_without_end): - self._write_color_replace(msg) + for line in lines: + self._emit_line(line) else: self._write_color_replace(s) + # Same reason as safe_print: the dashboard gives us a pipe, which is + # block buffered, so in-process esptool progress would not show up + # until the buffer filled. + self._out.flush() + # write() returns the number of characters written # Let's print the number of characters of the original string in order to not confuse # any caller. @@ -251,11 +298,11 @@ def run_external_command( _LOGGER.debug("Running: %s", full_cmd) orig_stdout = sys.stdout - sys.stdout = RedirectText( + stdout_redirect = sys.stdout = RedirectText( sys.stdout, filter_lines=filter_lines, line_callbacks=line_callbacks ) orig_stderr = sys.stderr - sys.stderr = RedirectText( + stderr_redirect = sys.stderr = RedirectText( sys.stderr, filter_lines=filter_lines, line_callbacks=line_callbacks ) @@ -281,6 +328,18 @@ def run_external_command( sys.stdout = orig_stdout sys.stderr = orig_stderr + # Release a last line that never got its terminator. This runs after + # the real streams are back, and uses the wrappers we made rather + # than whatever the command left in sys.stdout, so it cannot strand + # them. With capture_stdout the stdout wrapper was never written to, + # so draining it does nothing. Drain stderr from a finally so a + # surprise from the first one cannot strand the second; a real bug + # still propagates, it just does not take the other line with it. + try: + stdout_redirect.drain() + finally: + stderr_redirect.drain() + if capture_stdout: return cap_stdout.getvalue() @@ -466,11 +525,24 @@ def detect_rp2040_bootsel(picotool_path: str | Path) -> BootselResult: return BootselResult(0) -def get_esp32_arduino_flash_error_help() -> str | None: - """Returns helpful message when ESP32 with Arduino runs out of flash space.""" +def is_esp32_arduino_build() -> bool: + """Whether the build targets ESP32 with the Arduino framework. + + The PlatformIO runner subprocess has no configured CORE, so the parent + passes the answer in the environment. + """ from esphome.core import CORE - if not (CORE.is_esp32 and CORE.using_arduino): + if not CORE.is_configured: + # The runner subprocess. A half filled in CORE still counts as + # configured, so reading from it raises instead of landing here. + return os.environ.get(ESP32_ARDUINO_ENV) == "1" + return CORE.is_esp32 and CORE.using_arduino + + +def get_esp32_arduino_flash_error_help() -> str | None: + """Returns helpful message when ESP32 with Arduino runs out of flash space.""" + if not is_esp32_arduino_build(): return None from esphome.log import AnsiFore, color diff --git a/esphome/web_server_helpers.py b/esphome/web_server_helpers.py new file mode 100644 index 0000000000..f48934b185 --- /dev/null +++ b/esphome/web_server_helpers.py @@ -0,0 +1,43 @@ +"""Shared helpers for the web_server HTTP transports (OTA upload and logs).""" + +from __future__ import annotations + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import CORE, EsphomeError +from esphome.helpers import format_ip_url, resolve_ip_address +from esphome.types import ConfigType + + +def resolve_web_server_urls(host: str, port: int, path: str) -> list[tuple[str, str]]: + """Resolve ``host`` to ``(ip, url)`` pairs for the web_server ``path``. + + Wraps :func:`resolve_ip_address` (honoring ``CORE.address_cache``) and + formats each resolved address into an ``http://host:port/path`` URL via + :func:`format_ip_url`, handling both IPv4 and IPv6. Shared by the + web_server OTA upload and log streaming paths. + """ + addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + return [ + (sockaddr[0], format_ip_url(family, sockaddr, port, path)) + for family, _socktype, _, _, sockaddr in addr_infos + ] + + +def get_web_server_connection(config: ConfigType) -> tuple[int, str | None, str | None]: + """Return ``(port, username, password)`` for the web_server HTTP endpoint. + + Reads the port and optional HTTP Basic-auth credentials from the validated + ``web_server:`` config, shared by the web_server OTA upload and log + streaming paths. Raises :class:`EsphomeError` if ``web_server`` is absent. + """ + web_conf = config.get(CONF_WEB_SERVER) + if not web_conf: + raise EsphomeError(f"The {CONF_WEB_SERVER} component is not configured.") + auth = web_conf.get(CONF_AUTH) or {} + return int(web_conf[CONF_PORT]), auth.get(CONF_USERNAME), auth.get(CONF_PASSWORD) diff --git a/esphome/web_server_logs.py b/esphome/web_server_logs.py new file mode 100644 index 0000000000..e091e24bb7 --- /dev/null +++ b/esphome/web_server_logs.py @@ -0,0 +1,189 @@ +"""Stream device logs over the ``web_server`` component's HTTP SSE endpoint. + +The ``web_server`` component exposes a Server-Sent Events stream at ``/events`` +that multiplexes entity state, keepalive pings, and log lines (``event: log``). +This is the logging counterpart to the web_server OTA upload path +(:mod:`esphome.web_server_ota`); it lets ``esphome logs`` reach a device that +has ``web_server:`` configured but no ``api:``. + +Only the ``event: log`` frames are rendered; the payload is the device's +already-formatted, ANSI-colored log line, so it is passed through the same +``LogParser`` + ``safe_print`` path the serial and native-API log viewers use. +The stream is long-lived and the server drops idle connections, so the reader +reconnects automatically until interrupted. +""" + +from __future__ import annotations + +from datetime import datetime +import logging +import time +from typing import TYPE_CHECKING + +import requests +from requests.auth import HTTPBasicAuth + +from esphome.core import EsphomeError +from esphome.util import safe_print +from esphome.web_server_helpers import resolve_web_server_urls + +if TYPE_CHECKING: + from aioesphomeapi import LogParser + +_LOGGER = logging.getLogger(__name__) + +EVENTS_PATH = "/events" +# (connect_timeout, read_timeout). The device sends a keepalive ``ping`` every +# 10s, so a 30s read timeout tolerates a few missed pings before we treat the +# connection as dead and reconnect. +TIMEOUT = (10.0, 30.0) +# Pause between reconnect attempts so a downed device doesn't spin the CPU. +RECONNECT_DELAY = 1.0 +# Upper bound for the exponential backoff applied to consecutive failures, so an +# unreachable host backs off instead of retrying (and logging) once a second. +MAX_RECONNECT_DELAY = 10.0 + + +class WebServerLogsError(EsphomeError): + """Raised when the web_server log stream cannot be used (e.g. bad auth).""" + + +def _build_urls(hosts: list[str], port: int) -> list[tuple[str, str]]: + """Resolve ``hosts`` to ``(ip, url)`` pairs for the ``/events`` endpoint.""" + urls: list[tuple[str, str]] = [] + seen: set[str] = set() + for host in hosts: + try: + resolved = resolve_web_server_urls(host, port, EVENTS_PATH) + except EsphomeError as err: + _LOGGER.warning("Error resolving IP address of %s: %s", host, err) + continue + for ip, url in resolved: + if url not in seen: + seen.add(url) + urls.append((ip, url)) + return urls + + +def _emit(data_lines: list[str], parser: LogParser) -> None: + """Render the accumulated ``data:`` lines of one ``event: log`` frame.""" + time_ = datetime.now().astimezone() + milliseconds = time_.microsecond // 1000 + time_str = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]" + ) + for line in data_lines: + safe_print(parser.parse_line(line, time_str)) + + +def _consume(response: requests.Response, parser: LogParser) -> None: + """Parse the SSE stream, rendering only ``event: log`` frames. + + Implements the minimal slice of the SSE grammar the ``web_server`` stream + uses: ``field: value`` lines (with one optional leading space after the + colon) accumulated until a blank line dispatches the frame. ``id:``, + ``retry:``, and comment (``:``) lines are ignored, as are non-``log`` + events (``ping``, ``state``, ...). + """ + event_type = "message" + data_lines: list[str] = [] + # Iterate bytes and decode as UTF-8 ourselves (matching run_miniterm); the + # text/event-stream response has no charset, so requests' decode_unicode + # would fall back to Latin-1 and mojibake UTF-8 log characters. + for raw in response.iter_lines(): + line = raw.decode("utf8", "backslashreplace") + if not line: + if event_type == "log" and data_lines: + _emit(data_lines, parser) + event_type = "message" + data_lines = [] + continue + if line.startswith(":"): + continue + field, _, value = line.partition(":") + value = value.removeprefix(" ") + if field == "event": + event_type = value + elif field == "data": + data_lines.append(value) + + +def _stream(url: str, ip: str, auth: HTTPBasicAuth | None, parser: LogParser) -> bool: + """Connect and stream one session. + + Returns ``True`` if a connection was established (even if it later + dropped), ``False`` if the connection attempt itself failed so the caller + can try the next resolved address. + """ + connected = False + _LOGGER.info("Connecting to %s ...", url) + try: + with requests.get( + url, + stream=True, + auth=auth, + timeout=TIMEOUT, + headers={"Accept": "text/event-stream"}, + ) as response: + if response.status_code == 401: + raise WebServerLogsError( + "Authentication failed (HTTP 401). Check the 'web_server' " + "'auth' username and password." + ) + if response.status_code in (403, 404): + # Permanent: the endpoint won't appear on retry (wrong version, + # 'log' disabled, or forbidden). Surface it instead of looping. + raise WebServerLogsError( + f"Device returned HTTP {response.status_code} for " + f"{EVENTS_PATH}; the web_server log stream is unavailable. " + "Ensure 'web_server' is version 2 or higher with 'log' enabled." + ) + if response.status_code != 200: + _LOGGER.error( + "Unexpected HTTP %s response from %s", response.status_code, ip + ) + return False + connected = True + _LOGGER.info("Connected to %s", ip) + _consume(response, parser) + except requests.RequestException as err: + if connected: + _LOGGER.info("Log stream from %s ended (%s); reconnecting...", ip, err) + else: + _LOGGER.warning("Could not connect to %s: %s", ip, err) + return connected + + +def run_logs( + hosts: list[str], + port: int, + username: str | None, + password: str | None, +) -> int: + """Stream logs from the first reachable host over the web_server SSE feed. + + Reconnects automatically when the stream drops and returns ``0`` on + ``KeyboardInterrupt`` (Ctrl+C), mirroring how the serial log viewer exits. + """ + from aioesphomeapi import LogParser + + auth = HTTPBasicAuth(username, password) if username and password else None + parser = LogParser() + delay = RECONNECT_DELAY + try: + while True: + if not (urls := _build_urls(hosts, port)): + _LOGGER.error("Could not resolve any of: %s", ", ".join(hosts)) + connected = False + else: + # ``any`` stops at the first address that connects; when that + # stream drops we reconnect to the same set on the next pass. + connected = any(_stream(url, ip, auth, parser) for ip, url in urls) + # Reset the backoff once we reach the device; otherwise grow it + # (capped) so an unreachable host doesn't retry/log once a second. + delay = ( + RECONNECT_DELAY if connected else min(delay * 2, MAX_RECONNECT_DELAY) + ) + time.sleep(delay) + except KeyboardInterrupt: + return 0 diff --git a/esphome/web_server_ota.py b/esphome/web_server_ota.py index 8d0fdeecff..7b508e8527 100644 --- a/esphome/web_server_ota.py +++ b/esphome/web_server_ota.py @@ -12,14 +12,14 @@ import io import logging from pathlib import Path import secrets -import socket from typing import BinaryIO import requests from requests.auth import HTTPBasicAuth from esphome.core import EsphomeError -from esphome.helpers import ProgressBar, resolve_ip_address +from esphome.helpers import ProgressBar +from esphome.web_server_helpers import resolve_web_server_urls _LOGGER = logging.getLogger(__name__) @@ -95,7 +95,7 @@ def _try_upload( from esphome.core import CORE try: - addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache) + addr_urls = resolve_web_server_urls(host, port, OTA_PATH) except EsphomeError as err: _LOGGER.error( "Error resolving IP address of %s. Is it connected to WiFi?", host @@ -104,7 +104,7 @@ def _try_upload( _LOGGER.error("(If you know the IP, try --device )") raise WebServerOTAError(err) from err - if not addr_infos: + if not addr_urls: _LOGGER.error("Could not resolve %s", host) return 1, None @@ -113,16 +113,7 @@ def _try_upload( auth = HTTPBasicAuth(username, password) if username and password else None # Iterate resolved IPs (IPv4 + IPv6 candidates) just like espota2 does. - for af, _socktype, _, _, sa in addr_infos: - ip = sa[0] - # IPv6 literals must be wrapped in brackets in URLs; link-local - # addresses need a percent-encoded zone index per RFC 6874. - if af == socket.AF_INET6: - scope = sa[3] if len(sa) >= 4 else 0 - host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]" - else: - host_part = ip - url = f"http://{host_part}:{port}{OTA_PATH}" + for ip, url in addr_urls: _LOGGER.info("Connecting to %s port %s...", ip, port) try: diff --git a/pyproject.toml b/pyproject.toml index eda3c4cf7c..afa6208cae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools==83.0.0", "wheel>=0.43,<0.48"] +requires = ["setuptools==84.0.0", "wheel>=0.43,<0.48"] build-backend = "setuptools.build_meta" [project] @@ -56,6 +56,9 @@ include = ["esphome*"] testpaths = [ "tests", ] +# Prepend the repo root so in-process esphome imports resolve to THIS tree, +# not wherever the venv's editable install points (e.g. another git worktree). +pythonpath = ["."] addopts = [ "--cov=esphome", "--cov-branch", diff --git a/requirements.txt b/requirements.txt index cd6ebc7db7..9c231bd0fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ pyserial==3.5 platformio==6.1.19 esptool==5.3.1 click==8.3.3 -aioesphomeapi==45.7.0 +aioesphomeapi==45.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi zeroconf==0.150.0 puremagic==2.2.0 @@ -27,11 +27,11 @@ bleak==2.1.1 smpclient==7.2.0 requests==2.34.2 py7zr==1.1.3 -platformdirs==4.11.0 # native esp-idf toolchain global cache dir +platformdirs==4.11.1 # native esp-idf toolchain global cache dir filelock==3.32.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.2 # For autocompletion -argcomplete>=3.7.0 +argcomplete>=3.7.2 diff --git a/requirements_test.txt b/requirements_test.txt index b5753066ba..0905fe6be1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.0.6 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.16.1 # also change in .pre-commit-config.yaml when updating +ruff==0.16.2 # also change in .pre-commit-config.yaml when updating pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating prek==0.4.12 # also change in .github/workflows/ci.yml when updating diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index a6ccb79544..b8ee3066bd 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -52,6 +52,7 @@ COMMON_BUS_PATH = ( # the packages on the right as well PACKAGE_DEPENDENCIES = { "modbus": ["uart"], # modbus packages include uart packages + "modbus_server": ["uart"], # modbus_server packages include uart packages # Add more package dependencies here as needed } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 451cd9ac1f..f4eff4a254 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -2401,7 +2401,10 @@ def get_varint64_ifdef( # At least one 64-bit varint field is unconditional, so the guard must be unconditional. return True, None ifdefs.discard(None) - return True, ifdefs.pop() if len(ifdefs) == 1 else None + # Several guards: the define is needed under any of them, so emit the union. + # Falling back to unconditional would pull 64-bit varint support into builds + # that have none of them. + return True, " || ".join(sorted(ifdefs)) def build_enum_type(desc, enum_ifdef_map) -> tuple[str, str, str]: diff --git a/tests/component_tests/adc/test_adc_sensor.py b/tests/component_tests/adc/test_adc_sensor.py new file mode 100644 index 0000000000..a6d86f0305 --- /dev/null +++ b/tests/component_tests/adc/test_adc_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the ADC sensor component.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + + +def test_adc_temperature_pin_is_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """`pin: TEMPERATURE` still works, but warns and points at internal_temperature.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml") + + assert "adc_temperature->set_is_temperature();" in main_cpp + assert "`pin: TEMPERATURE` is deprecated" in caplog.text + assert "internal_temperature" in caplog.text + assert "2027.2.0" in caplog.text + + +def test_adc_regular_pin_is_not_deprecated( + generate_main: Callable[[str | Path], str], + caplog: pytest.LogCaptureFixture, +) -> None: + """A normal ADC pin does not emit the temperature deprecation warning.""" + main_cpp = generate_main("tests/component_tests/adc/test_adc_sensor.yaml") + + assert "adc_voltage->set_is_temperature();" not in main_cpp + assert caplog.text.count("`pin: TEMPERATURE` is deprecated") == 1 diff --git a/tests/component_tests/adc/test_adc_sensor.yaml b/tests/component_tests/adc/test_adc_sensor.yaml new file mode 100644 index 0000000000..9455fef21b --- /dev/null +++ b/tests/component_tests/adc/test_adc_sensor.yaml @@ -0,0 +1,16 @@ +esphome: + name: test + +rp2: + board: rpipicow + +sensor: + - platform: adc + pin: TEMPERATURE + name: Deprecated ADC Temperature + id: adc_temperature + + - platform: adc + pin: 26 + name: ADC Voltage + id: adc_voltage diff --git a/tests/component_tests/bluetooth_connection/test_slot_ledger.py b/tests/component_tests/bluetooth_connection/test_slot_ledger.py index d722a4e165..1de4cc8ad6 100644 --- a/tests/component_tests/bluetooth_connection/test_slot_ledger.py +++ b/tests/component_tests/bluetooth_connection/test_slot_ledger.py @@ -18,11 +18,11 @@ from ..types import SetCoreConfigCallable def test_gatt_slot_ledger_rejects_overcommit_on_rp2( set_core_config: SetCoreConfigCallable, ) -> None: - # The cap logic in isolation: two hand charges must trip it. + # The cap logic in isolation: hand charges past the cap must trip it. set_core_config(PlatformFramework.RP2_ARDUINO) - bluetooth_connection.consume_gatt_slot("bluetooth_proxy")({}) + bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({}) bluetooth_connection.consume_gatt_slot("ble_client")({}) - with pytest.raises(cv.Invalid, match="supports at most 1 GATT client"): + with pytest.raises(cv.Invalid, match="supports at most 3 GATT client"): bluetooth_connection.FINAL_VALIDATE_SCHEMA({}) @@ -32,7 +32,7 @@ def test_gatt_slot_ledger_skipped_in_testing_mode( # Grouped component builds merge fixtures past the cap; the check defers # to testing mode like esp32_ble.validate_connection_slots. set_core_config(PlatformFramework.RP2_ARDUINO) - bluetooth_connection.consume_gatt_slot("bluetooth_proxy")({}) + bluetooth_connection.consume_gatt_slot("bluetooth_proxy", 3)({}) bluetooth_connection.consume_gatt_slot("ble_client")({}) CORE.testing_mode = True try: @@ -51,5 +51,10 @@ def test_real_validators_charge_the_ledger_on_rp2( CORE.loaded_integrations.add("rp2_ble_tracker") bluetooth_proxy.CONFIG_SCHEMA({}) ble_client.CONFIG_SCHEMA({CONF_MAC_ADDRESS: "AA:BB:CC:DD:EE:FF"}) - with pytest.raises(cv.Invalid, match="requested by: bluetooth_proxy, ble_client"): + # The proxy defaults to 3 slots on rp2; ble_client's claim overcommits. + with pytest.raises( + cv.Invalid, + match="requested by: bluetooth_proxy, bluetooth_proxy, bluetooth_proxy, " + "ble_client", + ): bluetooth_connection.FINAL_VALIDATE_SCHEMA({}) diff --git a/tests/component_tests/bluetooth_proxy/test_platform_gates.py b/tests/component_tests/bluetooth_proxy/test_platform_gates.py index 33a227170e..9190a80f41 100644 --- a/tests/component_tests/bluetooth_proxy/test_platform_gates.py +++ b/tests/component_tests/bluetooth_proxy/test_platform_gates.py @@ -142,8 +142,8 @@ def test_rp2_defaults_to_the_full_proxy( _register_tracker(PLATFORM_RP2) validated = bluetooth_proxy.CONFIG_SCHEMA({}) assert validated[CONF_ACTIVE] is True - assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 1 - assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 + assert validated[bluetooth_proxy.CONF_CONNECTION_SLOTS] == 3 + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 3 def test_rp2_accepts_explicit_passive( @@ -159,11 +159,15 @@ def test_rp2_accepts_explicit_passive( def test_rp2_rejects_slots_beyond_the_btstack_limit( set_core_config: SetCoreConfigCallable, ) -> None: - # The prebuilt BTstack library allows exactly one GATT client connection. + # The BTstack pool overrides are sized for RP2_MAX_CONNECTIONS slots. set_core_config(PlatformFramework.RP2_ARDUINO) _register_tracker(PLATFORM_RP2) - with pytest.raises(cv.Invalid, match="at most 1 connection slot"): - bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 2}) + with pytest.raises(cv.Invalid, match="at most 3 connection slot"): + bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 4}) + # Fewer slots than the cap stay accepted (the prebuilt single-client pool + # path for 1, the wrap path for 2). + validated = bluetooth_proxy.CONFIG_SCHEMA({"connection_slots": 1}) + assert len(validated[bluetooth_proxy.CONF_CONNECTIONS]) == 1 # Values past even the loosest platform cap stop at the outer walkable # schema, which stays bounded for range walkers (device-builder sync); # in-range values get the platform message above. diff --git a/tests/component_tests/gsl3670/test_init.py b/tests/component_tests/gsl3670/test_init.py index 8528cf23ca..950fa389be 100644 --- a/tests/component_tests/gsl3670/test_init.py +++ b/tests/component_tests/gsl3670/test_init.py @@ -87,13 +87,11 @@ def test_cache_path_is_deterministic_per_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """The cache path is derived from (and stable for) the URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) first = gsl._cache_path(VALID_URL) assert first == gsl._cache_path(VALID_URL) assert first != gsl._cache_path("https://example.com/other.bin") - assert first.parent == tmp_path + assert first.parent == tmp_path / "gsl3670" def test_firmware_path_prefers_local_file(tmp_path: Path) -> None: @@ -106,9 +104,7 @@ def test_firmware_path_uses_cache_for_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """A ``url`` source resolves to the cache path for that URL.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) assert gsl.firmware_path({"url": VALID_URL}) == gsl._cache_path(VALID_URL) @@ -145,9 +141,7 @@ def test_firmware_url_downloads_and_validates( ) -> None: """A url source downloads the content and validates its structure.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) assert gsl._validate_firmware({"url": VALID_URL}) == {"url": VALID_URL} @@ -157,9 +151,7 @@ def test_firmware_url_sha256_mismatch_rejected( ) -> None: """A configured SHA-256 that does not match the download is rejected.""" data = _make_firmware() - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr(gsl.external_files, "download_content", lambda url, path: data) with pytest.raises(cv.Invalid, match="SHA-256 mismatch"): gsl._validate_firmware({"url": VALID_URL, "sha256": "00" * 32}) @@ -169,9 +161,7 @@ def test_firmware_url_invalid_structure_rejected( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Downloaded content that is not a valid blob is rejected.""" - monkeypatch.setattr( - gsl.external_files, "compute_local_file_dir", lambda _: tmp_path - ) + monkeypatch.setenv("ESPHOME_DATA_DIR", str(tmp_path)) monkeypatch.setattr( gsl.external_files, "download_content", lambda url, path: b"\x00\x01\x02" ) diff --git a/tests/component_tests/modbus_server/test_modbus_server.py b/tests/component_tests/modbus_server/test_modbus_server.py index 3e041c6d4a..ce1098fbca 100644 --- a/tests/component_tests/modbus_server/test_modbus_server.py +++ b/tests/component_tests/modbus_server/test_modbus_server.py @@ -7,8 +7,13 @@ from esphome.components.modbus_server import ( SERVER_SENSOR_VALUE_TYPE, _validate_no_overlapping_registers, _validate_register_ranges, + _validate_unique_bit_addresses, +) +from esphome.components.modbus_server.const import ( + CONF_BITS, + CONF_REGISTERS, + CONF_VALUE_TYPE, ) -from esphome.components.modbus_server.const import CONF_REGISTERS, CONF_VALUE_TYPE from esphome.const import CONF_ADDRESS @@ -21,6 +26,10 @@ def _config(registers: list[tuple[int, str]]) -> dict: } +def _bits_config(addresses: list[int]) -> dict: + return {CONF_BITS: [{CONF_ADDRESS: address} for address in addresses]} + + def test_non_overlapping_registers_pass() -> None: # Values that tile the address space without gaps or overlaps are accepted. config = _config([(0x00, "U_WORD"), (0x01, "U_DWORD"), (0x03, "U_WORD")]) @@ -42,6 +51,18 @@ def test_duplicate_address_rejected() -> None: _validate_no_overlapping_registers(config) +def test_unique_bit_addresses_pass() -> None: + config = _bits_config([0x00, 0x01, 0x02]) + assert _validate_unique_bit_addresses(config) is config + + +def test_duplicate_bit_address_rejected() -> None: + # Coils and discrete inputs share one bit address space, so a repeated address is rejected. + config = _bits_config([0x05, 0x05]) + with pytest.raises(cv.Invalid, match="more than once"): + _validate_unique_bit_addresses(config) + + def test_multi_register_value_overlapping_neighbour_rejected() -> None: # U_DWORD at 0x10 occupies 0x10 and 0x11; a U_WORD at 0x11 collides with its low word. config = _config([(0x10, "U_DWORD"), (0x11, "U_WORD")]) diff --git a/tests/component_tests/network/config/priority_arduino.yaml b/tests/component_tests/network/config/priority_arduino.yaml new file mode 100644 index 0000000000..b66f676601 --- /dev/null +++ b/tests/component_tests/network/config/priority_arduino.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: arduino + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_rp2040.yaml b/tests/component_tests/network/config/priority_rp2040.yaml new file mode 100644 index 0000000000..984f2dcbb4 --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040.yaml @@ -0,0 +1,24 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi diff --git a/tests/component_tests/network/config/priority_single.yaml b/tests/component_tests/network/config/priority_single.yaml new file mode 100644 index 0000000000..bd23697808 --- /dev/null +++ b/tests/component_tests/network/config/priority_single.yaml @@ -0,0 +1,15 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +network: + priority: + - wifi diff --git a/tests/component_tests/network/test_priority.py b/tests/component_tests/network/test_priority.py index da1c0a061d..017f0711a3 100644 --- a/tests/component_tests/network/test_priority.py +++ b/tests/component_tests/network/test_priority.py @@ -16,9 +16,10 @@ from esphome.components.network import ( _validate_priority_list, get_network_priority, ) -from esphome.const import CONF_PRIORITY +from esphome.const import CONF_PRIORITY, PlatformFramework from esphome.core import CORE import esphome.final_validate as fv +from tests.component_tests.types import SetCoreConfigCallable @pytest.fixture(autouse=True) @@ -138,6 +139,22 @@ def test_final_validate_noop_without_priority_list() -> None: _final_validate({}) # must not raise +def test_final_validate_rejects_unsupported_arbitration_interface( + set_core_config: SetCoreConfigCallable, +) -> None: + """The ethernet/wifi-only arbitration tripwire fails as a clean config error. + + Unreachable through the public schema today (VALID_NETWORK_TYPES gates the + list), so the config is hand-built to simulate a future interface type that + was added to the schema without extending NetworkComponent::loop(). + """ + set_core_config(PlatformFramework.ESP32_IDF) + fv.full_config.set({"openthread": {}, "wifi": {}}) + config = {CONF_PRIORITY: [{"interface": "openthread"}, {"interface": "wifi"}]} + with pytest.raises(Invalid, match="arbitration does not support: openthread"): + _final_validate(config) + + def _cpp_setup_priority(name: str) -> float: """Read a setup_priority constant straight from esphome/core/component.h.""" header = Path(__file__).parents[3] / "esphome" / "core" / "component.h" @@ -199,3 +216,55 @@ def test_no_primary_interface_define_without_priority( assert not any( d.name.startswith("USE_NETWORK_PRIMARY_INTERFACE_") for d in CORE.defines ) + + +def _dns_per_default_netif_option() -> bool | None: + from esphome.components.esp32.const import KEY_ESP32, KEY_SDKCONFIG_OPTIONS + + if KEY_ESP32 not in CORE.data: # non-ESP32 configs have no sdkconfig at all + return None + return CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS].get( + "CONFIG_ESP_NETIF_SET_DNS_PER_DEFAULT_NETIF" + ) + + +@pytest.mark.parametrize( + "config_file", + [ + "priority_wifi_first.yaml", + "priority_ethernet_first.yaml", + "priority_arduino.yaml", + ], +) +def test_multi_interface_priority_enables_default_route_arbitration( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """More than one interface in 'priority' enables default-route arbitration.""" + generate_main(component_config_path(config_file)) + assert "USE_NETWORK_DEFAULT_ROUTE" in {d.name for d in CORE.defines} + assert _dns_per_default_netif_option() is True + + +@pytest.mark.parametrize( + "config_file", + [ + # Single-entry priority list / no list at all. + "priority_single.yaml", + "wifi_only.yaml", + # Dual-interface on rp2040: validates, but the arbitration is ESP32-only + # (NetworkComponent::loop() is compiled under USE_ESP32) — emitting the + # define here would be a hard build break. + "priority_rp2040.yaml", + ], +) +def test_single_interface_has_no_default_route_arbitration( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + config_file: str, +) -> None: + """Single-interface and non-ESP32 configs must not compile in the arbitration.""" + generate_main(component_config_path(config_file)) + assert "USE_NETWORK_DEFAULT_ROUTE" not in {d.name for d in CORE.defines} + assert _dns_per_default_netif_option() is None diff --git a/tests/component_tests/rp2040_ble/__init__.py b/tests/component_tests/rp2040_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml new file mode 100644 index 0000000000..93c769283f --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_default.yaml @@ -0,0 +1,15 @@ +esphome: + name: poolwrap-rp2-default + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml new file mode 100644 index 0000000000..4e9c94df59 --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_single_slot.yaml @@ -0,0 +1,16 @@ +esphome: + name: poolwrap-rp2-single + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: + connection_slots: 1 diff --git a/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml b/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml new file mode 100644 index 0000000000..c631562743 --- /dev/null +++ b/tests/component_tests/rp2040_ble/config/rp2_proxy_two_slots.yaml @@ -0,0 +1,16 @@ +esphome: + name: poolwrap-rp2-two + +rp2: + board: rpipicow + +wifi: + ssid: MySSID + password: password1 + +api: + +rp2_ble_tracker: + +bluetooth_proxy: + connection_slots: 2 diff --git a/tests/component_tests/rp2040_ble/test_connection_slots.py b/tests/component_tests/rp2040_ble/test_connection_slots.py new file mode 100644 index 0000000000..f33180e2d0 --- /dev/null +++ b/tests/component_tests/rp2040_ble/test_connection_slots.py @@ -0,0 +1,41 @@ +"""Connection-slot accounting: consumers claim against MAX_CONNECTIONS and +final validation rejects over-subscription with the consumer list.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome import config_validation as cv +from esphome.components import rp2040_ble +from esphome.core import CORE + + +def test_proxy_claims_its_slots_through_the_shared_accounting( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + # A default (3-slot) proxy build records one claim per slot, attributed + # to the consumer, and passes final validation. + generate_main(component_config_path("rp2_proxy_default.yaml")) + used = CORE.data[rp2040_ble.KEY_RP2040_BLE][rp2040_ble.KEY_USED_CONNECTION_SLOTS] + assert used == ["bluetooth_proxy"] * 3 + + +def test_oversubscription_is_rejected_with_the_consumer_list() -> None: + # No YAML shape reaches this today (the proxy schema caps at the same + # limit); the guard exists for a second consumer such as ble_client. + rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({}) + rp2040_ble.consume_connection_slots(1, "ble_client")({}) + with pytest.raises( + cv.Invalid, + match=r"4 connection slots.*maximum is 3.*bluetooth_proxy.*ble_client", + ): + rp2040_ble.validate_connection_slots() + + +def test_at_cap_passes() -> None: + rp2040_ble.consume_connection_slots(3, "bluetooth_proxy")({}) + rp2040_ble.validate_connection_slots() diff --git a/tests/component_tests/rp2040_ble/test_pool_wrap.py b/tests/component_tests/rp2040_ble/test_pool_wrap.py new file mode 100644 index 0000000000..291ca5eb58 --- /dev/null +++ b/tests/component_tests/rp2040_ble/test_pool_wrap.py @@ -0,0 +1,52 @@ +"""The rp2 BTstack pool overrides: multi-slot builds emit the --wrap flags +that swap the prebuilt single-client pools for the codegen-sized ones; +single-slot builds emit none and stay byte-identical to previous releases.""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from esphome.core import CORE + +from ..helpers import get_define_value + +# Spelled out rather than derived from rp2040_ble's symbol tuple, so a typo +# in the component's list fails here instead of mirroring into the test. +WRAP_FLAGS = ( + "-Wl,--wrap=btstack_memory_gatt_client_get", + "-Wl,--wrap=btstack_memory_gatt_client_free", + "-Wl,--wrap=btstack_memory_hci_connection_get", + "-Wl,--wrap=btstack_memory_hci_connection_free", +) + + +def test_default_slots_emit_the_pool_wrap( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("rp2_proxy_default.yaml")) + assert all(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "3" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "3" + + +def test_two_slots_emit_the_pool_wrap( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + # Two slots: the wrap pools are smaller than the cap, sized from the count. + generate_main(component_config_path("rp2_proxy_two_slots.yaml")) + assert all(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "2" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "2" + + +def test_single_slot_keeps_the_prebuilt_pools( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + generate_main(component_config_path("rp2_proxy_single_slot.yaml")) + assert not any(flag in CORE.build_flags for flag in WRAP_FLAGS) + assert get_define_value("ESPHOME_BLE_GATT_CLIENT_COUNT") == "1" + assert get_define_value("BLUETOOTH_PROXY_MAX_CONNECTIONS") == "1" diff --git a/tests/component_tests/sendspin/__init__.py b/tests/component_tests/sendspin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/sendspin/test_image.py b/tests/component_tests/sendspin/test_image.py new file mode 100644 index 0000000000..be3b7d6684 --- /dev/null +++ b/tests/component_tests/sendspin/test_image.py @@ -0,0 +1,114 @@ +"""Validation tests for the sendspin image platform. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import IMAGE_FORMAT_JPEG, MAX_ARTWORK_SLOTS, _get_data +from esphome.components.sendspin.image import CONFIG_SCHEMA, MAX_IMAGE_DIMENSION +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _slot_config(**overrides: Any) -> ConfigType: + """Build a minimal valid artwork slot config, allowing field overrides.""" + config: ConfigType = { + "id": "album_slot", + "format": "JPEG", + "type": "RGB565", + "resize": "240x240", + "current_image": {"id": "album_art"}, + } + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config()) + + assert config["slot"] == 0 + assert config["source"] == "ALBUM" + assert config["display_offset"].total_milliseconds == 0 + + +@pytest.mark.parametrize("image_format", ["JPEG", "JPG"]) +def test_jpeg_alias_maps_to_one_enum( + set_core_config: SetCoreConfigCallable, image_format: str +) -> None: + """runtime_image takes JPG as an alias for JPEG, so both spellings must reach the + library's single JPEG enum.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_slot_config(format=image_format)) + + assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG + + +def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Slot numbers run out after MAX_ARTWORK_SLOTS entries.""" + set_core_config(PlatformFramework.ESP32_IDF) + + for slot in range(MAX_ARTWORK_SLOTS): + assert CONFIG_SCHEMA(_slot_config(id=f"slot_{slot}"))["slot"] == slot + + with pytest.raises(cv.Invalid, match="Too many Sendspin image slots"): + CONFIG_SCHEMA(_slot_config(id="one_too_many")) + + +@pytest.mark.parametrize( + "resize", + [f"{MAX_IMAGE_DIMENSION + 1}x240", f"240x{MAX_IMAGE_DIMENSION + 1}"], +) +def test_oversized_resize_rejected( + set_core_config: SetCoreConfigCallable, resize: str +) -> None: + """Either dimension past the decoder's limit is refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match=f"must be {MAX_IMAGE_DIMENSION} or less"): + CONFIG_SCHEMA(_slot_config(resize=resize)) + + +def test_sub_millisecond_display_offset_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """The library field is whole milliseconds, so finer values are refused + rather than silently rounded down to zero.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Maximum precision is milliseconds"): + CONFIG_SCHEMA(_slot_config(display_offset="500us")) + + +@pytest.mark.parametrize("display_offset", ["61s", "-61s"]) +def test_out_of_range_display_offset_rejected( + set_core_config: SetCoreConfigCallable, display_offset: str +) -> None: + """Offsets more than a minute either side of the boundary are refused.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="value must be at (most|least)"): + CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + +@pytest.mark.parametrize( + ("display_offset", "expected_ms"), [("250ms", 250), ("-2s", -2000)] +) +def test_display_offset_accepted( + set_core_config: SetCoreConfigCallable, display_offset: str, expected_ms: int +) -> None: + """Whole-millisecond offsets pass through in both directions.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_slot_config(display_offset=display_offset)) + + assert config["display_offset"].total_milliseconds == expected_ms diff --git a/tests/component_tests/web_server/test_web_server_auth.py b/tests/component_tests/web_server/test_web_server_auth.py index 82635b26da..183c586a36 100644 --- a/tests/component_tests/web_server/test_web_server_auth.py +++ b/tests/component_tests/web_server/test_web_server_auth.py @@ -33,14 +33,49 @@ def test_web_server_auth_explicit_basic_no_warning( generate_main: Callable[[str], str], caplog: pytest.LogCaptureFixture, ) -> None: - """Auth type basic builds Basic and does not warn.""" - generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml") + """Auth type basic on ESP32 uses plaintext credentials and does not warn.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_basic.yaml" + ) + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert "set_auth_basic_hash" not in main_cpp assert _has_define("USE_WEBSERVER_AUTH") assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") assert _DEFAULT_CHANGE_WARNING not in caplog.text +def test_web_server_auth_basic_esp8266_uses_precomputed_hash( + generate_main: Callable[[str], str], +) -> None: + """Auth type basic on ESP8266 emits the precomputed base64 hash, not the credentials.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml" + ) + + assert '->set_auth_basic_hash("YWRtaW46cGFzc3dvcmQ=");' in main_cpp + assert "set_auth_username" not in main_cpp + assert "set_auth_password" not in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert not _has_define("USE_WEBSERVER_AUTH_DIGEST") + + +def test_web_server_auth_digest_esp8266_uses_plaintext_credentials( + generate_main: Callable[[str], str], +) -> None: + """Auth type digest on ESP8266 uses plaintext credentials, not the basic hash.""" + main_cpp = generate_main( + "tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml" + ) + + assert '->set_auth_username("admin");' in main_cpp + assert '->set_auth_password("password");' in main_cpp + assert "set_auth_basic_hash" not in main_cpp + assert _has_define("USE_WEBSERVER_AUTH") + assert _has_define("USE_WEBSERVER_AUTH_DIGEST") + + def test_web_server_auth_explicit_digest( generate_main: Callable[[str], str], caplog: pytest.LogCaptureFixture, diff --git a/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml b/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml new file mode 100644 index 0000000000..79e0c0ccf5 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_basic_esp8266.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: basic diff --git a/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml b/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml new file mode 100644 index 0000000000..59565f8733 --- /dev/null +++ b/tests/component_tests/web_server/web_server_auth_digest_esp8266.yaml @@ -0,0 +1,16 @@ +--- +esphome: + name: test + +esp8266: + board: esp01_1m + +wifi: + ssid: MySSID + password: password1 + +web_server: + auth: + username: admin + password: password + type: digest diff --git a/tests/components/adc/validate.rp2040-ard.yaml b/tests/components/adc/validate.rp2040-ard.yaml new file mode 100644 index 0000000000..cbe15f2746 --- /dev/null +++ b/tests/components/adc/validate.rp2040-ard.yaml @@ -0,0 +1,7 @@ +# Deprecated `pin: TEMPERATURE`, superseded by the `internal_temperature` platform. +# Remove before 2027.2.0 +sensor: + - id: adc_temperature_sensor + platform: adc + pin: TEMPERATURE + name: ADC Test temperature diff --git a/tests/components/bluetooth_connection/__init__.py b/tests/components/bluetooth_connection/__init__.py index 40810951ee..9c1ad4e74d 100644 --- a/tests/components/bluetooth_connection/__init__.py +++ b/tests/components/bluetooth_connection/__init__.py @@ -3,15 +3,17 @@ from tests.testing_helpers import ComponentManifestOverride def override_manifest(manifest: ComponentManifestOverride) -> None: - # close_service_batch compiles only under BLUETOOTH_CONNECTION_SERVES_PROXY - # (= USE_BLE_GATT_CLIENT && USE_BLUETOOTH_PROXY); emit both so the host - # build exercises it. + # close_service_batch compiles only under USE_BLUETOOTH_PROXY_CONNECTIONS; + # emit the backend define so the host build exercises it. async def to_code_testing(config): # These defines are global to the merged host test binary; safe # because no co-compiled test observes them. cg.add_define("USE_BLE_GATT_CLIENT") cg.add_define("USE_BLE_GATT_CLIENT_STUB_BACKEND") cg.add_define("USE_BLUETOOTH_PROXY") + # Gates the connection half of the API surface, which is what + # close_service_batch and the GATT response types live behind. + cg.add_define("USE_BLUETOOTH_PROXY_CONNECTIONS") cg.add_define("BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE", 16) cg.add_define("BLUETOOTH_PROXY_MAX_CONNECTIONS", 1) diff --git a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml index 620aaa177b..d3674b8406 100644 --- a/tests/components/bluetooth_connection/validate.rp2040-ard.yaml +++ b/tests/components/bluetooth_connection/validate.rp2040-ard.yaml @@ -6,6 +6,7 @@ packages: rp2_ble_tracker: +# Two slots: the one shape where the wrap pools are smaller than the cap. bluetooth_proxy: active: true - connection_slots: 1 + connection_slots: 2 diff --git a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml index e219c7542d..77ed2ea32d 100644 --- a/tests/components/bluetooth_proxy/test.rp2040-ard.yaml +++ b/tests/components/bluetooth_proxy/test.rp2040-ard.yaml @@ -1,5 +1,7 @@ # Full proxy on the rp2 BLE hub: active defaults to true here (esp32 parity), -# so this compiles the BTstack GATT client backend and one connection slot. +# so this compiles the BTstack GATT client backend with the default three +# connection slots, exercising the rp2040_ble/btstack_memory.cpp pool --wrap +# link. # No explicit ble_hub_id: the generated binding resolves the single declared # hub, and an inline id here would collide with rp2_ble_tracker's own fixture # once CI merges both components into one grouped rp2040-ard build (grouped diff --git a/tests/components/bluetooth_proxy/test.rp2350-ard.yaml b/tests/components/bluetooth_proxy/test.rp2350-ard.yaml new file mode 100644 index 0000000000..1abc62cedb --- /dev/null +++ b/tests/components/bluetooth_proxy/test.rp2350-ard.yaml @@ -0,0 +1,9 @@ +# Pico 2 W build of the full proxy: links the rp2350 framework archive, so +# the pool --wrap overrides and their per-architecture layout asserts are +# exercised for this chip too (see test.rp2040-ard.yaml for the slot shape). +packages: + common: !include common.yaml + +rp2_ble_tracker: + +bluetooth_proxy: diff --git a/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp new file mode 100644 index 0000000000..6e9b567080 --- /dev/null +++ b/tests/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor_test.cpp @@ -0,0 +1,52 @@ +#include + +#include "esphome/components/hoermann_hcp/binary_sensor/hoermann_hcp_binary_sensor.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +// Nothing has been heard from the bus controller yet, so the sensor starts out seeded as disconnected. +TEST(HoermannHcpBinarySensorTest, StartsDisconnected) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + EXPECT_TRUE(sensor.has_state()); + EXPECT_FALSE(sensor.state); +} + +// The connection flag follows the bus controller in both directions. +TEST(HoermannHcpBinarySensorTest, FollowsTheConnectionState) { + TestableHoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + ASSERT_FALSE(sensor.state); + + connect_controller(door); + door.update(); + EXPECT_TRUE(sensor.state); + + door.set_valid_(false); + door.update(); + EXPECT_FALSE(sensor.state); +} + +// Any hub change re-runs the publish path, so an unchanged connection must not be reported twice. +TEST(HoermannHcpBinarySensorTest, UnchangedConnectionIsPublishedOnce) { + HoermannHcp door; + HoermannHcpConnectedBinarySensor sensor(&door); + sensor.setup(); + int publishes = 0; + sensor.add_on_state_callback([&publishes](bool /*state*/) { publishes++; }); + + connect_controller(door); + door.update(); + ASSERT_EQ(publishes, 1); + + // A status broadcast changes the door state without touching the connection. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + EXPECT_EQ(publishes, 1); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.h b/tests/components/hoermann_hcp/common.h new file mode 100644 index 0000000000..a6151697f0 --- /dev/null +++ b/tests/components/hoermann_hcp/common.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include +#include +#include "esphome/components/hoermann_hcp/hoermann_hcp.h" + +namespace esphome::hoermann_hcp::testing { + +using modbus::RegisterValues; + +// Register block addresses the Hoermann bus controller polls (see hoermann_hcp.cpp). +constexpr uint16_t COMMAND_REG = 0x9C41; +constexpr uint16_t STATE_REG = 0x9CB9; +constexpr uint16_t BROADCAST_REG = 0x9D31; + +// The tests shorten the key-press delay to zero, so the release only needs the millis() clock to tick on. +constexpr auto KEY_PRESS_ELAPSED = std::chrono::milliseconds(2); + +inline RegisterValues make_registers(std::initializer_list values) { + RegisterValues registers; + for (uint16_t value : values) + registers.push_back(value); + return registers; +} + +// A status broadcast carrying the lamp register, which the door reports at index 6. +inline RegisterValues lamp_broadcast(uint16_t lamp_reg) { + return make_registers({0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, lamp_reg}); +} + +// The door only accepts commands once the bus controller has actually talked to it. +inline void connect_controller(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); +} + +// Runs one command poll (write 2 / read 8) and returns both key-press registers. +inline std::pair poll_command(HoermannHcp &door) { + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_EQ(response.size(), 8u); + if (response.size() != 8u) + return {0xFFFF, 0xFFFF}; + return {response[2], response[3]}; +} + +// Presents and then releases the queued command, leaving the slot free. +inline void consume_command(HoermannHcp &door) { + poll_command(door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); +} + +// Exposes the internal timings and the connection bookkeeping, so no test has to wait out a real delay. +class TestableHoermannHcp : public HoermannHcp { + public: + TestableHoermannHcp() { this->key_press_delay_ms_ = 0; } + + using HoermannHcp::connection_timeout_ms_; + using HoermannHcp::is_light_toggle_pending_; + using HoermannHcp::light_toggle_released_at_; + using HoermannHcp::light_toggles_in_flight_; + using HoermannHcp::set_valid_; +}; + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/common.yaml b/tests/components/hoermann_hcp/common.yaml new file mode 100644 index 0000000000..552b1cb0fd --- /dev/null +++ b/tests/components/hoermann_hcp/common.yaml @@ -0,0 +1,17 @@ +hoermann_hcp: + id: hoermann_hcp_hub + modbus_id: modbus_server_bus + +cover: + - platform: hoermann_hcp + name: Garage Door + device_class: garage + +binary_sensor: + - platform: hoermann_hcp + is_connected: + name: Garage Connected + +light: + - platform: hoermann_hcp + name: Garage Light diff --git a/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp new file mode 100644 index 0000000000..43ca47edb2 --- /dev/null +++ b/tests/components/hoermann_hcp/cover/hoermann_hcp_cover_test.cpp @@ -0,0 +1,147 @@ +#include + +#include "esphome/components/hoermann_hcp/cover/hoermann_hcp_cover.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +// Cover::position starts at COVER_OPEN, so a door that is already closed still has a state to publish. +TEST(HoermannHcpCoverTest, ClosedDoorPublishesItsInitialPosition) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + ASSERT_FLOAT_EQ(cover.position, cover::COVER_OPEN); + + // Any request marks the device connected, which is itself a state change. + door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})); + door.update(); + + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_CLOSED); +} + +// Venting and half-open moves report no direction, so one is only derived once the position has moved. +TEST(HoermannHcpCoverTest, DirectionlessMoveHoldsTheOperationUntilThePositionMoves) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // Position 100/200 = 0.5, state 0x80 -> resting half open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x8000})); + door.update(); + ASSERT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // State 0x05 -> moving to half-open, but the position has not moved yet. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); + + // Position 120/200 = 0.6 is higher than before, so the door is opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_OPENING); + EXPECT_FLOAT_EQ(cover.position, 0.6f); +} + +// Booting while the door is already mid-move gives no baseline to compare against, so no direction +// may be inferred from the first update. +TEST(HoermannHcpCoverTest, FirstDirectionlessMoveDoesNotGuessADirection) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + + // The very first thing seen is a half-open move already at 100/200 = 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0500})); + door.update(); + EXPECT_EQ(cover.current_operation, cover::COVER_OPERATION_IDLE); +} + +// A cover.open arrives as a position of 1.0, so it has to reach the door as a plain open command rather +// than as a target the door would be stopped at. +TEST(HoermannHcpCoverTest, OpenCommandOpensTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + + cover.make_call().set_command_open().perform(); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed +} + +// The same for cover.close, which arrives as a position of 0.0. +TEST(HoermannHcpCoverTest, CloseCommandClosesTheDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + + cover.make_call().set_command_close().perform(); + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed +} + +TEST(HoermannHcpCoverTest, ToggleCommandSendsAnImpulse) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + + cover.make_call().set_command_toggle().perform(); + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed +} + +TEST(HoermannHcpCoverTest, StopCommandStopsAMovingDoor) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + // The door is opening, so it takes an impulse to stop it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + + cover.make_call().set_command_stop().perform(); + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed +} + +// A position between the end stops starts the door in the right direction; it is stopped there later. +TEST(HoermannHcpCoverTest, PositionCommandStartsTheDoorTowardsTheTarget) { + HoermannHcp door; // starts out fully closed + HoermannHcpCover cover(&door); + cover.setup(); + connect_controller(door); + + cover.make_call().set_position(0.5f).perform(); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed +} + +// A command the door cannot take is assumed to have worked by whoever sent it, so the unchanged state has +// to be published back over that assumption. +TEST(HoermannHcpCoverTest, RefusedCommandPublishesTheUnchangedState) { + HoermannHcp door; // never contacted by a bus controller + HoermannHcpCover cover(&door); + cover.setup(); + int publishes = 0; + cover.add_on_state_callback([&publishes]() { publishes++; }); + + cover.make_call().set_command_close().perform(); + + EXPECT_EQ(poll_command(door).first, 0x0000); + EXPECT_EQ(publishes, 1); + EXPECT_FLOAT_EQ(cover.position, cover::COVER_OPEN); +} + +// Nothing is published before the bus controller is heard from, so a door that never reaches the bus would +// otherwise sit at its fully open default and look healthy. +TEST(HoermannHcpCoverTest, MissingBusControllerIsFlaggedUntilFirstContact) { + HoermannHcp door; + HoermannHcpCover cover(&door); + cover.setup(); + EXPECT_TRUE(cover.status_has_warning()); + + connect_controller(door); + door.update(); + EXPECT_FALSE(cover.status_has_warning()); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/hoermann_hcp_test.cpp b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp new file mode 100644 index 0000000000..1cc5301b4a --- /dev/null +++ b/tests/components/hoermann_hcp/hoermann_hcp_test.cpp @@ -0,0 +1,388 @@ +#include + +#include +#include + +#include "common.h" + +namespace esphome::hoermann_hcp::testing { + +// An empty poll (write 2 / read 2) answers with the fixed status word 0x0004. +TEST(HoermannHcpReadWrite, EmptyPollReturnsStatusWord) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 2, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 2u); + EXPECT_EQ(response[0], 0x0004); + EXPECT_EQ(response[1], 0x0000); +} + +// A bus scan (write 3 / read 5) answers with the fixed device identification block. +TEST(HoermannHcpReadWrite, BusScanReturnsIdentification) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 5, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 5u); + EXPECT_EQ(response[1], 0x0005); + EXPECT_EQ(response[2], 0x0430); + EXPECT_EQ(response[3], 0x10ff); + EXPECT_EQ(response[4], 0xa845); +} + +// Without a queued command, the command poll (write 2 / read 8) reports idle and no key press. +TEST(HoermannHcpReadWrite, IdleCommandPollHasNoCommand) { + HoermannHcp door; + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[1], 0x0001); + EXPECT_EQ(response[2], 0x0000); + EXPECT_EQ(response[3], 0x0000); +} + +// A queued control command is injected into the next command poll as a simulated key press. +TEST(HoermannHcpReadWrite, QueuedCommandIsInjectedIntoPoll) { + HoermannHcp door; + connect_controller(door); + door.open_door(); + EXPECT_FALSE(door.on_write_registers(COMMAND_REG, make_registers({0x0000, 0x0000})).has_value()); + RegisterValues response; + auto status = door.on_read_holding_registers(STATE_REG, 8, response); + EXPECT_FALSE(status.has_value()); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value + EXPECT_EQ(response[3], 0x0000); +} + +// A read of any other block is an addressing error rather than a successful all-zero reply. +TEST(HoermannHcpReadWrite, UnknownAddressIsRejected) { + HoermannHcp door; + RegisterValues response; + EXPECT_EQ(door.on_read_holding_registers(0x1234, 2, response), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_EQ(door.on_write_registers(0x1234, make_registers({0x0000})), modbus::ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A command is held for the key-press duration, then released, and only then can the next one be queued. +TEST(HoermannHcpReadWrite, CommandIsReleasedAfterTheKeyPressDelay) { + TestableHoermannHcp door; + connect_controller(door); + door.open_door(); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed + // Refused while one is pending: were it accepted, the release below would carry COMMAND_CLOSE's 0x0120. + door.close_door(); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released + // With the command gone, the next one is accepted again. + door.close_door(); + EXPECT_EQ(poll_command(door).first, 0x0220); // COMMAND_CLOSE pressed +} + +// Commands issued while the bus controller is absent are dropped instead of firing when it returns. +TEST(HoermannHcpReadWrite, CommandIsDroppedWhileDisconnected) { + HoermannHcp door; + door.open_door(); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// Losing the controller must drop a command it never fetched, otherwise it blocks every later command +// and fires unasked once the bus comes back. +TEST(HoermannHcpReadWrite, ConnectionLossDropsThePendingCommand) { + TestableHoermannHcp door; + connect_controller(door); + door.open_door(); + ASSERT_TRUE(door.is_valid()); + + door.set_valid_(false); + EXPECT_FALSE(door.is_valid()); + + // The reconnecting poll must not replay the dropped command. + EXPECT_EQ(poll_command(door).first, 0x0000); + // And the slot is free, so a new command is accepted. + door.close_door(); + EXPECT_EQ(poll_command(door).first, 0x0220); +} + +// The connection is dropped by update() once the controller stops polling, which is what releases a +// command it never fetched in the field. +TEST(HoermannHcpReadWrite, PollingTimeoutDropsTheConnection) { + TestableHoermannHcp door; + // Wide enough that a stall cannot expire the connection before the check below runs. + door.connection_timeout_ms_ = 10000; + connect_controller(door); + door.open_door(); + + // Still inside the window: the controller counts as present. + door.update(); + ASSERT_TRUE(door.is_valid()); + + // Shrink the window so the expiry needs only a short sleep; overshooting it only makes it surer. + door.connection_timeout_ms_ = 20; + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + EXPECT_FALSE(door.is_valid()); + // The pending command went with the connection instead of firing on the reconnecting poll. + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// Status broadcasts alone keep the connection alive, so a command the controller never fetches has to +// expire on its own; otherwise it blocks every later command until the bus goes quiet entirely. +TEST(HoermannHcpReadWrite, UnfetchedCommandExpiresWhileConnected) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.open_door(); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // A status broadcast refreshes the connection without ever fetching the command. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + door.update(); + ASSERT_TRUE(door.is_valid()); + + // With the stale command gone, the door accepts commands again. + door.close_door(); + EXPECT_EQ(poll_command(door).first, 0x0220); +} + +// The 0x17 read half echoes the message counter and command byte written to COMMAND_REG, packed +// differently per block length. +TEST(HoermannHcpReadWrite, CommandRegisterIsEchoedBack) { + HoermannHcp door; + // Counter 0x34 in the high byte, command 0x07 in the low byte. + door.on_write_registers(COMMAND_REG, make_registers({0x3407, 0x0000})); + + RegisterValues command_poll; + door.on_read_holding_registers(STATE_REG, 8, command_poll); + ASSERT_EQ(command_poll.size(), 8u); + EXPECT_EQ(command_poll[0], 0x3400); // counter alone + EXPECT_EQ(command_poll[1], 0x0701); // command in the high byte, status 0x01 in the low + + RegisterValues empty_poll; + door.on_read_holding_registers(STATE_REG, 2, empty_poll); + ASSERT_EQ(empty_poll.size(), 2u); + EXPECT_EQ(empty_poll[0], 0x3404); // status 0x04 shares the register with the counter here + EXPECT_EQ(empty_poll[1], 0x0700); // command alone + + RegisterValues scan; + door.on_read_holding_registers(STATE_REG, 5, scan); + ASSERT_EQ(scan.size(), 5u); + EXPECT_EQ(scan[0], 0x3400); + EXPECT_EQ(scan[1], 0x0705); +} + +// A status broadcast (function code 0x10 to 0x9D31) updates the decoded door state and position. +TEST(HoermannHcpWrite, BroadcastUpdatesStateAndPosition) { + HoermannHcp door; + // registers[1] low byte = position (value / 200), registers[2] high byte = state (0x01 -> opening). + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The first broadcast has to be decoded even when it carries the register's initial value, otherwise a +// door parked mid-travel at boot keeps the CLOSED default and reports itself fully closed. +TEST(HoermannHcpWrite, FirstBroadcastReportingAStopIsDecoded) { + HoermannHcp door; + auto status = door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0000})); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(door.get_door_state(), DoorState::STOPPED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// The vent position is reported as state 0x00 with low byte 0x61, so a change confined to the low byte of +// the state register still has to be decoded. +TEST(HoermannHcpWrite, VentIsDecodedFromTheStateLowByte) { + HoermannHcp door; + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x0061})); + EXPECT_EQ(door.get_door_state(), DoorState::VENT); +} + +// A door parking a count short of its end stop must still report exactly closed or open, because +// Cover::is_fully_closed() compares against 0.0 exactly. +TEST(HoermannHcpWrite, EndStopsReportExactPositions) { + HoermannHcp door; + // Position register 1 of 200 while the door reports itself closed. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0001, 0x4000})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.0f); + + // Position register 199 of 200 while the door reports itself open. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x00C7, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_FLOAT_EQ(door.get_current_position(), 1.0f); + + // Away from the end stops the raw count is reported as-is. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_FLOAT_EQ(door.get_current_position(), 0.5f); +} + +// A position request below the lower snap threshold becomes a plain close command. +TEST(HoermannHcpPosition, NearlyClosedTargetClosesTheDoor) { + HoermannHcp door; + connect_controller(door); + door.set_position(0.02f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0220); // COMMAND_CLOSE "key pressed" value +} + +// A half-open target starts the door moving towards the requested position. +TEST(HoermannHcpPosition, HalfOpenTargetOpensTheDoor) { + HoermannHcp door; // starts out fully closed + connect_controller(door); + door.set_position(0.5f); + RegisterValues response; + door.on_read_holding_registers(STATE_REG, 8, response); + ASSERT_EQ(response.size(), 8u); + EXPECT_EQ(response[2], 0x0210); // COMMAND_OPEN "key pressed" value +} + +// The door has no notion of a target, so it is stopped with an impulse once it travels past the request. +TEST(HoermannHcpPosition, TargetPositionStopsTheDoor) { + TestableHoermannHcp door; + connect_controller(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released + + // Position 20/200 = 0.1 while opening: short of the target, so the door keeps going. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + EXPECT_EQ(poll_command(door).first, 0x0000); + + // Position 120/200 = 0.6 is past the target, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed +} + +// An impulse restarts a stopped door, so a frame reporting the stop and the target crossing at once +// must be read as "already stopped" rather than "still opening". +TEST(HoermannHcpPosition, StopReportedWithTheCrossingSendsNoImpulse) { + TestableHoermannHcp door; + connect_controller(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); + + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + ASSERT_EQ(door.get_door_state(), DoorState::OPENING); + + // Same frame: position 0.6 (past the target) and state 0x20 -> the door has reached its open end stop. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x2000})); + ASSERT_EQ(door.get_door_state(), DoorState::OPEN); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// A target the door never reaches is dropped once it comes to rest, so a later move is not cut short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorStopsShort) { + TestableHoermannHcp door; + connect_controller(door); + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); + + // The door is stopped at 0.3 by a wall button, short of the requested 0.5. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0014, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +// A target armed while the door is still travelling the other way must not be judged by that old direction, +// otherwise the very next position it reports counts as reached and stops the door where it stands. +TEST(HoermannHcpPosition, TargetArmedWhileMovingTheOtherWayWaitsForTheTurnaround) { + TestableHoermannHcp door; + connect_controller(door); + // The door is closing, passing 60/200 = 0.3. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); // COMMAND_OPEN pressed + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); // COMMAND_OPEN released + + // Still closing at 58/200 = 0.29: below the target, but not on the way to it. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003A, 0x0200})); + EXPECT_EQ(poll_command(door).first, 0x0000); + + // Now opening at 62/200 = 0.31, still short of the target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); + + // Past the target at 110/200 = 0.55, so the door is stopped. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0240); // COMMAND_IMPULSE pressed +} + +// A motor turning around can report a momentary stop; dropping the target there would let the door run on +// to the end stop that the reversing command asked for. +TEST(HoermannHcpPosition, MomentaryStopWhileTurningAroundKeepsTheTarget) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); + + // The stop reported on the way from closing to opening. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0000})); + ASSERT_EQ(door.get_door_state(), DoorState::STOPPED); + + // The door then opens and still has to be stopped at the requested position. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0240); +} + +// A door that never turns around has to lose the target as well, otherwise it would cut a later move short. +TEST(HoermannHcpPosition, TargetIsDroppedWhenTheDoorNeverTurnsAround) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSING); + + door.set_position(0.5f); + EXPECT_EQ(poll_command(door).first, 0x0210); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + EXPECT_EQ(poll_command(door).first, 0x0110); + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // The door ignored the command and closed all the way. Its broadcast keeps the connection alive, so the + // target is the only thing that may expire here. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0000, 0x4000})); + door.update(); + ASSERT_TRUE(door.is_valid()); + ASSERT_EQ(door.get_door_state(), DoorState::CLOSED); + + // A later manual open must run freely instead of being stopped at the abandoned target. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003E, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x006E, 0x0100})); + EXPECT_EQ(poll_command(door).first, 0x0000); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp new file mode 100644 index 0000000000..ed7e81b279 --- /dev/null +++ b/tests/components/hoermann_hcp/light/hoermann_hcp_light_test.cpp @@ -0,0 +1,761 @@ +#include + +#include +#include + +#include "esphome/components/hoermann_hcp/light/hoermann_hcp_light.h" + +#include "../common.h" + +namespace esphome::hoermann_hcp::testing { + +namespace { + +// Counts how often the platform is asked to write, so a publish that re-triggers itself becomes visible. +class CountingHoermannHcpLight : public HoermannHcpLight { + public: + using HoermannHcpLight::HoermannHcpLight; + + void write_state(light::LightState *state) override { + this->writes++; + HoermannHcpLight::write_state(state); + } + + int writes{0}; +}; + +// Drives the platform against a real LightState. ALWAYS_OFF keeps setup() clear of preferences. +struct LightFixture { + TestableHoermannHcp door; + CountingHoermannHcpLight output{&door}; + light::LightState state{&output}; + + explicit LightFixture(light::LightRestoreMode restore_mode = light::LIGHT_ALWAYS_OFF) { + this->state.set_restore_mode(restore_mode); + this->output.setup(); + // setup() queues the restored state for write_state(); the first settle() below delivers it, which is the + // boot ordering tests need to be able to place around the bus controller coming up. + this->state.setup(); + } + + // Brings the bus controller up and lets the platform read the lamp once, which is what a device does before + // any user command can arrive. + void bring_up() { + connect_controller(this->door); + this->report_lamp(false); + } + + // Issues a command the way Home Assistant would, then lets the state machine settle. + void command(bool on) { + auto call = this->state.make_call(); + call.set_state(on); + call.perform(); + this->settle(); + } + + // Delivers a status broadcast and runs the hub's notification pass. + void report_broadcast(const RegisterValues ®isters) { + this->door.on_write_registers(BROADCAST_REG, registers); + this->pump(); + } + + void report_lamp(bool on) { this->report_broadcast(lamp_broadcast(on ? 0x0010 : 0x0000)); } + + // Runs the hub's notification pass and lets the resulting publishes settle. + void pump() { + this->door.update(); + this->settle(); + } + + void settle() { + for (int i = 0; i < 4; i++) + this->state.loop(); + } + + bool entity_on() { return this->state.remote_values.is_on(); } +}; + +} // namespace + +// The lamp state lives in the low byte of register 6; only 0x14 and 0x10 mean lit. +TEST(HoermannHcpLightTest, LampStateIsDecodedFromTheBroadcast) { + HoermannHcp door; + EXPECT_FALSE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0014)); + EXPECT_TRUE(door.is_light_on()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + EXPECT_FALSE(door.is_light_on()); +} + +// The lamp command is the only one that drives the second command register, on both halves of the press. +TEST(HoermannHcpLightTest, LampCommandUsesTheSecondRegister) { + TestableHoermannHcp door; + connect_controller(door); + ASSERT_FALSE(door.is_light_on()); + ASSERT_TRUE(door.toggle_light()); + + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + auto [released, released_2] = poll_command(door); + EXPECT_EQ(released, 0x0800); + EXPECT_EQ(released_2, 0x0200); + + // The command is spent, so the next poll carries nothing. + auto [idle, idle_2] = poll_command(door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// Toggling the lamp must not disturb a cover position the door is still travelling to. +TEST(HoermannHcpLightTest, LampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + + // Past the target: the door still has to be stopped despite the lamp command in between. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp toggle occupies the single command slot, so a target stop falling due while it waits to be fetched +// has to wait too. The target stays armed and the stop goes out on the next position report, which costs the +// door a little overshoot but never loses the stop. +TEST(HoermannHcpLightTest, LampToggleDelaysButDoesNotLoseTheTargetStop) { + TestableHoermannHcp door; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + ASSERT_TRUE(door.toggle_light()); + // The door passes the target while the lamp toggle still holds the slot, so the lamp goes out first. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); + + // The target survived the refusal, so the next position report still stops the door. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0079, 0x0100})); + auto [stop, stop_2] = poll_command(door); + EXPECT_EQ(stop, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(stop_2, 0x0000); +} + +// The target's start deadline is its own, so toggling the lamp cannot keep a stale target alive. +TEST(HoermannHcpLightTest, LampToggleDoesNotExtendTheTargetWatchdog) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // The door is closing, so an opening target is armed but not yet under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0200})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + door.update(); + + // The target expired on its own schedule, so a later opening move runs freely. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Without a bus controller the command cannot be delivered, and the caller is told. +TEST(HoermannHcpLightTest, LampCommandIsRefusedWhileDisconnected) { + HoermannHcp door; + EXPECT_FALSE(door.toggle_light()); +} + +// Switching the entity on sends one toggle, and the door's own report does not send a second. +TEST(HoermannHcpLightPlatformTest, CommandTogglesOnceAndSettles) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); + EXPECT_EQ(pressed_2, 0x0200); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, clearing the slot + + // The lamp is now on, and the resulting broadcast must not queue another toggle. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A broadcast arriving while a toggle is queued must not reconcile against the not-yet-inverted lamp, which +// would cancel the user's own command. +TEST(HoermannHcpLightPlatformTest, BroadcastDuringPendingToggleKeepsTheCommand) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + // A door movement sets changed_, firing the state callback while the toggle is still queued. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp switched on at the door itself has to reach the entity. +TEST(HoermannHcpLightPlatformTest, DoorDrivenChangeReachesTheEntity) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_FALSE(fixture.entity_on()); + + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refused command must leave the entity showing the lamp, not the request. +TEST(HoermannHcpLightPlatformTest, RefusedCommandRepublishesTheLamp) { + LightFixture fixture; // never connected, so the hub refuses every command + + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press once the toggle is already on the wire cannot stop it, so the entity has to end up +// showing the lamp rather than the request that was refused. +TEST(HoermannHcpLightPlatformTest, RefusedPressAfterFetchShowsWhereTheLampIsHeading) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); // the controller fetches the press, so it can no longer be cancelled + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_TRUE(fixture.entity_on()); + + // A door movement while the refused toggle is still on the wire must not pull the entity back either. + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + EXPECT_TRUE(fixture.entity_on()); + + // The toggle lands and the door confirms it; the entity must already agree. + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// The lamp is only reported some time after the key press is released, so an unrelated door broadcast in +// that gap must not publish the state the lamp is about to leave. +TEST(HoermannHcpLightPlatformTest, DoorMovementDoesNotFlipTheEntityBeforeTheLampReports) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // release, so nothing is pending any more + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); // the lamp has still not been reported + + fixture.report_broadcast(make_registers({0x0000, 0x0064, 0x0100})); + + EXPECT_TRUE(fixture.entity_on()); +} + +// A toggle the controller never fetches is eventually dropped, and nothing else will ever report the lamp +// moving, so the entity has to be brought back to what the lamp actually is. +TEST(HoermannHcpLightPlatformTest, DroppedToggleReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + // The controller keeps broadcasting but never fetches the command, so the connection stays up. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + fixture.pump(); + + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); +} + +// Losing the bus controller discards the queued toggle too, so the entity must not keep showing it once the +// controller is back and still reporting the lamp unchanged. +TEST(HoermannHcpLightPlatformTest, ToggleLostWithTheConnectionReturnsTheEntityToTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); // the connection times out and the command goes with it + ASSERT_FALSE(fixture.door.is_valid()); + + connect_controller(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// The lamp can be switched at the door while the bus is quiet, so what was read before an outage must not +// decide whether a toggle is needed after it. +TEST(HoermannHcpLightPlatformTest, LampIsNotTrustedAcrossAConnectionLoss) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + fixture.report_lamp(true); + ASSERT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Back on the bus, but nothing has said what the lamp is doing yet. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(false); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); +} + +// A door that never reports the lamp leaves the entity unable to do anything, so it must not look healthy. +TEST(HoermannHcpLightPlatformTest, UnreportedLampIsFlaggedOnTheEntity) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + EXPECT_TRUE(fixture.output.status_has_warning()); + + fixture.report_lamp(false); + EXPECT_FALSE(fixture.output.status_has_warning()); +} + +// Two outstanding toggles leave the lamp where it started, so a third tap has to be judged against that and +// withdraw the one still waiting rather than deciding nothing is needed. +TEST(HoermannHcpLightPlatformTest, ThirdTapWithTwoTogglesOutstandingIsHonoured) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // the first toggle is released but not reported back + fixture.command(false); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + + // Two toggles cancel out, so asking for on again means withdrawing the second one. + fixture.command(true); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 1); + EXPECT_TRUE(fixture.entity_on()); +} + +// The boot replay is the first write and nothing else, so a real command arriving before the hub's next poll +// must not be mistaken for it and swallowed. +TEST(HoermannHcpLightPlatformTest, CommandBeforeTheFirstPollIsNotMistakenForTheBootReplay) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); // the boot replay lands here, while the lamp is still unknown + + // The first status broadcast arrives, but the hub has not polled yet, so no callback has fired. + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// On boot the restored state is replayed through write_state() before the lamp has ever been read. A lamp +// that is already on must not be switched off by that replay. +TEST(HoermannHcpLightPlatformTest, RestoredStateOnBootDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller is already up and reporting the lamp lit before the entity's first loop. + connect_controller(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_TRUE(fixture.door.is_light_on()); + + fixture.settle(); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + // Once the platform has read the lamp the entity follows it, still without commanding anything. + fixture.pump(); + EXPECT_TRUE(fixture.entity_on()); +} + +// Bus traffic makes the connection valid without saying anything about the lamp, so a request arriving before +// the first status broadcast must not be judged against a lamp state that was never read. +TEST(HoermannHcpLightPlatformTest, RequestBeforeTheLampIsReportedDoesNotCommandTheLamp) { + LightFixture fixture; + // The controller polls for commands, which is enough to connect but carries no lamp register. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_TRUE(fixture.door.is_valid()); + ASSERT_FALSE(fixture.door.is_light_known()); + + fixture.command(true); + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A toggle that has been released onto the wire is no longer pending, but the lamp has not reported it yet. +// A reversing request in that window is a real request and has to be sent, not swallowed. +TEST(HoermannHcpLightPlatformTest, ReversingRequestAfterReleaseQueuesASecondToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + poll_command(fixture.door); + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); // released, so nothing is pending and the lamp is still unreported + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + ASSERT_FALSE(fixture.door.is_light_on()); + + fixture.command(false); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); + EXPECT_FALSE(fixture.entity_on()); + + // The first toggle lands and is reported, but the entity is already heading for off. + fixture.report_lamp(true); + EXPECT_FALSE(fixture.entity_on()); + + // The second toggle lands too, and the lamp finally agrees with the request. + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(fixture.door); + fixture.report_lamp(false); + EXPECT_FALSE(fixture.entity_on()); +} + +// A refusal that has no toggle on the wire leaves nothing outstanding, so it must not latch the entity +// against the next lamp change the door reports. +TEST(HoermannHcpLightPlatformTest, RefusalWithoutAToggleStillFollowsTheLamp) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_valid()); + + // Refused because the bus is down, so no toggle is heading for the lamp. + fixture.command(true); + EXPECT_FALSE(fixture.entity_on()); + + // The controller returns and reports the lamp switched on at the door itself. + connect_controller(fixture.door); + fixture.report_lamp(true); + EXPECT_TRUE(fixture.entity_on()); +} + +// A lamp toggle carries no target, so dropping it unfetched must leave the cover's target alone. +TEST(HoermannHcpLightTest, DroppedLampToggleKeepsTheCoverTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + + // The controller keeps broadcasting but stops fetching, so the lamp toggle expires on its own. + ASSERT_TRUE(door.toggle_light()); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0050, 0x0100})); + door.update(); + + // The target survived the lamp toggle being dropped, so the door is still stopped on the way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0240); // COMMAND_IMPULSE + EXPECT_EQ(pressed_2, 0x0000); +} + +// A door that takes the key press but never actually switches the lamp must not leave the entity showing the +// request for ever; the wait has to end so the entity can settle back on what the door reports. +TEST(HoermannHcpLightPlatformTest, ToggleTheDoorIgnoresStopsBeingWaitedFor) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the door takes press and release, then does nothing + ASSERT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_TRUE(fixture.entity_on()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); // the lamp is still off, and keeps saying so + EXPECT_FALSE(fixture.entity_on()); +} + +// A resting door's first broadcast changes nothing except the lamp finally being reported, so unless that +// counts as a change the light never hears about it and swallows the first command. +TEST(HoermannHcpLightPlatformTest, FirstLampReportReachesTheEntity) { + LightFixture fixture; + // A command poll connects the controller without saying anything about the lamp. + connect_controller(fixture.door); + fixture.pump(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // Closed, at rest, lamp off: every field matches the defaults the hub started with. + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000, 0x0000, 0x0000, 0x0000, 0x0000})); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.command(true); + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0100); // COMMAND_TOGGLE_LAMP + EXPECT_EQ(pressed_2, 0x0200); +} + +// A lost connection means the door can travel unwatched, so a target left armed would stop it long afterwards. +// Which command happened to be in the slot must not change that. +TEST(HoermannHcpLightTest, ConnectionLossWithALampTogglePendingClearsTheTarget) { + TestableHoermannHcp door; + door.connection_timeout_ms_ = 20; + connect_controller(door); + // Position 60/200 = 0.3 while opening, so a 0.5 target is armed and under way. + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x003C, 0x0100})); + ASSERT_TRUE(door.set_position(0.5f)); + consume_command(door); + ASSERT_TRUE(door.toggle_light()); + + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + door.update(); + ASSERT_FALSE(door.is_valid()); + + // Back on the bus and travelling past where the target was: nothing should stop the door now. + connect_controller(door); + door.on_write_registers(BROADCAST_REG, make_registers({0x0000, 0x0078, 0x0100})); + auto [pressed, pressed_2] = poll_command(door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// Withdrawing a later toggle must not take the deadline of the one already on the wire with it, or a door +// that never reports the lamp would leave the entity waiting for ever. +TEST(HoermannHcpLightPlatformTest, WithdrawingALaterToggleKeepsTheWatchdogArmed) { + LightFixture fixture; + fixture.door.connection_timeout_ms_ = 20; + fixture.bring_up(); + + fixture.command(true); + consume_command(fixture.door); // the first toggle is released but never reported back + fixture.command(false); + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 2); + fixture.command(true); // withdraws the second, leaving the first outstanding + ASSERT_EQ(fixture.door.light_toggles_in_flight_, 1); + + // The door still says nothing about the lamp, so the wait has to time out on its own. + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + fixture.report_lamp(false); + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_FALSE(fixture.entity_on()); +} + +// A request refused while the lamp is unknown must leave the entity idle. Republishing unconditionally would +// re-enter write_state() on every loop, so the platform would never stop asking to be written. +TEST(HoermannHcpLightPlatformTest, RefusedRequestLeavesTheEntityIdle) { + LightFixture fixture; + connect_controller(fixture.door); + fixture.settle(); + ASSERT_FALSE(fixture.door.is_light_known()); + + // The lamp is unknown and the entity already shows off, so asking for off cannot be serviced or displayed. + fixture.command(false); + const int settled_writes = fixture.output.writes; + fixture.settle(); + EXPECT_EQ(fixture.output.writes, settled_writes); +} + +// A door that acts on the key press and reports the lamp before the release is even fetched leaves nothing +// outstanding. Arming the watchdog on that release anyway would leave it firing on every poll and abandoning +// the next toggle the moment it is queued. +TEST(HoermannHcpLightTest, ReleaseWithNothingOutstandingLeavesTheWatchdogDisarmed) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + poll_command(door); // the door is shown the key press + + // The door acts on it and reports the lamp straight away, which settles the count. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + ASSERT_EQ(door.light_toggles_in_flight_, 0); + + std::this_thread::sleep_for(KEY_PRESS_ELAPSED); + poll_command(door); // the release, with nothing left to wait for + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// A restore mode that boots the entity on replays a lit state the door has never confirmed, so it has to be +// adopted back to what is known rather than turned into a command. +TEST(HoermannHcpLightPlatformTest, RestoredOnStateIsAdoptedNotCommanded) { + LightFixture fixture{light::LIGHT_ALWAYS_ON}; + connect_controller(fixture.door); + fixture.settle(); + + auto [idle, idle_2] = poll_command(fixture.door); + EXPECT_EQ(idle, 0x0000); + EXPECT_EQ(idle_2, 0x0000); + EXPECT_FALSE(fixture.entity_on()); +} + +// A reversing press before the toggle is fetched cancels it, so the lamp never moves. +TEST(HoermannHcpLightPlatformTest, ReversingPressCancelsTheQueuedToggle) { + LightFixture fixture; + fixture.bring_up(); + + fixture.command(true); + ASSERT_TRUE(fixture.door.is_light_toggle_pending_()); + + fixture.command(false); + EXPECT_FALSE(fixture.door.is_light_toggle_pending_()); + EXPECT_FALSE(fixture.entity_on()); + + // Nothing is left for the controller to fetch, so the lamp stays off as asked. + auto [pressed, pressed_2] = poll_command(fixture.door); + EXPECT_EQ(pressed, 0x0000); + EXPECT_EQ(pressed_2, 0x0000); +} + +// A lamp switched at the door itself is not one of our toggles landing, so a toggle the door has not even +// been shown has to keep counting. +TEST(HoermannHcpLightTest, DoorSideLampChangeLeavesAnUnsentToggleCounted) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + // The toggle still in the slot will invert what the door just reported. + EXPECT_FALSE(door.is_light_heading_on()); +} + +// Once the toggles left over are all still waiting in the slot, nothing the door has seen is outstanding, +// so the wait has to end rather than time out against toggles the door was never shown. +TEST(HoermannHcpLightTest, SettlingTheLastSentToggleEndsTheWait) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, so the wait for a lamp report starts + ASSERT_TRUE(door.toggle_light()); // queued behind it, never shown + ASSERT_NE(door.light_toggle_released_at_, 0u); + + // The door reports the lamp change the first toggle caused, leaving only the unsent one. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + + ASSERT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_EQ(door.light_toggle_released_at_, 0u); +} + +// The watchdog gives up on the toggles the door was shown, but one still waiting in the command slot is +// going to fire, so it keeps counting. +TEST(HoermannHcpLightTest, WatchdogKeepsAToggleTheDoorHasNotSeen) { + TestableHoermannHcp door; + // Wide enough that the toggle queued after the sleep cannot expire before update() runs. + door.connection_timeout_ms_ = 200; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + consume_command(door); // shown to the door, which then says nothing about the lamp + + std::this_thread::sleep_for(std::chrono::milliseconds(220)); + // Queued just now, so only the wait for the first toggle is overdue. + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + ASSERT_TRUE(door.toggle_light()); + door.update(); + + EXPECT_EQ(door.light_toggles_in_flight_, 1); + EXPECT_TRUE(door.is_light_toggle_pending_()); + EXPECT_TRUE(door.is_light_heading_on()); +} + +// Only the parity of the outstanding count says where the lamp is heading, so the count must not run away. +TEST(HoermannHcpLightTest, TogglesAreRefusedOnceTooManyAreOutstanding) { + TestableHoermannHcp door; + connect_controller(door); + door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0000)); + + // The door takes every key press but never reports the lamp, so nothing is ever confirmed. + for (int i = 0; i < 4; i++) { + ASSERT_TRUE(door.toggle_light()); + consume_command(door); + } + + EXPECT_FALSE(door.toggle_light()); + EXPECT_EQ(door.light_toggles_in_flight_, 4); +} + +// A controller that stops carrying the lamp register leaves nothing refreshing it, so the entity has to flag +// itself rather than command against what was read before. +TEST(HoermannHcpLightPlatformTest, BroadcastWithoutTheLampRegisterMarksItUnknown) { + LightFixture fixture; + fixture.bring_up(); + ASSERT_TRUE(fixture.door.is_light_known()); + + fixture.report_broadcast(make_registers({0x0000, 0x0000, 0x4000})); + + EXPECT_FALSE(fixture.door.is_light_known()); + EXPECT_TRUE(fixture.output.status_has_warning()); +} + +// A publish of ours only reaches write_state() a loop pass later. If the lamp changed at the door in that +// gap, the write still carries the old value and must not be taken for a request to invert the lamp. +TEST(HoermannHcpLightPlatformTest, PublishOvertakenByTheLampIsNotARequest) { + LightFixture fixture; + fixture.bring_up(); + // A door command holds the only command slot, so the request below is refused and the lamp published back. + ASSERT_TRUE(fixture.door.open_door()); + + auto call = fixture.state.make_call(); + call.set_state(true); + call.perform(); + fixture.state.loop(); // the refusal happens here and schedules the publish for a later pass + + // The slot frees up and the lamp is switched on at the door before that publish arrives. + consume_command(fixture.door); + fixture.door.on_write_registers(BROADCAST_REG, lamp_broadcast(0x0010)); + fixture.settle(); + + EXPECT_EQ(fixture.door.light_toggles_in_flight_, 0); + EXPECT_TRUE(fixture.entity_on()); +} + +} // namespace esphome::hoermann_hcp::testing diff --git a/tests/components/hoermann_hcp/test.esp32-idf.yaml b/tests/components/hoermann_hcp/test.esp32-idf.yaml new file mode 100644 index 0000000000..ce3aa2437a --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp32-idf.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/hoermann_hcp/test.esp8266-ard.yaml b/tests/components/hoermann_hcp/test.esp8266-ard.yaml new file mode 100644 index 0000000000..8f7ba81b5b --- /dev/null +++ b/tests/components/hoermann_hcp/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + modbus_server: !include ../../test_build_components/common/modbus_server/esp8266-ard.yaml + hoermann_hcp: !include common.yaml diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index 7703b02fcd..3bc6d5b2b8 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -2,16 +2,16 @@ namespace esphome::mitsubishi_cn105::testing { -struct TestContext { +struct MitsubishiCN105TestsContext { MockUARTComponent uart; uart::UARTDevice device{&uart}; TestableMitsubishiCN105 sut{device}; - TestContext() { this->sut.set_current_time(0); } + MitsubishiCN105TestsContext() { this->sut.set_current_time(0); } }; TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_current_time(123); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::NOT_CONNECTED); @@ -26,7 +26,7 @@ TEST(MitsubishiCN105Tests, InitSendsConnectPacket) { } TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -106,7 +106,7 @@ TEST(MitsubishiCN105Tests, ConnectAndUpdateStatus) { } TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -133,7 +133,7 @@ TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { } TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -164,7 +164,7 @@ TEST(MitsubishiCN105Tests, RxWatchdogLimitsProcessingPerUpdate) { } TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.initialize(); ctx.uart.tx.clear(); // Remove first connect packet bytes @@ -228,7 +228,7 @@ TEST(MitsubishiCN105Tests, ParserHandlesMixedRxStream) { } TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(80000); @@ -258,7 +258,7 @@ TEST(MitsubishiCN105Tests, NextStatusUpdateAfterUpdateIntervalMilliseconds) { } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x01, 0x03, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x55}); @@ -266,14 +266,14 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedA) { ctx.sut.update(); EXPECT_TRUE(ctx.sut.status().power_on); - EXPECT_FALSE(ctx.sut.use_temperature_encoding_b_); + EXPECT_FALSE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 26.0f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::COOL); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::QUIET); } TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx( {0xFC, 0x62, 0x01, 0x30, 0x0C, 0x02, 0x00, 0x00, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0xA5, 0xAD}); @@ -281,14 +281,14 @@ TEST(MitsubishiCN105Tests, DecodeStatusSettingsPackageTempEncodedB) { ctx.sut.update(); EXPECT_FALSE(ctx.sut.status().power_on); - EXPECT_TRUE(ctx.sut.use_temperature_encoding_b_); + EXPECT_TRUE(ctx.sut.property_context_.use_temperature_encoding_b); EXPECT_EQ(ctx.sut.status().target_temperature, 18.5f); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::FAN_ONLY); EXPECT_EQ(ctx.sut.status().fan_mode, MitsubishiCN105::FanMode::SPEED_4); } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x5D}); @@ -298,7 +298,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedA) { } TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x07, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0xBC, 0xA7}); @@ -308,7 +308,7 @@ TEST(MitsubishiCN105Tests, DecodeStatusRoomTempPackageTempEncodedB) { } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x58}); @@ -316,11 +316,11 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitNotSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_FALSE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_FALSE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.uart.push_rx({0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x83, 0x00, 0x00, 0x00, 0x00, 0x00, 0xD8}); @@ -328,11 +328,11 @@ TEST(MitsubishiCN105Tests, DecodeWideVanePackageHighBitSet) { ctx.sut.update(); EXPECT_EQ(ctx.sut.status().wide_vane_mode, MitsubishiCN105::WideVaneMode::CENTER); - EXPECT_TRUE(ctx.sut.set_wide_vane_high_bit_); + EXPECT_TRUE(ctx.sut.property_context_.set_wide_vane_high_bit); } TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_power(true); ctx.sut.apply_settings(); @@ -342,7 +342,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsPowerOn) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_target_temperature(23.0f); ctx.sut.apply_settings(); @@ -352,9 +352,9 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedA) { } TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.0f); ctx.sut.apply_settings(); @@ -363,9 +363,9 @@ TEST(MitsubishiCN105Tests, ApplySettingsTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_target_temperature(26.5f); ctx.sut.apply_settings(); @@ -374,7 +374,7 @@ TEST(MitsubishiCN105Tests, ApplySettingsHalfDegreeTemperatureEncodedB) { } TEST(MitsubishiCN105Tests, ApplyModeCool) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_mode(MitsubishiCN105::Mode::COOL); ctx.sut.apply_settings(); @@ -384,7 +384,7 @@ TEST(MitsubishiCN105Tests, ApplyModeCool) { } TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_fan_mode(MitsubishiCN105::FanMode::SPEED_1); ctx.sut.apply_settings(); @@ -394,7 +394,7 @@ TEST(MitsubishiCN105Tests, ApplyFanModeSpeed1) { } TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_vane_mode(MitsubishiCN105::VaneMode::SWING); ctx.sut.apply_settings(); @@ -404,7 +404,7 @@ TEST(MitsubishiCN105Tests, ApplyVaneModeSwing) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -414,9 +414,9 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitNotSet) { } TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; - ctx.sut.set_wide_vane_high_bit_ = true; + ctx.sut.property_context_.set_wide_vane_high_bit = true; ctx.sut.set_wide_vane_mode(MitsubishiCN105::WideVaneMode::LEFT); ctx.sut.apply_settings(); @@ -425,7 +425,7 @@ TEST(MitsubishiCN105Tests, ApplyWideVaneModeLeftAndHighBitSet) { } TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -445,7 +445,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { EXPECT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Write new values - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -470,7 +470,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { } TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Set remote temperature ctx.sut.set_remote_temperature(28.5f); @@ -505,10 +505,10 @@ TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { } TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; // Queue normal settings plus remote temperature together. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -521,11 +521,11 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x0F, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xBB)); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::POWER)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::TEMPERATURE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::MODE)); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::FAN)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::POWER)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::MODE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::FAN)); // ACK the first write. Remote temperature should still be pending afterward. ctx.uart.tx.clear(); @@ -533,7 +533,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); ASSERT_FALSE(ctx.sut.update()); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); // The next apply sends the remote-temperature packet and clears the last pending flag. ctx.uart.tx.clear(); @@ -545,7 +545,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { } TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_update_interval(2000); ctx.sut.set_current_time(5000); @@ -557,7 +557,7 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) ASSERT_EQ(ctx.sut.status_update_wait_credit_ms_, 0); // Interrupt that wait with a write so credit is accumulated. - ctx.sut.use_temperature_encoding_b_ = true; + ctx.sut.property_context_.use_temperature_encoding_b = true; ctx.sut.set_power(false); ctx.sut.set_target_temperature(25.0f); ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); @@ -578,28 +578,28 @@ TEST(MitsubishiCN105Tests, WriteTimeoutClearsStatusUpdateWaitCreditOnReconnect) } TEST(MitsubishiCN105Tests, SetOutOfRangeRemoteRoomTempIsIgnored) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(7.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(40.0f); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); ctx.sut.set_remote_temperature(NAN); - EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_FALSE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMinRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(8.0f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } TEST(MitsubishiCN105Tests, SetMaxRemoteRoomTemp) { - auto ctx = TestContext{}; + MitsubishiCN105TestsContext ctx; ctx.sut.set_remote_temperature(39.5f); - EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::UpdateFlag::REMOTE_TEMPERATURE)); + EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); } } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index a14043c737..f542880eef 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -8,6 +8,7 @@ #include #include "esphome/components/uart/uart_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105.h" +#include "esphome/components/mitsubishi_cn105/automation.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h" #include "esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.h" @@ -47,12 +48,11 @@ class TestableMitsubishiCN105 : public MitsubishiCN105 { public: using MitsubishiCN105::MitsubishiCN105; using MitsubishiCN105::State; - using MitsubishiCN105::UpdateFlag; + using MitsubishiCN105::PropertyId; using MitsubishiCN105::state_; using MitsubishiCN105::status_; using MitsubishiCN105::operation_start_ms_; - using MitsubishiCN105::use_temperature_encoding_b_; - using MitsubishiCN105::set_wide_vane_high_bit_; + using MitsubishiCN105::property_context_; using MitsubishiCN105::status_update_wait_credit_ms_; using MitsubishiCN105::pending_updates_; @@ -78,4 +78,11 @@ class TestableMitsubishiCN105Climate : public MitsubishiCN105Climate { MitsubishiCN105Component component_; }; +class TestableMitsubishiCN105Component : public MitsubishiCN105Component { + public: + MitsubishiCN105::Status &mutable_status() { return const_cast(this->status()); } + + void notify_status() { this->status_callback_.call(); } +}; + } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/common.yaml b/tests/components/mitsubishi_cn105/common.yaml index 5966523b34..fc14724786 100644 --- a/tests/components/mitsubishi_cn105/common.yaml +++ b/tests/components/mitsubishi_cn105/common.yaml @@ -3,6 +3,11 @@ mitsubishi_cn105: uart_id: uart_bus update_interval: 30s telemetry_request_min_interval: 120s + vane: + on_state: + - logger.log: + format: "TRIGGER: vane on_state is auto: %s" + args: ['x.vertical.direction == VERTICAL_VANE_MODE_AUTO ? "yes" : "no"'] climate: - platform: mitsubishi_cn105 @@ -10,6 +15,12 @@ climate: name: "AC Test" supported_swing_modes: BOTH +select: + - platform: mitsubishi_cn105 + mitsubishi_cn105_id: ac + vertical_vane_direction: + name: "Vertical Vane" + esphome: on_boot: then: @@ -18,3 +29,11 @@ esphome: temperature: 22.0 - mitsubishi_cn105.clear_remote_temperature: id: ac + - mitsubishi_cn105.vane.control: + id: ac + vertical: + direction: SWING + - mitsubishi_cn105.vane.control: + id: ac + vertical: + direction: !lambda return esphome::mitsubishi_cn105::VERTICAL_VANE_MODE_SWING; diff --git a/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp new file mode 100644 index 0000000000..c957759223 --- /dev/null +++ b/tests/components/mitsubishi_cn105/mitsubishi_cn105_component_tests.cpp @@ -0,0 +1,73 @@ +#include "common.h" + +namespace esphome::mitsubishi_cn105::testing { + +TEST(MitsubishiCN105ComponentTests, PublishesVaneStateForEveryValidSnapshot) { + TestableMitsubishiCN105Component hub; + size_t callback_count = 0; + std::optional callback_direction; + hub.add_on_vane_state_callback([&](const VaneState &state) { + callback_count++; + callback_direction = state.vertical.direction; + }); + + hub.mutable_status().room_temperature = 20.0f; + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + hub.publish_status(); + + EXPECT_EQ(callback_count, 1); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); + + hub.publish_status(); + + EXPECT_EQ(callback_count, 2); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); +} + +TEST(MitsubishiCN105ComponentTests, PublishesUnknownVaneState) { + TestableMitsubishiCN105Component hub; + size_t status_callback_count = 0; + size_t vane_callback_count = 0; + std::optional callback_direction; + hub.add_on_status_callback([&]() { status_callback_count++; }); + hub.add_on_vane_state_callback([&](const VaneState &state) { + vane_callback_count++; + callback_direction = state.vertical.direction; + }); + + hub.mutable_status().room_temperature = 20.0f; + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + hub.publish_status(); + + EXPECT_EQ(status_callback_count, 1); + EXPECT_EQ(vane_callback_count, 1); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_UNKNOWN}); + + hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::POSITION_4; + hub.publish_status(); + + EXPECT_EQ(status_callback_count, 2); + EXPECT_EQ(vane_callback_count, 2); + EXPECT_EQ(callback_direction, std::optional{VERTICAL_VANE_MODE_POSITION_4}); +} + +TEST(MitsubishiCN105ComponentTests, VaneCallAppliesVerticalDirection) { + TestableMitsubishiCN105Component hub; + + auto call = hub.make_vane_call(); + call.vertical.set_direction(VERTICAL_VANE_MODE_POSITION_5); + call.perform(); + + EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_5); +} + +TEST(MitsubishiCN105ComponentTests, VaneControlActionAppliesConfiguredFields) { + TestableMitsubishiCN105Component hub; + VaneControlAction<> action(&hub, [](VaneCall &call) { call.vertical.set_direction(VERTICAL_VANE_MODE_SWING); }); + + action.play(); + + EXPECT_EQ(hub.status().vane_mode, MitsubishiCN105::VaneMode::SWING); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp new file mode 100644 index 0000000000..1f928e3bf4 --- /dev/null +++ b/tests/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical_tests.cpp @@ -0,0 +1,104 @@ +#include "../common.h" +#include "esphome/components/mitsubishi_cn105/select/mitsubishi_cn105_vane_select_vertical.h" + +namespace esphome::mitsubishi_cn105::testing { + +class TestableMitsubishiCN105VerticalVaneDirectionSelect : public MitsubishiCN105VerticalVaneDirectionSelect { + public: + using MitsubishiCN105VerticalVaneDirectionSelect::control; +}; + +struct VerticalVaneDirectionSelectTestContext { + TestableMitsubishiCN105Component hub; + TestableMitsubishiCN105VerticalVaneDirectionSelect select; + + VerticalVaneDirectionSelectTestContext() { + this->select.traits.set_options({"Auto", "1", "2", "3", "4", "5", "Swing"}); + this->select.set_parent(&this->hub); + this->select.setup(); + } +}; + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, MapsIndexesToVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array expected_modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < expected_modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.select.control(i); + EXPECT_EQ(ctx.hub.status().vane_mode, expected_modes[i]); + } +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, PublishesIncomingVaneModes) { + VerticalVaneDirectionSelectTestContext ctx; + + constexpr std::array modes{ + MitsubishiCN105::VaneMode::AUTO, MitsubishiCN105::VaneMode::POSITION_1, + MitsubishiCN105::VaneMode::POSITION_2, MitsubishiCN105::VaneMode::POSITION_3, + MitsubishiCN105::VaneMode::POSITION_4, MitsubishiCN105::VaneMode::POSITION_5, + MitsubishiCN105::VaneMode::SWING, + }; + + for (size_t i = 0; i < modes.size(); ++i) { + SCOPED_TRACE(i); + ctx.hub.mutable_status().vane_mode = modes[i]; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{i}); + } + + ctx.hub.mutable_status().vane_mode = MitsubishiCN105::VaneMode::UNKNOWN; + ctx.hub.notify_status(); + EXPECT_EQ(ctx.select.active_index(), std::optional{modes.size() - 1}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ControlPublishesSelectAndClimateThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + ctx.select.control(6); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_VERTICAL); + + ctx.select.control(3); + EXPECT_EQ(ctx.select.active_index(), std::optional{3}); + EXPECT_EQ(climate_entity.swing_mode, climate::CLIMATE_SWING_OFF); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, ClimateControlPublishesSelectThroughHub) { + VerticalVaneDirectionSelectTestContext ctx; + MitsubishiCN105Climate climate_entity; + climate_entity.set_parent(&ctx.hub); + climate_entity.set_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); + + ctx.hub.mutable_status().room_temperature = 20.0f; + climate_entity.setup(); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_VERTICAL).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{6}); + + climate_entity.make_call().set_swing_mode(climate::CLIMATE_SWING_OFF).perform(); + EXPECT_EQ(ctx.select.active_index(), std::optional{0}); +} + +TEST(MitsubishiCN105VerticalVaneDirectionSelectTests, BeforeInitializationDoesNotPublishSelectState) { + VerticalVaneDirectionSelectTestContext ctx; + + ctx.select.control(3); + + EXPECT_EQ(ctx.hub.status().vane_mode, MitsubishiCN105::VaneMode::POSITION_3); + EXPECT_FALSE(ctx.select.has_state()); +} + +} // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/modbus/modbus_client_device_test.cpp b/tests/components/modbus/modbus_client_device_test.cpp index 38c28ce2df..333da5b228 100644 --- a/tests/components/modbus/modbus_client_device_test.cpp +++ b/tests/components/modbus/modbus_client_device_test.cpp @@ -118,6 +118,35 @@ TEST(ModbusClientDeviceFanOut, ReadHoldingRegistersSuccess) { EXPECT_FALSE(call.status.has_value()); } +// FC 0x17: the response carries only the read block, so it decodes as a holding-register read of the read +// start/count. The write half has no client-side ack callback - it is confirmed by a successful response. +TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersDeliversReadBlockAsHolding) { + RecordingDevice device; + // read 2 regs at 0x0010, write 1 reg (0x00FF) at 0x0020 + const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF}; + const uint8_t response[] = {0x17, 0x04, 0x00, 0x2A, 0x01, 0x00}; // read-back: 0x002A, 0x0100 + device.on_response(request, response); + + ASSERT_EQ(device.holding_calls.size(), 1u); + const auto &call = device.holding_calls.front(); + EXPECT_EQ(call.start_address, 0x0010); // the READ start address, not the write + EXPECT_EQ(call.registers, (std::vector{0x002A, 0x0100})); + EXPECT_FALSE(call.status.has_value()); + EXPECT_TRUE(device.write_multiple_registers_calls.empty()); // no separate write-ack on the client side +} + +// A 0x17 response shorter than the requested read count is self-consistent but wrong; it must be diverted +// to on_custom_response(), never clamped and delivered as if complete. +TEST(ModbusClientDeviceFanOut, ReadWriteMultipleRegistersShortResponseGoesToCustom) { + RecordingDevice device; + const uint8_t request[] = {0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, 0x00, 0x01, 0x02, 0x00, 0xFF}; + const uint8_t response[] = {0x17, 0x02, 0x00, 0x2A}; // only 1 register, but 2 were requested + device.on_response(request, response); + + EXPECT_TRUE(device.holding_calls.empty()); + EXPECT_EQ(device.custom_requests.size(), 1u); +} + TEST(ModbusClientDeviceFanOut, ReadInputRegistersDelegateToGeneric) { GenericDevice device; const uint8_t request[] = {0x04, 0x00, 0x10, 0x00, 0x01}; diff --git a/tests/components/modbus/modbus_client_hub_test.cpp b/tests/components/modbus/modbus_client_hub_test.cpp index c2a36c0da7..43c81bbf34 100644 --- a/tests/components/modbus/modbus_client_hub_test.cpp +++ b/tests/components/modbus/modbus_client_hub_test.cpp @@ -684,6 +684,155 @@ TEST(ModbusClientHubSent, FiresOnWireNotOnQueue) { EXPECT_TRUE(hub.waiting()); } +namespace { +// Records on_sent / on_response / on_no_response so a broadcast's fire-and-forget completion +// (on_sent, and no terminal) can be asserted. +class BroadcastProbeDevice : public ModbusClientDevice { + public: + BroadcastProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { this->sent_count_++; } + void on_response(std::span request_pdu, std::span response_pdu) override { + this->response_count_++; + this->last_response_size_ = response_pdu.size(); + } + bool on_no_response(std::span request_pdu) override { + this->no_response_count_++; + return false; + } + int sent_count_{0}; + int response_count_{0}; + int no_response_count_{0}; + size_t last_response_size_{0}; +}; +} // namespace + +// A broadcast (address 0) is never answered (Modbus 4.1), so the client treats it as fire-and-forget: +// on_sent fires as the frame goes out, NO terminal (on_response/on_error/on_no_response) is delivered, +// the hub is left NOT waiting - no timeout is burned - and the sweep erases the entry. +TEST(ModbusClientHubBroadcast, CompletesAtTransmissionWithoutWaiting) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001 + ASSERT_TRUE(device.queue_pdu(write)); + EXPECT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire + EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback + EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply + EXPECT_FALSE(hub.waiting()); // no waiting slot occupied + EXPECT_EQ(hub.queued_frames(), 0u); // and the entry is gone + EXPECT_EQ(hub.entries(), 0u); +} + +namespace { +// Keeps the DEFAULT on_response() (so the base typed dispatcher runs) and records the typed write +// callback and the catch-all, to prove a broadcast reaches neither - only on_sent. +class BroadcastTypedProbeDevice : public ModbusClientDevice { + public: + BroadcastTypedProbeDevice(ModbusClientHub *hub, uint8_t address) : ModbusClientDevice(hub, address) {} + void on_sent(std::span request_pdu) override { this->sent_count_++; } + void on_write_single_register(uint16_t address, uint16_t value, ResponseStatus status) override { + this->write_single_count_++; + } + void on_custom_response(std::span request_pdu, std::span response_pdu, + ResponseStatus status) override { + this->custom_count_++; + } + int sent_count_{0}; + int write_single_count_{0}; + int custom_count_{0}; +}; +} // namespace + +// Completing a broadcast with an empty response({}) used to fall, for a device on the default +// on_response(), through the typed dispatcher to on_custom_response() - firing the wrong callback and +// logging a spurious "non-standard" warning. Fire-and-forget delivers no terminal at all, so a broadcast +// write reaches neither the typed write callback nor the catch-all: only on_sent. +TEST(ModbusClientHubBroadcast, DeliversNoTerminalToTypedDevice) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastTypedProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t write[] = {0x06, 0x00, 0x10, 0x00, 0x01}; // write single register 0x0010 = 0x0001 + ASSERT_TRUE(device.queue_pdu(write)); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // on_sent still reports the transmission + EXPECT_EQ(device.write_single_count_, 0); // no terminal: the typed write callback never fires + EXPECT_EQ(device.custom_count_, 0); // and it is NOT diverted to the catch-all (no false warning) + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); +} + +// A broadcast is only meaningful for a command that changes state; a broadcast READ could never be +// answered, so the hub refuses it at the door (false return, no entry queued) rather than silently +// retiring it. Writes, 0x17, and custom codes still go through (covered above). +TEST(ModbusClientHubBroadcast, RefusesReadBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t read[] = {0x03, 0x00, 0x10, 0x00, 0x02}; // read holding registers 0x0010, count 2 + EXPECT_FALSE(device.queue_pdu(read)); // refused: a broadcast read is never answered + EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine + EXPECT_FALSE(hub.waiting()); + + hub.send_next_for_test(); // nothing to send + EXPECT_EQ(device.sent_count_, 0); // never transmitted +} + +// The counterpart to RefusesReadBroadcast: a custom (user-defined) function code carries no reply the +// hub knows how to expect, so a broadcast of one is accepted and completes fire-and-forget like a write. +TEST(ModbusClientHubBroadcast, AcceptsCustomBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t custom[] = {0x41, 0x01, 0x02}; // FC 0x41: first user-defined function code space + ASSERT_TRUE(device.queue_pdu(custom)); // accepted: a custom code is not a read + EXPECT_EQ(hub.queued_frames(), 1u); + + hub.send_next_for_test(); // transmit + sweep + + EXPECT_EQ(device.sent_count_, 1); // the frame went on the wire + EXPECT_EQ(device.response_count_, 0); // fire-and-forget: no terminal callback + EXPECT_EQ(device.no_response_count_, 0); // and it never waited for a reply + EXPECT_FALSE(hub.waiting()); + EXPECT_EQ(hub.entries(), 0u); // the entry is gone +} + +// An exception-flagged custom code (0x80 bit set) is not a real request: is_function_code_custom() masks +// the bit away and would accept it, but the broadcast guard excludes it, matching classify()'s handling +// of an exception-flagged write. +TEST(ModbusClientHubBroadcast, RefusesExceptionFlaggedCustomBroadcast) { + NullUART uart; + NoResponseProbeHub hub; + hub.set_uart_parent(&uart); + hub.setup(); + BroadcastProbeDevice device(&hub, BROADCAST_ADDRESS); + + const uint8_t exception_custom[] = {0xC1, 0x01, 0x02}; // 0x41 | 0x80: custom code with the exception bit + EXPECT_FALSE(device.queue_pdu(exception_custom)); // refused: exception-flagged, never a real broadcast + EXPECT_EQ(hub.entries(), 0u); // nothing entered the machine + EXPECT_FALSE(hub.waiting()); + + hub.send_next_for_test(); // nothing to send + EXPECT_EQ(device.sent_count_, 0); // never transmitted +} + namespace { // tx_blocked() clear for send_next_frame_'s gate, then blocked for send_frame_'s post-delay re-check. class RejectPostDelayHub : public NoResponseProbeHub { diff --git a/tests/components/modbus/modbus_helpers_test.cpp b/tests/components/modbus/modbus_helpers_test.cpp index 6a65c3bf68..53f51b016b 100644 --- a/tests/components/modbus/modbus_helpers_test.cpp +++ b/tests/components/modbus/modbus_helpers_test.cpp @@ -483,6 +483,71 @@ TEST(ModbusTypedBuilders, WriteRegistersPduRejectsOverLimit) { EXPECT_FALSE(create_write_registers_pdu(0x0000, values).empty()); } +TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduWireBytes) { + const uint16_t write_values[] = {0x000B, 0x0016}; + // Read 2 registers at 0x0010, write 2 registers at 0x0020. + auto pdu = create_read_write_multiple_registers_pdu(0x0010, 2, 0x0020, write_values); + const std::vector expected{0x17, 0x00, 0x10, 0x00, 0x02, 0x00, 0x20, + 0x00, 0x02, 0x04, 0x00, 0x0B, 0x00, 0x16}; + EXPECT_EQ(std::vector(pdu.begin(), pdu.end()), expected); + EXPECT_TRUE(is_client_pdu_standard(pdu.data(), pdu.size())); +} + +TEST(ModbusTypedBuilders, ReadWriteMultipleRegistersPduRejectsOutOfRange) { + const uint16_t one_value[] = {0x0001}; + const uint16_t two_values[] = {0x0001, 0x0002}; + // Read count out of range (zero and above the read ceiling). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 0, 0x0020, one_value).empty()); + EXPECT_TRUE( + create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ + 1, 0x0020, one_value).empty()); + // Write count out of range (empty, and above the read/write ceiling which is lower than a plain write). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, std::span()).empty()); + std::vector too_many(MAX_NUM_OF_REGISTERS_TO_WRITE_RW + 1, 0xAAAA); + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 1, 0x0020, too_many).empty()); + // Both blocks at their respective ceilings are accepted. + std::vector at_write_limit(MAX_NUM_OF_REGISTERS_TO_WRITE_RW, 0xAAAA); + EXPECT_FALSE( + create_read_write_multiple_registers_pdu(0x0000, MAX_NUM_OF_REGISTERS_TO_READ, 0x0020, at_write_limit).empty()); + // A block that runs past the 16-bit address space is refused (read block, then write block). + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0xFFFF, 2, 0x0020, one_value).empty()); + EXPECT_TRUE(create_read_write_multiple_registers_pdu(0x0000, 2, 0xFFFF, two_values).empty()); + // Accept boundary: a block ending exactly at 0x10000 (last register 0xFFFF) still fits. + EXPECT_FALSE(create_read_write_multiple_registers_pdu(0xFFFE, 2, 0x0000, one_value).empty()); // read ends at 0x10000 + EXPECT_FALSE( + create_read_write_multiple_registers_pdu(0x0000, 1, 0xFFFF, one_value).empty()); // write ends at 0x10000 +} + +TEST(ModbusFunctionCodeClass, ReadWriteMultipleCountsAsBothReadAndWrite) { + const auto rw = static_cast(FC::READ_WRITE_MULTIPLE_REGISTERS); + // 0x17 both reads and writes, but it is not a pure (retry-safe) read. + EXPECT_TRUE(is_function_code_read(rw)); + EXPECT_TRUE(is_function_code_write(rw)); + EXPECT_FALSE(is_function_code_read_only(rw)); + // Pure reads are read and read-only, never write. + const auto rd = static_cast(FC::READ_HOLDING_REGISTERS); + EXPECT_TRUE(is_function_code_read(rd)); + EXPECT_TRUE(is_function_code_read_only(rd)); + EXPECT_FALSE(is_function_code_write(rd)); + // Plain writes are write only. + const auto wr = static_cast(FC::WRITE_MULTIPLE_REGISTERS); + EXPECT_TRUE(is_function_code_write(wr)); + EXPECT_FALSE(is_function_code_read(wr)); + EXPECT_FALSE(is_function_code_read_only(wr)); + // Mask-write register mutates via read-modify-write, so it classes as a write, never a read. + const auto mask = static_cast(FC::MASK_WRITE_REGISTER); + EXPECT_TRUE(is_function_code_write(mask)); + EXPECT_FALSE(is_function_code_read(mask)); + EXPECT_FALSE(is_function_code_read_only(mask)); +} + +TEST(ModbusCreateClientPdu, ReadWriteMultipleReturnsEmpty) { + // The generic builder cannot express 0x17's two blocks; callers use the dedicated builder instead. + const uint16_t values[] = {0x0001}; + EXPECT_TRUE(create_client_pdu(FC::READ_WRITE_MULTIPLE_REGISTERS, 0x0000, 1, reinterpret_cast(values), + sizeof(values)) + .empty()); +} + TEST(ModbusTypedBuilders, FloatToPayloadAppendsToExistingContent) { // The container overload appends - the semantic every migrated caller relies on when a lambda // has already put words into the buffer. diff --git a/tests/components/modbus_client/common.yaml b/tests/components/modbus_client/common.yaml index cae2002342..bce2149fbf 100644 --- a/tests/components/modbus_client/common.yaml +++ b/tests/components/modbus_client/common.yaml @@ -35,6 +35,8 @@ button: id(bare_client).write_single_register(0x10, 42); id(bare_client).write_single_coil(0x01, true); id(bare_client_explicit_hub).read_holding_registers(0x20, 4); + const uint16_t rw_vals[] = {1, 2}; + id(bare_client).read_write_multiple_registers(0x0400, 2, 0x0300, rw_vals); - platform: template name: "Send Read" on_press: @@ -134,3 +136,13 @@ button: on_error: then: - lambda: 'ESP_LOGW("modbus_client.test", "fc 0x%X exception %d", request.empty() ? 0 : request[0], (int) exception_code);' + - modbus_client.read_write_multiple_registers: + address: 0x01 + write_address: 0x0300 + values: !lambda "return {1, 2};" + read_address: 0x0400 + read_count: 2 + on_response: + then: + # `values` here is the READ-BACK block, not the written block above + - lambda: 'ESP_LOGI("modbus_client.test", "rw read0=%u n=%u", values[0], (unsigned) values.size());' diff --git a/tests/components/modbus_server/common.yaml b/tests/components/modbus_server/common.yaml index 1f3a8f551b..3f84a3f6da 100644 --- a/tests/components/modbus_server/common.yaml +++ b/tests/components/modbus_server/common.yaml @@ -15,6 +15,16 @@ modbus_server: - id: modbus_server3 address: 0x3 modbus_id: mod_bus2 + bits: + - address: 0x0 + read_lambda: |- + return true; + - address: 0x1 + read_lambda: |- + return address == 0x1; + write_lambda: |- + printf("bit address=%d, value=%d\n", (int) address, (int) x); + return true; registers: - address: 0x9 value_type: S_DWORD diff --git a/tests/components/modbus_server/modbus_server_test.cpp b/tests/components/modbus_server/modbus_server_test.cpp index 2137a77f3d..ce39e83736 100644 --- a/tests/components/modbus_server/modbus_server_test.cpp +++ b/tests/components/modbus_server/modbus_server_test.cpp @@ -105,15 +105,29 @@ TEST(ModbusServerWrite, UnwritableRegisterRejected) { EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } -// An address with no registered register yields ILLEGAL_DATA_ADDRESS. +// A write to an address not covered by any configured register (on a populated server) yields +// ILLEGAL_DATA_ADDRESS. TEST(ModbusServerWrite, UnmatchedAddressRejected) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.write_lambda = [](int64_t) { return true; }; + server.add_server_register(®); + auto status = server.on_write_registers(0x0005, make_registers({0x1234})); ASSERT_TRUE(status.has_value()); if (status.has_value()) EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } +// A server with no registers configured does not implement the register-write function: ILLEGAL_FUNCTION. +TEST(ModbusServerWrite, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + auto status = server.on_write_registers(0x0000, make_registers({0x1234})); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + // A write_lambda failing at runtime is the one non-atomic case: the earlier register is already // applied, and the handler reports SERVICE_DEVICE_FAILURE. TEST(ModbusServerWrite, CallbackFailureIsServiceDeviceFailure) { @@ -248,9 +262,13 @@ TEST(ModbusServerRead, CourtesyDefaultForUnregistered) { EXPECT_EQ(out[1], 0xABCD); } -// An unregistered address with courtesy disabled is rejected. +// An unregistered address on a populated server (courtesy disabled) is rejected with ILLEGAL_DATA_ADDRESS. TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.read_lambda = []() -> int64_t { return 0x1234; }; + server.add_server_register(®); + RegisterValues out; auto status = server.on_read_registers(0x0005, 1, out); ASSERT_TRUE(status.has_value()); @@ -258,6 +276,31 @@ TEST(ModbusServerRead, UnregisteredRejectedWithoutCourtesy) { EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_DATA_ADDRESS); } +// A server with no registers configured (courtesy disabled) does not implement the register-read +// function: ILLEGAL_FUNCTION. +TEST(ModbusServerRead, EmptyServerRejectsWithIllegalFunction) { + ModbusServer server; + RegisterValues out; + auto status = server.on_read_registers(0x0005, 1, out); + ASSERT_TRUE(status.has_value()); + if (status.has_value()) + EXPECT_EQ(status.value(), ExceptionCode::ILLEGAL_FUNCTION); +} + +// A register read lambda returning an empty optional declines the read: the whole request is +// answered with SERVICE_DEVICE_FAILURE. Uses set_read_lambda so the optional-forwarding wrapper +// (not a hand-assigned read_lambda) is what carries the decline through. +TEST(ModbusServerRead, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerRegister reg(0x0000, SensorValueType::U_WORD, 1); + reg.set_read_lambda([](uint16_t address) -> optional { return {}; }); + server.add_server_register(®); + + RegisterValues out; + auto status = server.on_read_registers(0x0000, 1, out); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + // --- partial reads (opt-in) ---------------------------------------------------- // With allow_partial_read, reading only the first register of a DWORD returns its high word. @@ -310,4 +353,139 @@ TEST(ModbusServerRead, PartialReadReversedType) { EXPECT_EQ(second[0], 0x1234); } +// --- bits (coils / discrete inputs, one shared address space) ------------------- + +// Bits are read through the shared table regardless of which read function code arrived: +// the hub routes both 0x01 and 0x02 to on_read_bits(). +TEST(ModbusServerBits, ReadSetsRequestedBits) { + ModbusServer server; + ServerBit bit0(0x0000); + bit0.set_read_lambda([](uint16_t) { return true; }); + ServerBit bit1(0x0001); + bit1.set_read_lambda([](uint16_t) { return false; }); + ServerBit bit2(0x0002); + bit2.set_read_lambda([](uint16_t) { return true; }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + server.add_server_bit(&bit2); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 3)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0b101); +} + +// The read lambda receives the bit's address, so one lambda can serve several bits. +TEST(ModbusServerBits, ReadLambdaReceivesAddress) { + ModbusServer server; + ServerBit server_bit(0x0007); + server_bit.set_read_lambda([](uint16_t address) { return address == 0x0007; }); + server.add_server_bit(&server_bit); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0007, modbus::MutablePackedBits(packed, 1)); + EXPECT_FALSE(status.has_value()); + EXPECT_EQ(packed[0], 0x01); +} + +// An unregistered or write-only bit rejects the whole read with ILLEGAL_DATA_ADDRESS. +TEST(ModbusServerBits, UnreadableBitRejectsRead) { + ModbusServer server; + ServerBit readable(0x0000); + readable.set_read_lambda([](uint16_t) { return true; }); + ServerBit write_only(0x0001); + write_only.set_write_lambda([](uint16_t, bool) { return true; }); + server.add_server_bit(&readable); + server.add_server_bit(&write_only); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + + auto unregistered = server.on_read_bits(0x0005, modbus::MutablePackedBits(packed, 1)); + EXPECT_EQ(unregistered, ExceptionCode::ILLEGAL_DATA_ADDRESS); +} + +// A read lambda returning an empty optional declines the read: the whole request is answered +// with SERVICE_DEVICE_FAILURE. +TEST(ModbusServerBits, ReadLambdaDecliningIsServiceDeviceFailure) { + ModbusServer server; + ServerBit ok(0x0000); + ok.set_read_lambda([](uint16_t) { return true; }); + ServerBit declining(0x0001); + declining.set_read_lambda([](uint16_t) -> optional { return {}; }); + server.add_server_bit(&ok); + server.add_server_bit(&declining); + + uint8_t packed[1] = {0}; + auto status = server.on_read_bits(0x0000, modbus::MutablePackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); +} + +// A multi-coil write applies every bit and reports success. +TEST(ModbusServerBits, WriteAppliesAllBits) { + ModbusServer server; + bool state[2] = {false, true}; + ServerBit bit0(0x0000); + bit0.set_write_lambda([&state](uint16_t, bool value) { + state[0] = value; + return true; + }); + ServerBit bit1(0x0001); + bit1.set_write_lambda([&state](uint16_t, bool value) { + state[1] = value; + return true; + }); + server.add_server_bit(&bit0); + server.add_server_bit(&bit1); + + const uint8_t packed[1] = {0b01}; // bit0 on, bit1 off + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_FALSE(status.has_value()); + EXPECT_TRUE(state[0]); + EXPECT_FALSE(state[1]); +} + +// Pre-flight atomicity: an unwritable bit anywhere in the span rejects the write before any +// bit is applied. +TEST(ModbusServerBits, UnwritableBitAppliesNothing) { + ModbusServer server; + bool written = false; + ServerBit writable(0x0000); + writable.set_write_lambda([&written](uint16_t, bool) { + written = true; + return true; + }); + ServerBit read_only(0x0001); + read_only.set_read_lambda([](uint16_t) { return false; }); + server.add_server_bit(&writable); + server.add_server_bit(&read_only); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::ILLEGAL_DATA_ADDRESS); + EXPECT_FALSE(written); // the writable bit must NOT have been applied +} + +// A write lambda failing at runtime is the one non-atomic case: earlier bits stay applied and +// the handler reports SERVICE_DEVICE_FAILURE (mirrors the register behavior). +TEST(ModbusServerBits, CallbackFailureIsServiceDeviceFailure) { + ModbusServer server; + bool first_written = false; + ServerBit first(0x0000); + first.set_write_lambda([&first_written](uint16_t, bool) { + first_written = true; + return true; + }); + ServerBit second(0x0001); + second.set_write_lambda([](uint16_t, bool) { return false; }); // rejects at runtime + server.add_server_bit(&first); + server.add_server_bit(&second); + + const uint8_t packed[1] = {0b11}; + auto status = server.on_write_coils(0x0000, modbus::PackedBits(packed, 2)); + EXPECT_EQ(status, ExceptionCode::SERVICE_DEVICE_FAILURE); + EXPECT_TRUE(first_written); +} + } // namespace esphome::modbus_server diff --git a/tests/components/network/test-priority.esp32-ard.yaml b/tests/components/network/test-priority.esp32-ard.yaml new file mode 100644 index 0000000000..a04246a128 --- /dev/null +++ b/tests/components/network/test-priority.esp32-ard.yaml @@ -0,0 +1,23 @@ +# Arduino dual-stack test: default-route arbitration must also compile under +# the Arduino framework, which builds the same esp_netif/ESP-IDF from source. +# Ethernet is listed first so this build exercises the ethernet-first side of +# the arbitration pivot in NetworkComponent::loop() (the IDF variant of this +# test covers the wifi-first side). +wifi: + ssid: MySSID + password: password1 + +ethernet: + type: W5500 + clk_pin: GPIO19 + mosi_pin: GPIO21 + miso_pin: GPIO23 + cs_pin: GPIO18 + interrupt_pin: GPIO36 + reset_pin: GPIO22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi diff --git a/tests/components/sendspin/common-image.yaml b/tests/components/sendspin/common-image.yaml new file mode 100644 index 0000000000..7c32a5e257 --- /dev/null +++ b/tests/components/sendspin/common-image.yaml @@ -0,0 +1,47 @@ +packages: + sendspin: !include common.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + lambda: |- + it.fill(Color(0, 0, 0)); + it.image(0, 0, id(album_art)); + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + - logger.log: + format: "Album art displayed (late by %u ms)" + args: ["(unsigned) lateness_ms"] + # Stand-in for a display transition; with a transition image every display must end + # with transition_finished so the library releases the next artwork frame. + - delay: 300ms + - sendspin.image.transition_finished: album_slot + on_image_clear: + - logger.log: "Album art cleared" + on_image_error: + - logger.log: "Album art error" + - platform: sendspin + id: artist_slot + format: PNG + type: RGB565 + resize: 96x96 + source: ARTIST + current_image: + id: artist_art diff --git a/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml new file mode 100644 index 0000000000..9084d77262 --- /dev/null +++ b/tests/components/sendspin/test-image-lvgl.esp32-idf.yaml @@ -0,0 +1,66 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common.yaml + +display: + - platform: ili9xxx + spi_id: spi_bus + id: main_lcd + model: ili9342 + cs_pin: 20 + dc_pin: 13 + reset_pin: 21 + invert_colors: true + auto_clear_enabled: false + +lvgl: + displays: + - main_lcd + animations: + # Fades the top widget out to reveal the new artwork underneath. Starting it also snaps the + # widget back to full opacity, and on_stop acks the transition so the library can deliver the + # next artwork. + - id: album_art_crossfade + duration: 2s + widgets: + - id: outgoing_art + opa: + from: 100% + to: 0% + on_stop: + - sendspin.image.transition_finished: album_slot + widgets: + # Cross-fade pair: the bottom widget always shows the current artwork; the top widget is + # pointed at the outgoing frame on each display event and faded out over it. + - image: + id: incoming_art + src: album_art + - image: + id: outgoing_art + src: album_art + +image: + - platform: sendspin + id: album_slot + format: JPEG + type: RGB565 + resize: 240x240 + source: ALBUM + # Start the fade 1s before the track boundary so the 2s cross-fade straddles it. + display_offset: 1s + current_image: + id: album_art + transition_image: + id: album_art_transition + on_image_display: + # A widget keeps drawing the buffer it was last pointed at until its source is set again, so + # both widgets are re-pointed on every display: the top widget at the outgoing frame + # (covering the bottom), the bottom widget at the new frame. The transition image is black + # before the first artwork, so the first fade needs no special case. + - lvgl.image.update: + id: outgoing_art + src: album_art_transition + - lvgl.image.update: + id: incoming_art + src: album_art + - lvgl.animation.start: album_art_crossfade diff --git a/tests/components/sendspin/test-image.esp32-idf.yaml b/tests/components/sendspin/test-image.esp32-idf.yaml new file mode 100644 index 0000000000..a4f9e492c6 --- /dev/null +++ b/tests/components/sendspin/test-image.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + sendspin: !include common-image.yaml diff --git a/tests/components/web_server/test-basicauth.esp8266-ard.yaml b/tests/components/web_server/test-basicauth.esp8266-ard.yaml new file mode 100644 index 0000000000..6a01180892 --- /dev/null +++ b/tests/components/web_server/test-basicauth.esp8266-ard.yaml @@ -0,0 +1,8 @@ +packages: + web_server: !include common_v2.yaml + +web_server: + auth: + username: admin + password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + type: basic diff --git a/tests/components/web_server/test.rp2040-ard.yaml b/tests/components/web_server/test.rp2040-ard.yaml index e4d50d7776..6a01180892 100644 --- a/tests/components/web_server/test.rp2040-ard.yaml +++ b/tests/components/web_server/test.rp2040-ard.yaml @@ -4,5 +4,5 @@ packages: web_server: auth: username: admin - password: password + password: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA type: basic diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index a9c9e0686f..1bf799b658 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -63,6 +63,11 @@ def _get_platformio_env(cache_dir: Path) -> dict[str, str]: env["PLATFORMIO_LIBDEPS_DIR"] = str(cache_dir / "libdeps") # Prevent cache cleaning during integration tests env["ESPHOME_SKIP_CLEAN_BUILD"] = "1" + # Compile with THIS tree's esphome sources, not wherever the venv's editable + # install points (which may be a different git worktree or checkout). + repo_root = str(Path(__file__).resolve().parent.parent.parent) + existing = env.get("PYTHONPATH") + env["PYTHONPATH"] = f"{repo_root}{os.pathsep}{existing}" if existing else repo_root return env @@ -101,7 +106,7 @@ def shared_platformio_cache() -> Generator[Path]: env = _get_platformio_env(cache_dir) subprocess.run( - ["esphome", "compile", str(config_path)], + [sys.executable, "-m", "esphome", "compile", str(config_path)], check=True, cwd=init_dir, env=env, @@ -245,6 +250,8 @@ async def compile_esphome( for attempt in range(max_retries): # Compile using subprocess, inheriting stdout/stderr to show progress proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", "esphome", "compile", str(config_path), diff --git a/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml b/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml new file mode 100644 index 0000000000..8857bf8c96 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_broadcast_write.yaml @@ -0,0 +1,150 @@ +esphome: + name: uart-mock-modbus-broadcast + +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: true # controller polls at boot; forwarding must already be active + 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 + 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 + 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 + +globals: + - id: srv1_reg + type: int + initial_value: "0" + - id: srv2_reg + type: int + initial_value: "0" + +modbus_controller: + - address: 1 + modbus_id: virtual_modbus_client + # Polling is off until the test has subscribed; the Start Scenario button starts it, so the + # first poll is never lost to a boot-time race ahead of the API subscription. + update_interval: never + id: modbus_controller_1 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + - address: 0x01 + value_type: U_WORD + read_lambda: return 919; + - address: 0x10 + value_type: U_WORD + read_lambda: return id(srv1_reg); + write_lambda: |- + id(srv1_reg) = x; + return true; + - address: 2 + modbus_id: virtual_modbus_server_2 + registers: + - address: 0x10 + value_type: U_WORD + read_lambda: return id(srv2_reg); + write_lambda: |- + id(srv2_reg) = x; + return true; + +sensor: + # Normal polling continues before and after the broadcast: the old behavior burned a + # timeout per broadcast, which surfaces as modbus warnings and failed expectations here. + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "reg_u_word" + address: 0x01 + register_type: holding + value_type: U_WORD + # Republish every poll (the value is constant 919): the test observes successive publishes to + # prove polling continues before and after the broadcast, which dedup would otherwise hide. + force_update: true + # The servers' written values, published locally. + - platform: template + name: "srv1_written" + lambda: return id(srv1_reg); + update_interval: 0.2s + - platform: template + name: "srv2_written" + lambda: return id(srv2_reg); + update_interval: 0.2s + # Whether the hub accepted the broadcast into the transmit queue (the bool queue_pdu() returns). + - platform: template + name: "broadcast_accepted" + id: broadcast_accepted + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + - lambda: |- + // Start polling now that the test has subscribed. + id(modbus_controller_1).set_update_interval(1000); + id(modbus_controller_1).start_poller(); + // Broadcast (address 0) write single register: reg 0x10 = 777 on every server. + // PDU is function code + data (no address/CRC); the hub prepends address 0 and appends CRC. + const uint8_t pdu[] = {0x06, 0x00, 0x10, 0x03, 0x09}; + // queue_pdu() returns whether the broadcast was accepted into the machine (the answer this PR + // makes meaningful); publish it so the test asserts the accept, not just the servers' writes. + bool accepted = id(virtual_modbus_client)->queue_pdu(0x00, pdu); + id(broadcast_accepted).publish_state(accepted ? 1.0f : 0.0f); diff --git a/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml new file mode 100644 index 0000000000..1f89889c95 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_client_read_write.yaml @@ -0,0 +1,111 @@ +esphome: + name: uart-mock-modbus-cli-rw + +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 + +# Two virtual buses looped back to each other: the client's transmissions reach the server and the +# server's replies reach the client. auto_start so forwarding is active before the button fires. +uart_mock: + - id: virtual_uart_server + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_client + data: !lambda return data; + - id: virtual_uart_client + baud_rate: 9600 + auto_start: true + debug: + on_tx: + - then: + - uart_mock.inject_rx: + id: virtual_uart_server + data: !lambda return data; + +globals: + - id: stored_1 + type: uint16_t + initial_value: "0" + +modbus: + - uart_id: virtual_uart_server + id: virtual_modbus_server + role: server + - uart_id: virtual_uart_client + id: virtual_modbus_client + role: client + turnaround_time: 10ms + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + registers: + # Writable + readable register: the read publishes what it returns, so the test can confirm the + # write half of the 0x17 ran before the read half (Modbus 6.17). + - address: 0x01 + value_type: U_WORD + read_lambda: |- + id(srv_read_1).publish_state(id(stored_1)); + return id(stored_1); + write_lambda: |- + id(stored_1) = x; + id(srv_write_1).publish_state(x); + return true; + # Read-only register, returned together with 0x01 by the 2-register read half. + - address: 0x02 + value_type: U_WORD + read_lambda: return 0x00AA; + +sensor: + # Server-side observations. + - platform: template + name: "srv_write_1" + id: srv_write_1 + - platform: template + name: "srv_read_1" + id: srv_read_1 + # Client-side read-back: the values the client's on_response received. + - platform: template + name: "client_read_0" + id: client_read_0 + - platform: template + name: "client_read_1" + id: client_read_1 + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + on_press: + # FC 0x17: write reg 0x0001 = 0x1234, then read regs 0x0001..0x0002 back in the same transaction. + - modbus_client.read_write_multiple_registers: + address: 0x01 + read_address: 0x0001 + read_count: 2 + write_address: 0x0001 + values: [0x1234] + on_response: + then: + - lambda: |- + // values is the read-back block: reg 0x0001 (must be the just-written 0x1234) and reg 0x0002. + if (values.size() >= 2) { + id(client_read_0).publish_state(values[0]); + id(client_read_1).publish_state(values[1]); + } diff --git a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml index 167ad2c5bb..e445093625 100644 --- a/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml +++ b/tests/integration/fixtures/uart_mock_modbus_client_typed.yaml @@ -133,8 +133,8 @@ button: on_error: then: - lambda: "id(error_code).publish_state((int) exception_code);" - # The mock server is register-only, so a coil read draws ILLEGAL_FUNCTION - proving the bit-read - # action's request PDU and its typed error delivery. + # The mock server maps no bits, so it does not implement the coil function: a coil read draws + # ILLEGAL_FUNCTION - proving the bit-read action's request PDU and its typed error delivery. - modbus_client.read_coils: address: 1 start_address: 0x00 @@ -166,7 +166,7 @@ button: on_not_sent: then: - lambda: "id(not_sent_flag).publish_state(1);" - # Multi-coil write (fc 0x0F): the register-only server answers ILLEGAL_FUNCTION. + # Multi-coil write (fc 0x0F): the server maps no bits, so it answers ILLEGAL_FUNCTION. - modbus_client.write_multiple_coils: address: 1 start_address: 0x00 diff --git a/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml new file mode 100644 index 0000000000..cb6fc6f074 --- /dev/null +++ b/tests/integration/fixtures/uart_mock_modbus_server_controller_bits.yaml @@ -0,0 +1,147 @@ +esphome: + name: uart-mock-modbus-srv-bits + +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_bit_2 + type: bool + initial_value: "false" + - id: stored_bit_3 + type: bool + initial_value: "true" + +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 + +modbus_server: + - address: 1 + modbus_id: virtual_modbus_server + id: modbus_server_1 + bits: + - address: 0x00 + read_lambda: return true; + - address: 0x01 + read_lambda: return false; + - address: 0x02 + read_lambda: return id(stored_bit_2); + write_lambda: id(stored_bit_2) = x; return true; + - address: 0x03 + read_lambda: return id(stored_bit_3); + write_lambda: id(stored_bit_3) = x; return true; + +# The same four bits are read both as coils (FC 0x01) and as discrete inputs +# (FC 0x02): the server serves both from one shared bit table, so the two +# views must always agree. +binary_sensor: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_0" + address: 0x00 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_1" + address: 0x01 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_coil_3" + address: 0x03 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_0" + address: 0x00 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_1" + address: 0x01 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_2" + address: 0x02 + register_type: discrete_input + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "bit_di_3" + address: 0x03 + register_type: discrete_input + +# write_bit_2 uses the single-coil write (FC 0x05); write_bit_3 opts into the +# multiple-coils write (FC 0x0F) so both server write paths are exercised. +switch: + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_2" + address: 0x02 + register_type: coil + - platform: modbus_controller + modbus_controller_id: modbus_controller_1 + name: "write_bit_3" + address: 0x03 + register_type: coil + use_write_multiple: true + +button: + - platform: template + name: "Start Scenario" + id: start_scenario_btn + # This test does not have anything to start (mock is autostart) diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 65af57b944..9c0debbc5c 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -387,8 +387,9 @@ class SensorStateCollector: 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. + Tracks sensor and binary sensor state updates and resolves futures when + they report specific expected values. Eliminates per-sensor future + boilerplate. Usage:: @@ -419,20 +420,32 @@ class SensorTracker: """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: + def on_state(self, state: EntityState, first_pending_only: bool = False) -> None: + """State callback suitable for ``subscribe_states``. + + Args: + state: The state update to record + first_pending_only: Only allow the first pending expectation for this + sensor to match, instead of the first matching one. Used for + connect-time states so they cannot satisfy a later phase. + """ + if ( + not isinstance(state, (SensorState, BinarySensorState)) + 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 - ): + if future.done(): + continue + if expected_value is self._ANY or state.state == expected_value: future.set_result(True) break + if first_pending_only: + break async def await_change( self, future: asyncio.Future, name: str, timeout: float = 2.0 @@ -470,8 +483,22 @@ class SensorTracker: 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.""" + async def setup_and_start_scenario( + self, client: APIClient, match_initial_states: bool = False + ) -> list[EntityInfo]: + """Wire up subscriptions, wait for initial states, press Start Scenario. + + Args: + client: The connected API client + match_initial_states: Also match expectations against the states the + device sends when the client connects, so a value published before + the client subscribed still counts. Binary sensors need this: they + drop repeats, so a value that lands in the connect-time dump is + never sent again. Plain sensors publish on every poll, so there it + only saves waiting for the next one. Only the first pending + expectation per sensor can match, so a connect-time value cannot + satisfy a later phase. + """ entities, _ = await client.list_entities_services() self.key_to_sensor.update( build_key_to_entity_mapping(entities, list(self.sensor_states.keys())) @@ -484,6 +511,9 @@ class SensorTracker: import pytest pytest.fail("Timeout waiting for initial states") + if match_initial_states: + for state in initial_state_helper.initial_states.values(): + self.on_state(state, first_pending_only=True) 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) diff --git a/tests/integration/test_uart_mock_modbus.py b/tests/integration/test_uart_mock_modbus.py index ca0041cc5b..d0b375dd25 100644 --- a/tests/integration/test_uart_mock_modbus.py +++ b/tests/integration/test_uart_mock_modbus.py @@ -21,7 +21,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass -from aioesphomeapi import ButtonInfo, NumberInfo +from aioesphomeapi import ButtonInfo, NumberInfo, SwitchInfo import pytest from .state_utils import SensorTracker, find_entity @@ -330,7 +330,10 @@ async def test_uart_mock_modbus_server_controller( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the first values can already be in + # the states the device sends on connect; matching them there saves + # waiting for the next poll + await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) @@ -392,7 +395,12 @@ async def test_uart_mock_modbus_server_controller_write( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - entities = await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the baseline can already be in the + # states the device sends on connect; matching it there saves waiting for + # the next poll + entities = await tracker.setup_and_start_scenario( + client, match_initial_states=True + ) # Wait for initial baseline values to confirm the controller <-> server # connection is working before issuing writes @@ -411,6 +419,72 @@ async def test_uart_mock_modbus_server_controller_write( _assert_no_modbus_errors(error_log_lines, warning_log_lines) +@pytest.mark.asyncio +async def test_uart_mock_modbus_server_controller_bits( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test coil/discrete-input round trips between controller and server bits. + + The server serves four bits from one shared table. The controller reads + each of them both as a coil (FC 0x01) and as a discrete input (FC 0x02), + so the two views must always agree. Two bits are then written back, one + via the single-coil write (FC 0x05) and one via the multiple-coils write + (FC 0x0F), and the new values must show up in both read views. + """ + + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + initial_values = { + "bit_coil_0": True, + "bit_coil_1": False, + "bit_coil_2": False, + "bit_coil_3": True, + "bit_di_0": True, + "bit_di_1": False, + "bit_di_2": False, + "bit_di_3": True, + } + tracker = SensorTracker(list(initial_values.keys())) + + # Phase 1: expect initial baseline values in both read views + initial_futures = tracker.expect_all(initial_values) + # Phase 2: expect post-write values (registered now so on_state can match them) + written_futures = tracker.expect_all( + { + "bit_coil_2": True, + "bit_di_2": True, + "bit_coil_3": False, + "bit_di_3": False, + } + ) + + async with ( + run_compiled(yaml_config, line_callback=line_callback), + api_client_connected() as client, + ): + # The controller polls from boot and binary sensors drop repeats, so the + # baseline can arrive only in the states the device sends on connect + entities = await tracker.setup_and_start_scenario( + client, match_initial_states=True + ) + + # 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) + + # Flip both writable bits: 0x02 false -> true, 0x03 true -> false + for switch_name, value in (("write_bit_2", True), ("write_bit_3", False)): + entity = find_entity(entities, switch_name, SwitchInfo) + assert entity is not None, f"{switch_name} switch entity not found" + client.switch_command(entity.key, value) + + # Wait for both read views to reflect the written values + await tracker.await_all(written_futures, timeout=4.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + @pytest.mark.asyncio async def test_uart_mock_modbus_server_controller_multiple( yaml_config: str, @@ -429,7 +503,10 @@ async def test_uart_mock_modbus_server_controller_multiple( run_compiled(yaml_config, line_callback=line_callback), api_client_connected() as client, ): - await tracker.setup_and_start_scenario(client) + # The controller polls from boot, so the first values can already be in + # the states the device sends on connect; matching them there saves + # waiting for the next poll + await tracker.setup_and_start_scenario(client, match_initial_states=True) await tracker.await_all(futures) _assert_no_modbus_errors(error_log_lines, warning_log_lines) @@ -447,10 +524,11 @@ async def test_uart_mock_modbus_client_typed( with the reply decoded by the shared device dispatch into host-order words (values[0] -> typed_value); a read of unserved register 0x99 resolves via on_error with the device's exception code (ILLEGAL_DATA_ADDRESS = 2 -> error_code); a coil read of the register-only server resolves via - on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code), proving the bit-read request and typed error - delivery. A multi-register write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 - chained inside its ack handler (-> multi_value = 222); a multi-coil write draws ILLEGAL_FUNCTION from - the register-only server (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime + on_error with ILLEGAL_FUNCTION (= 1 -> coil_error_code) - the server maps no bits, so it does not + implement the coil function - proving the bit-read request and typed error delivery. A multi-register + write (fc 0x10) lands on registers 0x11/0x12 with the read-back of 0x12 chained inside its ack handler + (-> multi_value = 222); a multi-coil write likewise draws ILLEGAL_FUNCTION from the register-only server + (-> multi_coil_error = 1). A read whose count lambda returns 0 at runtime builds an empty (rejected) PDU, is refused at the hub door, and resolves via on_not_sent (-> not_sent_flag). """ @@ -756,3 +834,76 @@ async def test_uart_mock_modbus_fairness( f"controllers did not get a fair share of the bus: " f"controller 1 issued {count_1}, controller 2 issued {count_2}" ) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_broadcast_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A client broadcast write (address 0) reaches every server and costs no timeout. + + The scenario button sends a broadcast single-register write of 777 to register + 0x10; both servers must apply it. The client's normal polling sensor must keep + updating, and no modbus warnings may appear - the pre-broadcast-support behavior + parked the frame in the waiting slot until the send-wait timeout, which surfaced + here as 'Stop waiting for response' warnings and a stalled poll. + """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["reg_u_word", "srv1_written", "srv2_written", "broadcast_accepted"] + ) + poll_before = tracker.expect("reg_u_word", 919) + written = tracker.expect_all({"srv1_written": 777, "srv2_written": 777}) + # queue_pdu() must accept the broadcast into the machine (return true), the answer this PR adds. + accepted = tracker.expect("broadcast_accepted", 1) + + 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_change(accepted, "broadcast_accepted") + await tracker.await_change(poll_before, "reg_u_word") + await tracker.await_all(written) + # Polling must continue after the broadcast (a burned timeout stalls it). + poll_after = tracker.expect("reg_u_word", 919) + await tracker.await_change(poll_after, "reg_u_word", timeout=3.0) + _assert_no_modbus_errors(error_log_lines, warning_log_lines) + + +@pytest.mark.asyncio +async def test_uart_mock_modbus_client_read_write( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """A modbus_client.read_write_multiple_registers action (FC 0x17) drives a server end to end. + + The client writes reg 0x0001 = 0x1234 and reads regs 0x0001..0x0002 in one transaction; the server + applies the write first (Modbus 6.17). The test confirms both ends: the server's write_lambda ran + (srv_write_1) and the read half came back to the client's on_response (client_read_0 = the + just-written 0x1234, client_read_1 = the read-only 0x00AA). + """ + line_callback, error_log_lines, warning_log_lines = _make_modbus_line_callback() + + tracker = SensorTracker( + ["srv_write_1", "srv_read_1", "client_read_0", "client_read_1"] + ) + futures = tracker.expect_all( + { + "srv_write_1": 4660, # server wrote 0x1234 to reg 0x0001 + "client_read_0": 4660, # client read reg 0x0001 back as the just-written 0x1234 + "client_read_1": 170, # client read reg 0x0002 (0x00AA) in the same request + } + ) + + 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) diff --git a/tests/test_build_components/build_components_base.rp2350-ard.yaml b/tests/test_build_components/build_components_base.rp2350-ard.yaml index 5df1670862..f76c5fc3f9 100644 --- a/tests/test_build_components/build_components_base.rp2350-ard.yaml +++ b/tests/test_build_components/build_components_base.rp2350-ard.yaml @@ -2,8 +2,10 @@ esphome: name: componenttestrp2040pico2ard friendly_name: $component_name +# rpipico2w: superset of rpipico2 with the CYW43 radio, so wireless +# components (wifi, BLE) can share this target too. rp2: - board: rpipico2 + board: rpipico2w logger: level: VERY_VERBOSE diff --git a/tests/test_build_components/common/README.md b/tests/test_build_components/common/README.md index a3c6f476e0..010313db7f 100644 --- a/tests/test_build_components/common/README.md +++ b/tests/test_build_components/common/README.md @@ -31,11 +31,14 @@ common/ │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml -├── modbus/ # Modbus (includes uart via packages) +├── modbus/ # Modbus client (includes uart via packages) │ ├── esp32-idf.yaml │ ├── esp32-c3-idf.yaml │ ├── esp8266-ard.yaml │ └── rp2040-ard.yaml +├── modbus_server/ # Modbus server (includes uart via packages) +│ ├── esp32-idf.yaml +│ └── esp8266-ard.yaml └── ble/ ├── esp32-idf.yaml ├── esp32-ard.yaml diff --git a/tests/test_build_components/common/modbus_server/esp32-idf.yaml b/tests/test_build_components/common/modbus_server/esp32-idf.yaml new file mode 100644 index 0000000000..093467ebfd --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp32-idf.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP32 IDF tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp32-idf.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server diff --git a/tests/test_build_components/common/modbus_server/esp8266-ard.yaml b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml new file mode 100644 index 0000000000..ab9cad8b56 --- /dev/null +++ b/tests/test_build_components/common/modbus_server/esp8266-ard.yaml @@ -0,0 +1,10 @@ +# Common server-role Modbus configuration for ESP8266 Arduino tests +# Provides a shared Modbus bus that all Modbus server components can use + +packages: + uart: !include ../uart/esp8266-ard.yaml + +modbus: + - id: modbus_server_bus + uart_id: uart_bus + role: server diff --git a/tests/unit_tests/components/api/test_api_proto.py b/tests/unit_tests/components/api/test_api_proto.py new file mode 100644 index 0000000000..35aa5ff529 --- /dev/null +++ b/tests/unit_tests/components/api/test_api_proto.py @@ -0,0 +1,371 @@ +"""Invariant tests for esphome/components/api/api.proto and its generated code. + +These guard the DeviceCapabilitiesRequest/DeviceCapabilitiesResponse addition +(API 1.15) against regressions that protoc-based codegen would not catch on +its own, without requiring protoc to be installed at test time: + +* script/api_protobuf/api_protobuf.py skips any field marked + `[deprecated = true]` completely -- it generates no C++ for it at all, so + the device silently stops sending that value. Six DeviceInfoResponse fields + were superseded by DeviceCapabilitiesResponse but must keep being sent for + backward compatibility with clients older than API 1.15. If a future edit + "tidies up" by marking one of them deprecated, this file breaks that field + for every existing client with nothing else in CI noticing. +* Field numbers are the wire protocol, not the field names. Renaming a field + is harmless; renumbering it is a silent breaking change, because an old + client still decodes by number. This file pins the field number of each of + the six superseded DeviceInfoResponse fields and of every field on the new + DeviceCapabilitiesResponse/BluetoothProxyCapabilities/ + VoiceAssistantCapabilities/ZWaveProxyCapabilities sub-messages, so a + well-intentioned reshuffle of api.proto gets caught here instead of on a + device in the field. +* Message wire ids must be unique, and the new capabilities RPC must stay + authenticated-only. + +Group A below asserts on the checked-in generated files (api_pb2.h / +api_pb2.cpp), since "the field is present in the generated C++" is exactly +equivalent to "the device still sends it". Group B parses api.proto as plain +text (no protoc). Group C checks the advertised API minor version. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +import esphome + +API_DIR = Path(esphome.__file__).parent / "components" / "api" + +PROTO_TEXT = (API_DIR / "api.proto").read_text(encoding="utf-8") +HEADER_TEXT = (API_DIR / "api_pb2.h").read_text(encoding="utf-8") +CPP_TEXT = (API_DIR / "api_pb2.cpp").read_text(encoding="utf-8") +API_CONNECTION_TEXT = (API_DIR / "api_connection.cpp").read_text(encoding="utf-8") + +# Fields on DeviceInfoResponse that were superseded by DeviceCapabilitiesResponse +# as of API 1.15 but must still be generated (and therefore still sent) for +# backward compatibility with older clients. +SUPERSEDED_FIELDS: dict[str, int] = { + "bluetooth_proxy_feature_flags": 15, + "voice_assistant_feature_flags": 17, + "bluetooth_mac_address": 18, + "zwave_proxy_feature_flags": 23, + "zwave_home_id": 24, + "serial_proxies": 25, +} + +# Field numbers on the new capability messages. These are a frozen wire +# contract from the moment they ship: an old client decodes a sub-message +# field purely by number, so renumbering any of these -- even without +# touching a name -- silently corrupts what every already-deployed client +# reads. Keyed by message name so the next capability sub-message is a +# data-only addition here. +NEW_CAPABILITY_FIELDS: dict[str, dict[str, int]] = { + "DeviceCapabilitiesResponse": { + "bluetooth_proxy": 1, + "voice_assistant": 2, + "zwave_proxy": 3, + "serial_proxies": 4, + }, + "BluetoothProxyCapabilities": { + "feature_flags": 1, + "mac_address": 2, + }, + "VoiceAssistantCapabilities": { + "feature_flags": 1, + }, + "ZWaveProxyCapabilities": { + "feature_flags": 1, + "home_id": 2, + }, +} + +# Fields that are genuinely dead and are expected to carry `deprecated=true`. +# Used to prove the deprecated-detection logic below actually detects +# deprecation rather than trivially passing. +GENUINELY_DEPRECATED_FIELDS: tuple[str, ...] = ( + "legacy_bluetooth_proxy_version", + "legacy_voice_assistant_version", +) + +DEPRECATED_FIELD_TRAP = ( + "script/api_protobuf/api_protobuf.py skips fields marked `[deprecated = " + "true]` completely, generating no C++ for them at all. Marking this field " + "deprecated would silently stop the device from ever sending it, breaking " + "every existing client that still reads it from DeviceInfoResponse." +) + + +def _extract_braced_region(text: str, anchor_pattern: str) -> str: + """Return the region of `text` starting at the first match of + `anchor_pattern` up to the matching closing brace (inclusive), using + brace-depth counting so nested braces (e.g. a `for (...) { ... }` loop + inside a function body) don't cause a premature stop. + """ + anchor_match = re.search(anchor_pattern, text) + if anchor_match is None: + raise AssertionError(f"could not find a match for {anchor_pattern!r}") + start = anchor_match.start() + open_brace = text.index("{", start) + depth = 0 + for i in range(open_brace, len(text)): + if text[i] == "{": + depth += 1 + elif text[i] == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] + raise AssertionError(f"unbalanced braces while scanning after {anchor_pattern!r}") + + +def _extract_class_body(header_text: str, class_name: str) -> str: + """Return the body of a generated C++ class, scoped so a field name that + also happens to exist on some other class cannot satisfy the assertion. + """ + return _extract_braced_region(header_text, rf"class {re.escape(class_name)}\b") + + +def _extract_function_body(cpp_text: str, qualified_name: str) -> str: + """Return the body of a generated `Class::method(...)` definition.""" + return _extract_braced_region(cpp_text, rf"{re.escape(qualified_name)}\(") + + +def _extract_proto_message(proto_text: str, message_name: str) -> str: + """Return the body of a top-level `message Name { ... }` block from the + .proto source. Proto message bodies here contain no nested `{`/`}` of + their own (options use parens, not braces), so a non-greedy match up to + the first line that is just `}` is sufficient and keeps the parsing + simple. + """ + match = re.search( + rf"^message {re.escape(message_name)}\s*\{{(.*?)^\}}", + proto_text, + re.MULTILINE | re.DOTALL, + ) + if match is None: + raise AssertionError(f"could not find `message {message_name}` in api.proto") + return match.group(1) + + +def _extract_rpc_body(proto_text: str, rpc_name: str) -> str: + """Return the option body of an `rpc name (...) returns (...) { ... }` + declaration from the APIConnection service, robust to it being written + on one line (`{}`) or spread across several with options inside. + """ + match = re.search( + rf"rpc\s+{re.escape(rpc_name)}\s*\([^)]*\)\s*returns\s*\([^)]*\)\s*\{{(.*?)\}}", + proto_text, + re.DOTALL, + ) + if match is None: + raise AssertionError(f"could not find `rpc {rpc_name}` in api.proto") + return match.group(1) + + +def _field_declaration_line(message_body: str, field_name: str) -> str: + """Return the single source line declaring `field_name` inside a proto + message body (all fields here are declared on one line). + """ + for line in message_body.splitlines(): + if re.search(rf"\b{re.escape(field_name)}\s*=\s*\d+", line): + return line + raise AssertionError( + f"could not find a field declaration for {field_name!r} in the given message body" + ) + + +# ==================== Group A: generated files ==================== + + +def test_superseded_device_info_fields_still_declared_in_header() -> None: + """Each superseded field must still be a real member of DeviceInfoResponse + in api_pb2.h -- not merely present somewhere in the file. Several of these + names (e.g. serial_proxies) also exist on DeviceCapabilitiesResponse, so an + unscoped substring search over the whole header would pass even if the + field were removed from DeviceInfoResponse. + """ + class_body = _extract_class_body(HEADER_TEXT, "DeviceInfoResponse") + for field_name in SUPERSEDED_FIELDS: + assert re.search(rf"\b{field_name}\b", class_body), ( + f"{field_name} is missing from the DeviceInfoResponse class body in " + f"api_pb2.h. {DEPRECATED_FIELD_TRAP}" + ) + + +def test_superseded_device_info_fields_still_encoded_and_sized() -> None: + """Each superseded field must still be touched by DeviceInfoResponse's + generated encode() and calculate_size(), i.e. it is still put on the wire. + """ + encode_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::encode") + size_body = _extract_function_body(CPP_TEXT, "DeviceInfoResponse::calculate_size") + for field_name in SUPERSEDED_FIELDS: + assert f"this->{field_name}" in encode_body, ( + f"DeviceInfoResponse::encode() no longer references {field_name}. " + f"{DEPRECATED_FIELD_TRAP}" + ) + assert f"this->{field_name}" in size_body, ( + f"DeviceInfoResponse::calculate_size() no longer references " + f"{field_name}. {DEPRECATED_FIELD_TRAP}" + ) + + +def test_new_capability_classes_present_in_header() -> None: + """The new response message and its capability sub-messages must exist as + generated classes. + """ + for class_name in ( + "DeviceCapabilitiesResponse", + "BluetoothProxyCapabilities", + "VoiceAssistantCapabilities", + "ZWaveProxyCapabilities", + ): + assert re.search(rf"class {re.escape(class_name)}\b", HEADER_TEXT), ( + f"expected a generated class named {class_name} in api_pb2.h" + ) + + +# ==================== Group B: api.proto source text ==================== + + +def test_all_message_ids_are_unique() -> None: + """Every `option (id) = N;` in api.proto must be unique. Two messages + sharing a wire id would make the client and server misinterpret each + other's messages -- nothing else currently checks this. + """ + ids = [int(value) for value in re.findall(r"option \(id\) = (\d+);", PROTO_TEXT)] + assert ids, "did not find any `option (id) = N;` declarations in api.proto" + duplicates = sorted({value for value in ids if ids.count(value) > 1}) + assert not duplicates, ( + f"Duplicate `option (id)` values found in api.proto: {duplicates}. Each " + "message must have a unique wire id." + ) + + +def test_device_capabilities_request_has_id_149() -> None: + body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesRequest") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "DeviceCapabilitiesRequest is missing `option (id)`" + assert int(match.group(1)) == 149, ( + f"DeviceCapabilitiesRequest has id {match.group(1)}, expected 149. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + +def test_device_capabilities_response_has_id_150() -> None: + body = _extract_proto_message(PROTO_TEXT, "DeviceCapabilitiesResponse") + match = re.search(r"option \(id\) = (\d+);", body) + assert match is not None, "DeviceCapabilitiesResponse is missing `option (id)`" + assert int(match.group(1)) == 150, ( + f"DeviceCapabilitiesResponse has id {match.group(1)}, expected 150. " + "Message ids are part of the wire protocol and must not change once " + "assigned." + ) + + +def test_superseded_fields_are_not_marked_deprecated_in_proto() -> None: + """The six superseded fields must not carry `[deprecated = true]` in + api.proto, or the generator drops them and old clients stop receiving + them (see module docstring). The second half of this test proves the + deprecated-detection itself works: two genuinely dead fields + (legacy_bluetooth_proxy_version, legacy_voice_assistant_version) must + still be detected as deprecated, so the first half isn't vacuously true. + """ + body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse") + + for field_name in SUPERSEDED_FIELDS: + line = _field_declaration_line(body, field_name) + assert "deprecated" not in line, ( + f"{field_name} in DeviceInfoResponse is marked deprecated in " + f"api.proto ({line.strip()!r}). {DEPRECATED_FIELD_TRAP}" + ) + + for field_name in GENUINELY_DEPRECATED_FIELDS: + line = _field_declaration_line(body, field_name) + assert "deprecated" in line, ( + f"expected {field_name} to still carry `deprecated=true` in " + f"api.proto ({line.strip()!r}). If this fails, the deprecated " + "detection used above is broken, and the sibling assertion that " + "the superseded fields are NOT deprecated is not testing anything." + ) + + +def test_superseded_fields_keep_their_wire_numbers() -> None: + """Each superseded field must stay on the field number recorded in + SUPERSEDED_FIELDS. Old clients decode DeviceInfoResponse purely by field + number, so renumbering one of these -- even without touching its name -- + would make an old client read a completely different value out of the + wire, with nothing else in CI noticing. + """ + body = _extract_proto_message(PROTO_TEXT, "DeviceInfoResponse") + + for field_name, field_number in SUPERSEDED_FIELDS.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), ( + f"{field_name} in DeviceInfoResponse is no longer declared at " + f"field number {field_number} ({line.strip()!r}). Field numbers " + "are the wire protocol -- renumbering this field silently breaks " + "every existing client that still decodes DeviceInfoResponse by " + "the old numbering." + ) + + +def test_capability_message_fields_keep_their_wire_numbers() -> None: + """Every field on DeviceCapabilitiesResponse and its three capability + sub-messages must stay on the field number recorded in + NEW_CAPABILITY_FIELDS. These messages are brand new as of API 1.15, but + the moment a device ships with them, their field numbers are a frozen + wire contract -- a client decodes a sub-message field purely by number, + so a later "cleanup" that renumbers one of these would silently corrupt + what every already-deployed client reads, with nothing else in CI + noticing. + """ + for message_name, fields in NEW_CAPABILITY_FIELDS.items(): + body = _extract_proto_message(PROTO_TEXT, message_name) + for field_name, field_number in fields.items(): + line = _field_declaration_line(body, field_name) + assert re.search(rf"\b{field_name}\s*=\s*{field_number}\b", line), ( + f"{field_name} on {message_name} is no longer declared at " + f"field number {field_number} ({line.strip()!r}). Field " + "numbers are the wire protocol -- renumbering this field " + "silently breaks every existing client that decodes this " + "message by the old numbering." + ) + + +def test_device_capabilities_rpc_requires_authentication() -> None: + """The `device_capabilities` RPC must not set + `option (needs_authentication) = false;` (or set it to anything at all). + Leaving it unset makes it inherit needs_authentication = true, keeping + capability data behind authentication (and encryption, when configured). + """ + body = _extract_rpc_body(PROTO_TEXT, "device_capabilities") + assert "needs_authentication" not in body, ( + "rpc device_capabilities sets a `needs_authentication` option in " + "api.proto. It must stay unset so it inherits needs_authentication = " + "true; otherwise device capability data could be requested over an " + "unauthenticated connection." + ) + + +# ==================== Group C: advertised API version ==================== + + +def test_api_version_minor_is_at_least_15() -> None: + """Clients gate sending DeviceCapabilitiesRequest on seeing + api_version >= 1.15 in HelloResponse. Regressing api_version_minor below + 15 would make every client believe capabilities are unsupported even + though the RPC exists, so this must never go backwards. Use >= rather + than == so the next unrelated minor-version bump doesn't need to touch + this test. + """ + match = re.search(r"resp\.api_version_minor\s*=\s*(\d+);", API_CONNECTION_TEXT) + assert match is not None, ( + "could not find `resp.api_version_minor = N;` in api_connection.cpp" + ) + minor = int(match.group(1)) + assert minor >= 15, ( + f"api_version_minor is {minor}, but device_capabilities requires " + "clients to see api_version >= 1.15 in HelloResponse before they will " + "ever request it." + ) diff --git a/tests/unit_tests/components/api/test_api_protobuf_generator.py b/tests/unit_tests/components/api/test_api_protobuf_generator.py new file mode 100644 index 0000000000..2a07cbd49c --- /dev/null +++ b/tests/unit_tests/components/api/test_api_protobuf_generator.py @@ -0,0 +1,93 @@ +"""Unit tests for script/api_protobuf/api_protobuf.py generator logic. + +ci-api-proto.yml only checks that the committed output matches what the +generator currently produces, so a semantic regression in the generator would +be committed and matched without anything failing. These tests pin the +semantics directly. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[4] / "script" / "api_protobuf")) + +from api_protobuf import _make_ifdef_line, get_varint64_ifdef # noqa: E402 +from google.protobuf import descriptor_pb2 # noqa: E402 + + +def _file_with_messages( + *messages: tuple[str, int, bool], +) -> descriptor_pb2.FileDescriptorProto: + """Build a FileDescriptorProto with one single-field message per entry. + + Each entry is (message_name, field_type, deprecated). + """ + file_desc = descriptor_pb2.FileDescriptorProto(name="test.proto") + for name, field_type, deprecated in messages: + msg = file_desc.message_type.add(name=name) + field = msg.field.add(name="value", number=1, type=field_type) + field.options.deprecated = deprecated + return file_desc + + +UINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT64 +INT64 = descriptor_pb2.FieldDescriptorProto.TYPE_INT64 +SINT64 = descriptor_pb2.FieldDescriptorProto.TYPE_SINT64 +UINT32 = descriptor_pb2.FieldDescriptorProto.TYPE_UINT32 +FIXED64 = descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64 + + +def test_no_varint64_fields() -> None: + file_desc = _file_with_messages(("A", UINT32, False), ("B", FIXED64, False)) + assert get_varint64_ifdef(file_desc, {}) == (False, None) + + +@pytest.mark.parametrize("field_type", [UINT64, INT64, SINT64]) +def test_single_guard_is_kept(field_type: int) -> None: + file_desc = _file_with_messages(("A", field_type, False)) + assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, "USE_X") + + +def test_two_guards_emit_the_union() -> None: + # The regression this pins: multiple guards used to collapse to + # unconditional, pulling 64-bit varint support into unrelated builds. + file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False)) + guards = {"A": "USE_X", "B": "USE_Y"} + assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y") + + +def test_union_is_sorted_for_deterministic_output() -> None: + file_desc = _file_with_messages(("B", UINT64, False), ("A", INT64, False)) + guards = {"B": "USE_Y", "A": "USE_X"} + assert get_varint64_ifdef(file_desc, guards) == (True, "USE_X || USE_Y") + + +def test_any_unconditional_message_wins() -> None: + file_desc = _file_with_messages(("A", UINT64, False), ("B", INT64, False)) + assert get_varint64_ifdef(file_desc, {"A": "USE_X"}) == (True, None) + + +def test_deprecated_fields_and_messages_are_ignored() -> None: + file_desc = _file_with_messages(("A", UINT64, True), ("B", INT64, False)) + file_desc.message_type[1].options.deprecated = True + assert get_varint64_ifdef(file_desc, {"A": "USE_X", "B": "USE_Y"}) == (False, None) + + +def test_make_ifdef_line_simple_identifier() -> None: + assert _make_ifdef_line("USE_X") == "#ifdef USE_X" + + +def test_make_ifdef_line_union_wraps_each_identifier() -> None: + # The second half of the varint64 union guard: compound conditions must + # become #if defined(A) || defined(B), never #ifdef of the raw string. + assert _make_ifdef_line("USE_X || USE_Y") == "#if defined(USE_X) || defined(USE_Y)" + + +def test_make_ifdef_line_conjunction_and_negation() -> None: + assert ( + _make_ifdef_line("USE_X && !USE_Y") == "#if defined(USE_X) && !defined(USE_Y)" + ) diff --git a/tests/unit_tests/components/bme68x_bsec2/__init__.py b/tests/unit_tests/components/bme68x_bsec2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/bme68x_bsec2/test_init.py b/tests/unit_tests/components/bme68x_bsec2/test_init.py new file mode 100644 index 0000000000..b34231a1aa --- /dev/null +++ b/tests/unit_tests/components/bme68x_bsec2/test_init.py @@ -0,0 +1,64 @@ +"""Tests for the bme68x_bsec2 prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components import bme68x_bsec2 as bsec +from esphome.loader import get_component + + +def test_prefetch_applies_defaults(setup_core: Path) -> None: + [files] = list(bsec.PREFETCH_FILES([{"model": "bme680"}])) + assert len(files) == 1 + assert "bme680_iaq_33v_3s_28d" in files[0].url + assert files[0].path == bsec._compute_local_file_path(files[0].url) + + +def test_prefetch_normalizes_enum_case(setup_core: Path) -> None: + [files] = list( + bsec.PREFETCH_FILES( + [ + { + "model": "BME688", + "sample_rate": "ulp", + "supply_voltage": "1.8v", + "algorithm_output": "REGRESSION", + "operating_age": "4D", + } + ] + ) + ) + assert len(files) == 1 + assert "bme688_reg_18v_300s_4d" in files[0].url + + +def test_prefetch_skips_unknown_values(setup_core: Path) -> None: + entries = [ + {"model": "bme999"}, + {"model": "bme680", "sample_rate": "TURBO"}, + {"model": "bme680", "algorithm_output": "psychic"}, + {}, + ] + assert list(bsec.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_matches_validator_url(setup_core: Path) -> None: + """The hook's URL equals _compute_url over the validated config shape.""" + validated = { + "model": "bme688", + "operating_age": "28d", + "sample_rate": "LP", + "supply_voltage": "3.3V", + "algorithm_output": "classification", + } + [files] = list(bsec.PREFETCH_FILES([dict(validated)])) + assert files[0].url == bsec._compute_url(validated) + + +def test_hook_is_wired_to_the_user_facing_domain() -> None: + """The i2c domain (the only user-facing one) exposes the hook.""" + + component = get_component("bme68x_bsec2_i2c") + assert component is not None + assert component.prefetch_files is bsec.PREFETCH_FILES diff --git a/tests/unit_tests/components/file/__init__.py b/tests/unit_tests/components/file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/file/test_image.py b/tests/unit_tests/components/file/test_image.py new file mode 100644 index 0000000000..a9c1684db3 --- /dev/null +++ b/tests/unit_tests/components/file/test_image.py @@ -0,0 +1,75 @@ +"""Tests for the file image platform's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from esphome.components.file import image as file_image +from esphome.external_files import RemoteFile +from esphome.loader import get_component, get_platform + + +def test_extract_mdi_shorthand(setup_core: Path) -> None: + ref = file_image._extract_file_ref("mdi:home") + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdi"] + "home.svg" + assert ref.path.name == "home.svg" + assert ref.path.parent.name == "mdi" + + +def test_extract_web_url(setup_core: Path) -> None: + url = "https://example.com/img.png" + ref = file_image._extract_file_ref(url) + assert ref == RemoteFile(url, file_image.compute_local_image_path(url)) + + +def test_extract_typed_dicts(setup_core: Path) -> None: + url = "https://example.com/img.png" + assert file_image._extract_file_ref({"source": "web", "url": url}) == RemoteFile( + url, file_image.compute_local_image_path(url) + ) + ref = file_image._extract_file_ref({"source": "mdil", "icon": "home"}) + assert ref is not None + assert ref.url == file_image.MDI_SOURCES["mdil"] + "home.svg" + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert file_image._extract_file_ref("images/local.png") is None + assert file_image._extract_file_ref("mdi:not a valid icon!") is None + assert file_image._extract_file_ref({"source": "local", "path": "x.png"}) is None + assert file_image._extract_file_ref(42) is None + assert file_image._extract_file_ref(None) is None + + +def test_prefetch_files_yields_remote_refs(setup_core: Path) -> None: + entries = [ + {"file": "mdi:home"}, + {"file": "images/local.png"}, + {"file": "https://example.com/img.png"}, + {"no_file_key": True}, + ] + [files] = list(file_image.PREFETCH_FILES(entries)) + assert len(files) == 2 + assert files[0].url.endswith("home.svg") + assert files[1].url == "https://example.com/img.png" + + +def test_extractor_matches_validator_path(setup_core: Path) -> None: + """The path the validator downloads to equals the extractor's path.""" + with patch( + "esphome.components.file.image.external_files.download_content" + ) as mock_download: + file_image.validate_file_shorthand("mdi:home") + + validated_path = mock_download.call_args[0][1] + assert validated_path == file_image._extract_file_ref("mdi:home").path + + +def test_hook_is_wired_to_both_animation_domains() -> None: + """Both animation entry points expose the shared image hook.""" + + assert get_component("animation").prefetch_files is file_image.PREFETCH_FILES + assert ( + get_platform("image", "animation").prefetch_files is file_image.PREFETCH_FILES + ) diff --git a/tests/unit_tests/components/font/__init__.py b/tests/unit_tests/components/font/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/font/test_init.py b/tests/unit_tests/components/font/test_init.py new file mode 100644 index 0000000000..0ea3a0e3a1 --- /dev/null +++ b/tests/unit_tests/components/font/test_init.py @@ -0,0 +1,229 @@ +"""Tests for the font component's prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import external_files +from esphome.components import font +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _gspec(family: str, weight: int = 400, italic: bool = False) -> dict: + return {"family": family, "weight": weight, "italic": italic} + + +def test_extract_gfonts_shorthand_defaults(setup_core: Path) -> None: + spec = font._extract_remote_font("gfonts://Roboto") + assert spec is not None + assert spec[font.CONF_FAMILY] == "Roboto" + assert spec[font.CONF_WEIGHT] == 400 + assert spec[font.CONF_ITALIC] is False + + +def test_extract_gfonts_shorthand_weight_variants(setup_core: Path) -> None: + assert font._extract_remote_font("gfonts://Roboto@bold")[font.CONF_WEIGHT] == 700 + assert font._extract_remote_font("gfonts://Roboto@500")[font.CONF_WEIGHT] == 500 + + +def test_extract_gfonts_normalizes_quoted_italic(setup_core: Path) -> None: + """Boolean spellings the schema accepts are accepted by the extractor.""" + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "true"} + ) + assert spec is not None + assert spec[font.CONF_ITALIC] is True + assert ( + font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "italic": "maybe"} + ) + is None + ) + + +def test_extract_typed_gfonts_dict(setup_core: Path) -> None: + spec = font._extract_remote_font( + {"type": "gfonts", "family": "Roboto", "weight": "medium", "italic": True} + ) + assert spec is not None + assert spec[font.CONF_WEIGHT] == 500 + assert spec[font.CONF_ITALIC] is True + + +def test_extract_web_font(setup_core: Path) -> None: + url = "https://example.com/font.ttf" + for value in (url, {"type": "web", "url": url}): + spec = font._extract_remote_font(value) + assert spec is not None + assert spec[font.CONF_URL] == url + + +def test_extract_skips_local_and_garbage(setup_core: Path) -> None: + assert font._extract_remote_font("fonts/local.ttf") is None + assert font._extract_remote_font({"type": "local", "path": "x.ttf"}) is None + assert ( + font._extract_remote_font({"type": "gfonts", "family": "R", "weight": "no"}) + is None + ) + assert font._extract_remote_font(42) is None + + +def test_prefetch_yields_css_for_stale_gfont(setup_core: Path) -> None: + entries = [ + {"file": "gfonts://Roboto"}, + {"file": "fonts/local.ttf"}, + { + "file": "https://example.com/font.ttf", + "extras": [{"file": "gfonts://Monocraft"}], + }, + ] + batches = list(font.PREFETCH_FILES(entries)) + urls = [file.url for file in batches[0]] + assert font._gfonts_css_url(_gspec("Roboto")) in urls + assert font._gfonts_css_url(_gspec("Monocraft")) in urls + assert "https://example.com/font.ttf" in urls + assert len(batches[0]) == 3 + + +def test_prefetch_skips_recent_ttf(setup_core: Path) -> None: + path = font._gfonts_ttf_path(_gspec("Roboto")) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"cached ttf") + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches == [[], []] + + +def test_stage2_parses_cached_css(setup_core: Path) -> None: + + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/roboto.ttf) format('truetype');" + ) + # Stage two only trusts CSS confirmed fetched this run. + external_files._run_data().fresh_paths.add(css_path) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [ + RemoteFile( + "https://fonts.gstatic.com/roboto.ttf", + font._gfonts_ttf_path(_gspec("Roboto")), + ) + ] + + +def test_stage2_skips_missing_css(setup_core: Path) -> None: + batches = list(font.PREFETCH_FILES([{"file": "gfonts://NoCss"}])) + assert batches[1] == [] + + +def test_prefetch_handles_bare_mapping_extras(setup_core: Path) -> None: + """A bare-mapping extras value (valid raw config) is scanned.""" + entries = [ + { + "file": "fonts/local.ttf", + "extras": {"file": "gfonts://Roboto", "glyphs": "ABC"}, + } + ] + batches = list(font.PREFETCH_FILES(entries)) + assert [file.url for file in batches[0]] == [font._gfonts_css_url(_gspec("Roboto"))] + + +def test_unparseable_gfonts_css_is_evicted(setup_core: Path) -> None: + """A CSS body that fails to parse is removed from the cache.""" + + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + css_path = font._gfonts_css_path(spec) + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"no truetype url here", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="please report this"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"\xff\xfe\x00\x01binary", + ), + patch( + "esphome.components.font.external_files.is_fresh_this_run", + return_value=True, + ), + pytest.raises(cv.Invalid, match="not a text document"), + ): + font.download_gfont(spec) + assert not css_path.exists() + + +def test_unrevalidated_gfonts_css_uses_cached_font(setup_core: Path) -> None: + """A CSS body that could not be revalidated is not parsed for a ttf + URL; the cached font is used instead.""" + spec = { + "family": "Roboto", + "weight": 400, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + ttf_path = font._gfonts_ttf_path(spec) + ttf_path.parent.mkdir(parents=True, exist_ok=True) + ttf_path.write_bytes(b"cached ttf") + cache = MagicMock() + with ( + patch.object(font, "FONT_CACHE", cache), + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + ): + assert font.download_gfont(spec) is spec + cache.__setitem__.assert_called_once_with(spec, ttf_path) + + +def test_unrevalidated_gfonts_css_without_cached_font_errors( + setup_core: Path, +) -> None: + """No verified CSS and no cached font is a clear error.""" + spec = { + "family": "Roboto", + "weight": 500, + "italic": False, + "refresh": font._REFRESH_VALIDATOR("0s"), + } + with ( + patch( + "esphome.components.font.external_files.download_content", + return_value=b"stale css", + ), + pytest.raises(cv.Invalid, match="no cached font"), + ): + font.download_gfont(spec) + + +def test_stage2_skips_css_not_fetched_this_run(setup_core: Path) -> None: + """A leftover CSS from an earlier run is not trusted for stage two.""" + css_path = font._gfonts_css_path(_gspec("Roboto")) + css_path.parent.mkdir(parents=True, exist_ok=True) + css_path.write_text( + "src: url(https://fonts.gstatic.com/rotated.ttf) format('truetype');" + ) + + batches = list(font.PREFETCH_FILES([{"file": "gfonts://Roboto"}])) + assert batches[1] == [] diff --git a/tests/unit_tests/components/gsl3670/__init__.py b/tests/unit_tests/components/gsl3670/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/gsl3670/test_touchscreen.py b/tests/unit_tests/components/gsl3670/test_touchscreen.py new file mode 100644 index 0000000000..a4b96d72da --- /dev/null +++ b/tests/unit_tests/components/gsl3670/test_touchscreen.py @@ -0,0 +1,35 @@ +"""Tests for the gsl3670 touchscreen prefetch extraction.""" + +from __future__ import annotations + +from pathlib import Path + +from esphome.components.gsl3670 import touchscreen as gsl +from esphome.external_files import RemoteFile + + +def test_prefetch_explicit_url(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"platform": "gsl3670", "firmware": {"url": url}}] + assert list(gsl.PREFETCH_FILES(entries)) == [ + [RemoteFile(url, gsl._cache_path(url))] + ] + + +def test_prefetch_model_default_firmware(setup_core: Path) -> None: + entries = [{"platform": "gsl3670", "model": "seeed-reterminal-d1001"}] + [files] = list(gsl.PREFETCH_FILES(entries)) + assert len(files) == 1 + assert ( + files[0].url == gsl.MODELS["SEEED-RETERMINAL-D1001"][gsl.CONF_FIRMWARE]["url"] + ) + assert files[0].path == gsl._cache_path(files[0].url) + + +def test_prefetch_skips_local_file_and_custom(setup_core: Path) -> None: + entries = [ + {"platform": "gsl3670", "firmware": {"file": "fw.bin"}}, + {"platform": "gsl3670", "model": "CUSTOM"}, + {"platform": "gsl3670"}, + ] + assert list(gsl.PREFETCH_FILES(entries)) == [[]] diff --git a/tests/unit_tests/components/micro_wake_word/test_init.py b/tests/unit_tests/components/micro_wake_word/test_init.py index 84371ab906..96fb73b18b 100644 --- a/tests/unit_tests/components/micro_wake_word/test_init.py +++ b/tests/unit_tests/components/micro_wake_word/test_init.py @@ -16,6 +16,7 @@ from esphome.const import ( CONF_TYPE, CONF_URL, ) +from esphome.external_files import RemoteFile @pytest.fixture @@ -114,12 +115,16 @@ def test_download_http_models_batches_manifests_then_models( assert mock_download_content_many.call_count == 2 manifest_items = list(mock_download_content_many.call_args_list[0].args[0]) assert manifest_items == [ - (f"https://example.com/models/{name}.json", paths[name] / "manifest.json") + RemoteFile( + f"https://example.com/models/{name}.json", paths[name] / "manifest.json" + ) for name in names ] model_items = list(mock_download_content_many.call_args_list[1].args[0]) assert model_items == [ - (f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite") + RemoteFile( + f"https://example.com/models/{name}.tflite", paths[name] / f"{name}.tflite" + ) for name in names ] diff --git a/tests/unit_tests/components/shelly_dimmer/__init__.py b/tests/unit_tests/components/shelly_dimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/shelly_dimmer/test_light.py b/tests/unit_tests/components/shelly_dimmer/test_light.py new file mode 100644 index 0000000000..e5440db4c9 --- /dev/null +++ b/tests/unit_tests/components/shelly_dimmer/test_light.py @@ -0,0 +1,154 @@ +"""Tests for the shelly_dimmer firmware download and prefetch extraction.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch + +import pytest + +from esphome import external_files +from esphome.components.shelly_dimmer import light as shd +from esphome.config_validation import Invalid +from esphome.external_files import RemoteFile + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def test_prefetch_known_version(setup_core: Path) -> None: + entries = [{"firmware": {"version": "51.6", "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + url, sha = shd.KNOWN_FIRMWARE["51.6"] + assert stages == [[RemoteFile(url, shd._firmware_cache_path(sha))]] + + +def test_prefetch_normalizes_update_like_the_schema(setup_core: Path) -> None: + """Quoted booleans behave as the schema will normalize them.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + off = [{"firmware": {"version": "51.6", "update": "false"}}] + assert list(shd.PREFETCH_FILES(off)) == [[]] + on = [{"firmware": {"version": "51.6", "update": "true"}}] + assert list(shd.PREFETCH_FILES(on)) == [ + [RemoteFile(url, shd._firmware_cache_path(sha))] + ] + + +def test_prefetch_rejects_malformed_sha256(setup_core: Path) -> None: + """A raw sha256 that is not a hash never becomes a path component.""" + entries = [ + { + "firmware": { + "url": "https://example.com/fw.bin", + "sha256": "/tmp/payload", + "update": True, + } + } + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_skips_content_addressed_blob_on_disk(setup_core: Path) -> None: + """A sha-keyed cache file needs no revalidation; get_firmware hashes it.""" + url, sha = shd.KNOWN_FIRMWARE["51.6"] + shd._firmware_cache_path(sha).write_bytes(b"pinned firmware") + entries = [{"firmware": {"version": "51.6", "update": True}}] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_prefetch_explicit_url_without_sha(setup_core: Path) -> None: + url = "https://example.com/fw.bin" + entries = [{"firmware": {"url": url, "update": True}}] + stages = list(shd.PREFETCH_FILES(entries)) + key = external_files.url_cache_key(url) + # No sha means the bytes cannot be verified, so the prefetch itself + # must carry the validator's strict no-stale policy. + assert stages == [ + [RemoteFile(url, shd._firmware_cache_path(key), allow_stale=False)] + ] + + +def test_prefetch_skips_no_update(setup_core: Path) -> None: + entries = [ + {"firmware": {"version": "51.6"}}, + {"firmware": "51.6"}, + {"firmware": {"version": "0.0", "update": True}}, + {}, + ] + assert list(shd.PREFETCH_FILES(entries)) == [[]] + + +def test_get_firmware_rejects_corrupted_cache(setup_core: Path) -> None: + """A cached blob failing its hash check is discarded and re-downloaded.""" + good = b"good firmware" + expected = _sha(good) + path = shd._firmware_cache_path(expected) + path.write_bytes(b"corrupted blob") + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=good, + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_called_once() + assert result == [int(b) for b in good] + + +def test_get_firmware_trusts_valid_cache(setup_core: Path) -> None: + """A cached blob passing its hash check is used with zero network.""" + good = b"good firmware" + expected = _sha(good) + shd._firmware_cache_path(expected).write_bytes(good) + + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content" + ) as mock_download: + result = shd.get_firmware( + { + "update": True, + "url": "https://example.com/fw.bin", + "sha256": expected, + } + ) + + mock_download.assert_not_called() + assert result == [int(b) for b in good] + + +def test_get_firmware_hash_mismatch_raises_and_uncaches(setup_core: Path) -> None: + """A fresh download failing its hash check raises and is not cached.""" + expected = _sha(b"expected firmware") + path = shd._firmware_cache_path(expected) + + with ( + patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"wrong firmware", + ), + pytest.raises(Invalid, match="Hash mismatch"), + ): + shd.get_firmware( + {"update": True, "url": "https://example.com/fw.bin", "sha256": expected} + ) + + assert not path.exists() + + +def test_get_firmware_without_sha_rejects_stale(setup_core: Path) -> None: + """The unverifiable no-hash branch must not accept a stale copy.""" + with patch( + "esphome.components.shelly_dimmer.light.external_files.download_content", + return_value=b"fw", + ) as mock_download: + shd.get_firmware({"update": True, "url": "https://example.com/fw.bin"}) + + assert mock_download.call_args.kwargs["allow_stale"] is False diff --git a/tests/unit_tests/components/test_rp2.py b/tests/unit_tests/components/test_rp2.py index 023d926dc4..cd92bc24fa 100644 --- a/tests/unit_tests/components/test_rp2.py +++ b/tests/unit_tests/components/test_rp2.py @@ -13,25 +13,24 @@ itself (Python imports, YAML key rename, deprecation warning) is covered by the framework tests under ``tests/unit_tests/``. """ +from pathlib import Path +import re + +from esphome.components import rp2 + def test_board_id_has_wifi_for_known_wifi_board() -> None: """``rpipicow`` is the canonical Pico W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipicow") is True def test_board_id_has_wifi_for_known_non_wifi_board() -> None: """Plain ``rpipico`` has no CYW43 → False.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico") is False def test_board_id_has_wifi_for_rp2350_w_variant() -> None: """``rpipico2w`` is the RP2350 Pico 2 W → True.""" - from esphome.components import rp2 - assert rp2.board_id_has_wifi("rpipico2w") is True @@ -43,8 +42,6 @@ def test_board_id_has_wifi_for_unknown_board_returns_true() -> None: block and any genuinely-unsupported config trips the existing "no CYW43" guard at compile time. """ - from esphome.components import rp2 - assert rp2.board_id_has_wifi("not-a-real-board-id") is True @@ -55,8 +52,6 @@ def test_rp2_declares_rp2040_as_alias() -> None: opts in via ``ALIASES``; without this declaration the rename framework wouldn't route legacy configs. """ - from esphome.components import rp2 - assert "rp2040" in rp2.ALIASES assert rp2.ALIAS_REMOVAL_VERSION == "2027.7.0" @@ -93,3 +88,95 @@ def test_rp2040_submodule_imports_resolve_to_rp2_submodules() -> None: assert rp2040_boards is rp2_boards assert rp2040_generate is rp2_generate + + +def test_lwip_segment_pool_exceeds_per_pcb_queue() -> None: + """The segment pool is global while the send queue is per-PCB. + + lwIP's sanity check only requires ``MEMP_NUM_TCP_SEG >= TCP_SND_QUEUELEN``, + which is the floor for a *single* connection: at equality one busy PCB can + drain the pool for every other PCB. Dropping back to that floor would + rebuild the starvation this sizing exists to prevent, and nothing in the + build would complain. + """ + assert rp2.LWIP_MEMP_NUM_TCP_SEG >= 2 * rp2.LWIP_TCP_SND_QUEUELEN + + +def test_lwip_mem_size_keeps_mem_size_t_narrow() -> None: + """``lwip/mem.h`` widens ``mem_size_t`` to ``u32_t`` on + ``MEM_SIZE > 64000L``, growing the header on every heap block. Raising the + heap past that bound is a real option, but it should be a deliberate one + rather than a side effect of tuning. + """ + assert rp2.LWIP_MEM_SIZE <= 64000 + + +def test_lwip_mem_size_holds_the_concurrent_senders_it_claims() -> None: + """Pin the floor as well as the ceiling. + + The ceiling above is satisfied by arduino-pico's own 16 KB, which is the + value this change exists to move off, so on its own it would let a revert + through. Derive the floor from the sizing comment on the constant: with + TCP_OVERSIZE at TCP_MSS every queued segment takes a full MSS-sized block + (pbuf header + PBUF_TRANSPORT offset + 1460 + heap block header, ~1.5 KB), + a PCB at a full 4xMSS TCP_SND_BUF holds four of them, and api's + max_connections on rp2 is 4. Room for three concurrent senders is the + minimum that makes the change worth making; 16 KB does not reach it. + """ + segments_per_full_send_buf = 4 + bytes_per_mss_block = 1536 + concurrent_senders = 3 + + assert ( + concurrent_senders * segments_per_full_send_buf * bytes_per_mss_block + <= rp2.LWIP_MEM_SIZE + ) + + +def test_lwip_defines_carry_the_sizing_into_the_header() -> None: + """The constants above only matter if they reach the generated header. + + ``build_lwip_defines()`` is what feeds lwipopts.h.jinja, so assert on it + rather than on the constants alone: dropping a key here would silently + fall back to arduino-pico's own value while every other assertion in this + file stayed green. + """ + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + + assert defines["MEM_SIZE"] == str(rp2.LWIP_MEM_SIZE) + assert defines["MEMP_NUM_TCP_SEG"] == str(rp2.LWIP_MEMP_NUM_TCP_SEG) + assert defines["TCP_SND_QUEUELEN"] == str(rp2.LWIP_TCP_SND_QUEUELEN) + # Socket-derived counts pass through untouched. + assert defines["MEMP_NUM_TCP_PCB"] == "8" + assert defines["MEMP_NUM_UDP_PCB"] == "6" + assert defines["MEMP_NUM_TCP_PCB_LISTEN"] == "2" + + +def test_lwipopts_template_renders_every_sizing_value() -> None: + """Render the template the way _generate_lwipopts_h() does and check the + header that actually ships. + + Covers both directions. A ``#define`` block deleted from the template + leaves the value at arduino-pico's own, which for MEM_SIZE is the 16 KB + heap this change exists to move off, and the loop below catches that. A + placeholder with no dict key would otherwise render empty and emit a bare + ``#define FOO``; StrictUndefined turns that into an error instead. + Matching on text also survives a filter or conditional appearing in the + template later, which a placeholder regex would not. + """ + from jinja2 import Environment, StrictUndefined + + defines = rp2.build_lwip_defines(tcp_sockets=8, udp_sockets=6, listening_tcp=2) + template_text = (Path(rp2.__file__).parent / "lwipopts.h.jinja").read_text( + encoding="utf-8" + ) + rendered = ( + Environment(keep_trailing_newline=True, undefined=StrictUndefined) + .from_string(template_text) + .render(**defines) + ) + + for name, value in defines.items(): + assert re.search( + rf"^#define {re.escape(name)} +{re.escape(value)}$", rendered, re.MULTILINE + ), f"{name} did not reach the generated header as {value!r}" diff --git a/tests/unit_tests/components/test_rp2_generate_boards.py b/tests/unit_tests/components/test_rp2_generate_boards.py index c5d2214695..329248488c 100644 --- a/tests/unit_tests/components/test_rp2_generate_boards.py +++ b/tests/unit_tests/components/test_rp2_generate_boards.py @@ -8,7 +8,11 @@ import textwrap import pytest -from esphome.components.rp2.generate_boards import load_boards, parse_variant_pins +from esphome.components.rp2.generate_boards import ( + generate, + load_boards, + parse_variant_pins, +) PICO_PINS_HEADER = textwrap.dedent("""\ #pragma once @@ -151,6 +155,8 @@ def test_load_basic_board(arduino_pico: Path) -> None: assert boards["rpipico"]["name"] == "Raspberry Pi Pico" assert boards["rpipico"]["mcu"] == "rp2040" assert boards["rpipico"]["max_pin"] == 29 + # The die key only applies to the RP2350, which ships as more than one die + assert "die" not in boards["rpipico"] assert "rpipico" in board_pins assert board_pins["rpipico"]["LED"] == 25 @@ -172,6 +178,7 @@ def test_load_rp2350_board(arduino_pico: Path) -> None: assert boards["rpipico2"]["mcu"] == "rp2350" assert boards["rpipico2"]["max_pin"] == 29 + assert boards["rpipico2"]["die"] == "A" def test_rp2350_missing_die_define_raises(arduino_pico: Path) -> None: @@ -200,6 +207,35 @@ def test_rp2350_unrecognized_die_define_raises(arduino_pico: Path) -> None: load_boards(arduino_pico) +def test_rp2350_unknown_die_define_raises(arduino_pico: Path) -> None: + """A third die breaks the "not A means B" reading, so stop rather than guess.""" + _add_board( + arduino_pico, + "future_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0\n#define PICO_RP2350C 1\n" + + PICO_PINS_HEADER, + ) + + with pytest.raises(ValueError, match="found a PICO_RP2350C define"): + load_boards(arduino_pico) + + +def test_rp2350_silicon_revision_define_ignored(arduino_pico: Path) -> None: + """PICO_RP2350_A2_SUPPORTED is a silicon revision, not a die letter.""" + _add_board( + arduino_pico, + "revision_define", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n#define PICO_RP2350_A2_SUPPORTED 1\n" + + PICO_PINS_HEADER, + ) + + _, boards = load_boards(arduino_pico) + + assert boards["revision_define"]["die"] == "A" + + def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None: """Literal forms like (1u) classify the same as bare 1.""" _add_board( @@ -212,6 +248,7 @@ def test_rp2350a_parenthesized_die_define(arduino_pico: Path) -> None: _, boards = load_boards(arduino_pico) assert boards["paren_die"]["max_pin"] == 29 + assert boards["paren_die"]["die"] == "A" def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None: @@ -229,10 +266,15 @@ def test_rp2350b_board_keeps_max_pin_47(arduino_pico: Path) -> None: _, boards = load_boards(arduino_pico) assert boards["weact_rp2350b"]["max_pin"] == 47 + assert boards["weact_rp2350b"]["die"] == "B" def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None: - """Generic boards leave the die a build-time choice; stay permissive.""" + """Generic boards leave the die a build-time choice; stay permissive. + + The permissive range is a fallback, so the die must be recorded as unknown + rather than as the B die. + """ _add_board( arduino_pico, "generic_rp2350", @@ -243,6 +285,43 @@ def test_rp2350_menu_selectable_die_keeps_max_pin_47(arduino_pico: Path) -> None _, boards = load_boards(arduino_pico) assert boards["generic_rp2350"]["max_pin"] == 47 + assert boards["generic_rp2350"]["die"] is None + + +def test_generated_output_records_die(arduino_pico: Path) -> None: + """The rendered boards.py carries the die on every RP2350 entry.""" + _add_board( + arduino_pico, + "rpipico", + pins_header=PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "a_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 1\n" + PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "b_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A 0\n" + PICO_PINS_HEADER, + ) + _add_board( + arduino_pico, + "menu_die", + mcu="rp2350", + pins_header="#define PICO_RP2350A __PICO_RP2350A\n" + PICO_PINS_HEADER, + ) + + namespace: dict = {} + exec(compile(generate(arduino_pico), "boards.py", "exec"), namespace) + + boards = namespace["BOARDS"] + assert boards["a_die"]["die"] == "A" + assert boards["b_die"]["die"] == "B" + assert boards["menu_die"]["die"] is None + assert "die" not in boards["rpipico"] def test_rp2350a_pins_above_29_filtered(arduino_pico: Path) -> None: diff --git a/tests/unit_tests/fixtures/espidf/closing_probe.py b/tests/unit_tests/fixtures/espidf/closing_probe.py new file mode 100644 index 0000000000..a77d5c8f28 --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/closing_probe.py @@ -0,0 +1,11 @@ +"""Leave a partial line behind and then close the stream under the runner. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. Draining +cannot work here; the point is that the failure is reported rather than +raised out of the runner's cleanup, where it would bury the exit code. +""" + +import sys + +sys.stdout.write("partial before close") +sys.stdout.close() diff --git a/tests/unit_tests/fixtures/espidf/crashing_probe.py b/tests/unit_tests/fixtures/espidf/crashing_probe.py new file mode 100644 index 0000000000..bf434cc24e --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/crashing_probe.py @@ -0,0 +1,11 @@ +"""Die part way through a line, the way a build that blows up does. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +message has no trailing newline, so the runner's shim is holding it when +the process exits; nothing else will ever come to release it. +""" + +import sys + +sys.stdout.write("FATAL: ld returned 1 exit status") +sys.exit(2) diff --git a/tests/unit_tests/fixtures/espidf/filtering_probe.py b/tests/unit_tests/fixtures/espidf/filtering_probe.py new file mode 100644 index 0000000000..04c2b2ed8c --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/filtering_probe.py @@ -0,0 +1,15 @@ +"""Write a mix of noisy and useful build lines, without flushing. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +runner's shim owns both the filtering and the flushing, so this script +only writes. +""" + +import sys + +sys.stdout.write("Project build complete.\n") +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("-- Component paths: /a /b /c\n") +sys.stdout.write("[2/9] Building C object\n") +# No terminator, so the shim has to hold this one back. +sys.stdout.write("still going") diff --git a/tests/unit_tests/fixtures/espidf/formfeed_probe.py b/tests/unit_tests/fixtures/espidf/formfeed_probe.py new file mode 100644 index 0000000000..727cda25ce --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/formfeed_probe.py @@ -0,0 +1,12 @@ +"""Write a form feed part way through the output. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. A form +feed is not a line terminator here, so everything written must still come +out, including the complete lines that follow it. +""" + +import sys + +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("page one\x0cpage two\n") +sys.stdout.write("[2/9] Building C object\n") diff --git a/tests/unit_tests/fixtures/espidf/partial_noise_probe.py b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py new file mode 100644 index 0000000000..9c81f8eb7b --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/partial_noise_probe.py @@ -0,0 +1,10 @@ +"""End on an unterminated line that the filter is supposed to drop. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py, to +check that releasing a held-back line still applies the filter. +""" + +import sys + +sys.stdout.write("Compiling main.cpp\n") +sys.stdout.write("Project build complete.") diff --git a/tests/unit_tests/fixtures/espidf/streaming_probe.py b/tests/unit_tests/fixtures/espidf/streaming_probe.py new file mode 100644 index 0000000000..c05741e311 --- /dev/null +++ b/tests/unit_tests/fixtures/espidf/streaming_probe.py @@ -0,0 +1,14 @@ +"""Print one line, then stay alive so the caller can prove it streamed. + +Run through ``esphome/espidf/runner.py`` by test_espidf_runner.py. The +runner wraps stdout in its filtering shim, so this script deliberately +does not flush: the shim has to do it. The long sleep keeps the process +running, so anything the caller reads must have arrived while the build +was still going rather than at exit. +""" + +import sys +import time + +sys.stdout.write("Compiling main.cpp\n") +time.sleep(60) diff --git a/tests/unit_tests/test_config_prefetch.py b/tests/unit_tests/test_config_prefetch.py new file mode 100644 index 0000000000..afb93a09a0 --- /dev/null +++ b/tests/unit_tests/test_config_prefetch.py @@ -0,0 +1,355 @@ +"""Tests for the remote file prefetch validation step.""" + +from __future__ import annotations + +from collections.abc import Iterable +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from esphome import core +from esphome.config import Config, PrefetchRemoteFilesValidationStep +import esphome.config_validation as cv +from esphome.external_files import RemoteFile + + +def _component(prefetch: Any = None, is_platform: bool = False) -> SimpleNamespace: + return SimpleNamespace( + is_platform_component=is_platform, + prefetch_files=prefetch, + ) + + +def _run_step( + domains: dict[str, Any], + components: dict[str, Any], + platforms: dict[tuple[str, str], Any] | None = None, + download_side_effect: Any = None, +) -> tuple[Config, MagicMock]: + result = Config() + for domain, conf in domains.items(): + result[domain] = conf + with ( + patch("esphome.config.get_component", side_effect=components.get), + patch( + "esphome.config.get_platform", + side_effect=lambda d, p: (platforms or {}).get((d, p)), + ), + patch( + "esphome.external_files.download_content_many", + side_effect=download_side_effect, + ) as mock_download, + ): + PrefetchRemoteFilesValidationStep().run(result) + return result, mock_download + + +def _downloaded(mock_download: MagicMock, call: int = 0) -> list[RemoteFile]: + return list(mock_download.call_args_list[call][0][0]) + + +def test_component_hook_receives_normalized_entries() -> None: + """A bare dict conf is passed to the hook as a one-entry list.""" + seen: list[Any] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("https://example.com/a", Path("/cache/a"))] + + _, mock_download = _run_step( + {"my_comp": {"key": "value"}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert seen == [[{"key": "value"}]] + mock_download.assert_called_once() + assert _downloaded(mock_download) == [ + RemoteFile("https://example.com/a", Path("/cache/a")) + ] + + +def test_platform_entries_are_grouped_per_platform() -> None: + """Platform domains route entries to each platform module's hook.""" + seen_a: list[Any] = [] + seen_b: list[Any] = [] + + def hook_a(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_a.extend(entries) + yield [RemoteFile("url-a", Path("/a"))] + + def hook_b(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen_b.extend(entries) + yield [RemoteFile("url-b", Path("/b"))] + + entries = [ + {"platform": "a", "n": 1}, + {"platform": "b", "n": 2}, + {"platform": "a", "n": 3}, + ] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(is_platform=True)}, + platforms={ + ("image", "a"): _component(prefetch=hook_a), + ("image", "b"): _component(prefetch=hook_b), + }, + ) + + assert seen_a == [entries[0], entries[2]] + assert seen_b == [entries[1]] + assert sorted(_downloaded(mock_download), key=lambda f: f.url) == [ + RemoteFile("url-a", Path("/a")), + RemoteFile("url-b", Path("/b")), + ] + + +def test_hook_failure_does_not_fail_validation( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising hook is logged and other hooks still prefetch.""" + + def bad_hook(entries: list[dict]) -> list[RemoteFile]: + raise RuntimeError("garbage config") + + def good_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/g"))] + + _, mock_download = _run_step( + {"bad": {"x": 1}, "good": {"y": 2}}, + { + "bad": _component(prefetch=bad_hook), + "good": _component(prefetch=good_hook), + }, + ) + + assert "Remote file prefetch for bad failed" in caplog.text + assert _downloaded(mock_download) == [RemoteFile("url", Path("/g"))] + + +def test_stages_download_between_resumptions() -> None: + """Each yielded stage is downloaded before the generator resumes.""" + order: list[str] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + order.append("stage1") + yield [RemoteFile("css-url", Path("/css"))] + order.append("stage2") + yield [RemoteFile("ttf-url", Path("/ttf"))] + + def record_download(items: Any, description: str) -> None: + order.append(f"download:{[file.url for file in items]}") + + _, mock_download = _run_step( + {"font": {"f": 1}}, + {"font": _component(prefetch=hook)}, + download_side_effect=record_download, + ) + + assert order == [ + "stage1", + "download:['css-url']", + "stage2", + "download:['ttf-url']", + ] + assert mock_download.call_count == 2 + + +def test_runaway_generator_is_capped(caplog: pytest.LogCaptureFixture) -> None: + """An endless generator stops after the stage backstop.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + n = 0 + while True: + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + n += 1 + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_mid_stage_failure_stops_only_that_hook( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator raising on a later stage does not affect other hooks.""" + + def flaky_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("first", Path("/first"))] + raise RuntimeError("stage two exploded") + + def steady_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("one", Path("/one"))] + yield [RemoteFile("two", Path("/two"))] + + _, mock_download = _run_step( + {"flaky": {"x": 1}, "steady": {"y": 2}}, + { + "flaky": _component(prefetch=flaky_hook), + "steady": _component(prefetch=steady_hook), + }, + ) + + assert "Remote file prefetch for flaky failed" in caplog.text + assert mock_download.call_count == 2 + assert _downloaded(mock_download, 1) == [RemoteFile("two", Path("/two"))] + + +def test_download_failure_is_swallowed() -> None: + """cv.Invalid from the batch download never escapes the step.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=cv.Invalid("download failed"), + ) + + mock_download.assert_called_once() + assert not result.errors + + +def test_domains_without_hooks_do_not_download() -> None: + """Components without PREFETCH_FILES cause no download call.""" + _, mock_download = _run_step( + {"plain": {"x": 1}, ".ignored": {"y": 2}, "unknown": {"z": 3}}, + {"plain": _component()}, + ) + mock_download.assert_not_called() + + +def test_none_and_autoload_confs_are_skipped() -> None: + """None and AutoLoad confs never reach a hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"a": None, "b": core.AutoLoad()}, + {"a": _component(prefetch=hook), "b": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_non_dict_entries_are_ignored() -> None: + """Garbage entries never reach a component hook.""" + hook = MagicMock() + _, mock_download = _run_step( + {"my_comp": ["just-a-string", 42]}, + {"my_comp": _component(prefetch=hook)}, + ) + hook.assert_not_called() + mock_download.assert_not_called() + + +def test_platform_entries_without_platform_key_are_ignored() -> None: + """Entries with a missing or unknown platform never reach a hook.""" + _, mock_download = _run_step( + {"image": [{"n": 1}, "garbage", {"platform": "unknown"}]}, + {"image": _component(is_platform=True)}, + ) + mock_download.assert_not_called() + + +def test_generator_still_alive_at_the_cap_is_warned_and_closed( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator with a stage left at the cap is warned about and closed.""" + closed: list[bool] = [] + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + finally: + closed.append(True) + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + assert closed == [True] + + +def test_plain_iterable_hook_survives_the_cap( + caplog: pytest.LogCaptureFixture, +) -> None: + """A hook returning a plain list of batches cannot crash the backstop.""" + + def hook(entries: list[dict]) -> list[list[RemoteFile]]: + return [[RemoteFile(f"url-{n}", Path(f"/f{n}"))] for n in range(12)] + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_domain_level_hook_on_platform_component() -> None: + """A hook on the platform component's domain module sees all entries.""" + seen: list[Any] = [] + + def domain_hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + seen.append(entries) + yield [RemoteFile("domain-url", Path("/domain"))] + + entries = [{"platform": "a", "n": 1}, {"platform": "b", "n": 2}] + _, mock_download = _run_step( + {"image": entries}, + {"image": _component(prefetch=domain_hook, is_platform=True)}, + ) + + assert seen == [entries] + assert _downloaded(mock_download) == [RemoteFile("domain-url", Path("/domain"))] + + +def test_generator_raising_on_close_is_contained( + caplog: pytest.LogCaptureFixture, +) -> None: + """A generator whose close() raises at the cap is logged, not crashed on.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + try: + for n in range(10): + yield [RemoteFile(f"url-{n}", Path(f"/f{n}"))] + except GeneratorExit: + raise RuntimeError("close exploded") from None + + _, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + ) + + assert mock_download.call_count == 10 + assert "stopped after" in caplog.text + + +def test_unexpected_download_error_is_logged_visibly( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken batch downloader warns instead of silently disabling prefetch.""" + + def hook(entries: list[dict]) -> Iterable[list[RemoteFile]]: + yield [RemoteFile("url", Path("/p"))] + + result, mock_download = _run_step( + {"my_comp": {"x": 1}}, + {"my_comp": _component(prefetch=hook)}, + download_side_effect=TypeError("not a RemoteFile"), + ) + + mock_download.assert_called_once() + assert not result.errors + assert "Remote file prefetch failed" in caplog.text diff --git a/tests/unit_tests/test_espidf_framework.py b/tests/unit_tests/test_espidf_framework.py index 5912facbb3..d8e7738569 100644 --- a/tests/unit_tests/test_espidf_framework.py +++ b/tests/unit_tests/test_espidf_framework.py @@ -178,6 +178,11 @@ def test_clone_idf_with_submodules_without_ref(tmp_path: Path) -> None: assert calls[-1][:5] == ["git", "submodule", "update", "--init", "--recursive"] assert not any(c[1] == "fetch" for c in calls) assert not any(c[1] == "reset" for c in calls) + # The clone must retry transient network failures and clean up a + # partial destination between attempts + clone_kwargs = run_git_command_mock.call_args_list[0].kwargs + assert clone_kwargs["network"] is True + assert clone_kwargs["retry_cleanup"] == framework_path def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: @@ -205,6 +210,13 @@ def test_clone_idf_with_submodules_with_ref(tmp_path: Path) -> None: ] assert calls[2] == ["git", "reset", "--hard", "FETCH_HEAD"] assert calls[3][:5] == ["git", "submodule", "update", "--init", "--recursive"] + # Clone and fetch talk to the network and must carry the retry flag; + # the local reset must not + kwargs = [c.kwargs for c in run_git_command_mock.call_args_list] + assert kwargs[0]["network"] is True + assert kwargs[0]["retry_cleanup"] == framework_path + assert kwargs[1]["network"] is True + assert "network" not in kwargs[2] def test_clone_idf_with_submodules_raises_when_tree_missing( diff --git a/tests/unit_tests/test_espidf_runner.py b/tests/unit_tests/test_espidf_runner.py new file mode 100644 index 0000000000..e4cc6e137e --- /dev/null +++ b/tests/unit_tests/test_espidf_runner.py @@ -0,0 +1,211 @@ +"""Tests for esphome.espidf.runner.""" + +from __future__ import annotations + +import io +import os +from pathlib import Path +import subprocess +import sys +import threading + +import pytest + +from esphome.espidf import runner + +# A flushing runner delivers the first line in well under a second; this is +# only ever waited out when the shim has gone back to buffering, so keep it +# just long enough to cover interpreter startup on a loaded CI machine. +FIRST_LINE_TIMEOUT = 10.0 + + +def _prepare_main( + monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str +) -> tuple[io.BytesIO, io.TextIOWrapper]: + """Point ``runner.main()`` at *probe* with a buffered fake stdout. + + ``main`` rewrites ``sys.path``, ``sys.argv``, both std streams and + ``os.get_terminal_size``; every one of those is monkeypatched so it is + put back afterwards. The fake stdout is block buffered like a pipe, so + the caller can tell whether the shim flushed. The wrapper comes back with + the buffer because dropping it would close the buffer underneath us. + """ + buf = io.BytesIO() + stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) + + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.setattr(sys, "argv", ["runner.py", str(probe), *args]) + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + monkeypatch.setattr(os, "get_terminal_size", os.get_terminal_size) + + return buf, stream + + +def _run_main( + monkeypatch: pytest.MonkeyPatch, probe: Path, *args: str +) -> tuple[io.BytesIO, io.TextIOWrapper]: + """Run ``runner.main()`` against *probe* and expect a clean exit.""" + buf, stream = _prepare_main(monkeypatch, probe, *args) + assert runner.main() == 0 + return buf, stream + + +def test_main_filters_noise_and_flushes_each_write( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Useful lines reach the stream right away; noisy ones are dropped.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "filtering_probe.py" + ) + + # Read before any flush of our own: the shim has to have flushed. + output = buf.getvalue().decode("utf-8") + + assert "Compiling main.cpp\n" in output + assert "[2/9] Building C object\n" in output + # Matched by FILTER_IDF_LINES, so they never leave the runner. + assert "Project build complete." not in output + assert "-- Component paths:" not in output + # Held back until the end because no terminator arrived. + assert output.endswith("still going\n") + + +def test_main_keeps_output_after_a_form_feed( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A form feed is text, not a line break, so nothing after it is lost.""" + buf, _stream = _run_main(monkeypatch, fixture_path / "espidf" / "formfeed_probe.py") + + assert buf.getvalue().decode("utf-8") == ( + "Compiling main.cpp\npage one\x0cpage two\n[2/9] Building C object\n" + ) + + +def test_main_drains_a_partial_line_when_the_build_dies( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """A build that stops mid line must still show that line. + + This is the whole point of draining: the message explaining why the + build failed is exactly the one most likely to arrive without a + trailing newline. + """ + buf, _stream = _prepare_main( + monkeypatch, fixture_path / "espidf" / "crashing_probe.py" + ) + + with pytest.raises(SystemExit) as excinfo: + runner.main() + + assert excinfo.value.code == 2 + assert buf.getvalue().decode("utf-8") == "FATAL: ld returned 1 exit status\n" + + +def test_main_reports_rather_than_raises_when_draining_fails( + monkeypatch: pytest.MonkeyPatch, + fixture_path: Path, + capfd: pytest.CaptureFixture[str], +) -> None: + """A stream that closed under us must not crash the runner's cleanup. + + The drain runs from a ``finally``, so an exception there would replace + whatever exit code the build was carrying back. + """ + _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") + + assert runner.main() == 0 + reported = capfd.readouterr().err + assert "Could not write out remaining output" in reported + # The held line has to come along; the stream it was meant for is gone. + assert "partial before close" in reported + + +def test_main_survives_a_drain_failure_with_nowhere_to_report_it( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """With no real stderr to report to, cleanup still must not raise. + + ``sys.__stderr__`` is None on some interpreters, and ``print(file=None)`` + falls back to ``sys.stdout``, which here is the shim wrapping the stream + that just failed. + """ + monkeypatch.setattr(sys, "__stderr__", None) + _prepare_main(monkeypatch, fixture_path / "espidf" / "closing_probe.py") + + assert runner.main() == 0 + + +def test_main_still_filters_a_drained_partial_line( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """Releasing a held line does not smuggle noise past the filter.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "partial_noise_probe.py" + ) + + assert buf.getvalue().decode("utf-8") == "Compiling main.cpp\n" + + +def test_main_keeps_everything_in_verbose_mode( + monkeypatch: pytest.MonkeyPatch, fixture_path: Path +) -> None: + """``-v`` turns the filter off so the noisy lines survive.""" + buf, _stream = _run_main( + monkeypatch, fixture_path / "espidf" / "filtering_probe.py", "-v" + ) + + output = buf.getvalue().decode("utf-8") + + assert "Project build complete.\n" in output + assert "-- Component paths: /a /b /c\n" in output + # With no filter there is no line buffering, so the partial line goes + # straight through as well. + assert output.endswith("still going") + + +def test_runner_streams_output_before_the_build_finishes( + fixture_path: Path, probe_env: dict[str, str] +) -> None: + """The runner must flush, or a dashboard build looks frozen. + + ``toolchain.py`` spawns the runner as a plain script with no ``-u``, and + hands it a pipe when esphome itself is running under the dashboard. A + pipe is block buffered, so without a flush in the shim's ``write()`` the + output sits in the child until 8 KiB piles up or the build ends. + """ + runner_py = Path(runner.__file__) + probe = fixture_path / "espidf" / "streaming_probe.py" + + with subprocess.Popen( + [sys.executable, str(runner_py), str(probe)], + stdout=subprocess.PIPE, + # Keep stderr: if the runner dies on startup, its traceback is the + # only clue about why no line showed up. + stderr=subprocess.PIPE, + env=probe_env, + text=True, + ) as proc: + assert proc.stdout is not None + assert proc.stderr is not None + first_line: list[str] = [] + reader = threading.Thread( + target=lambda: first_line.append(proc.stdout.readline()), daemon=True + ) + try: + reader.start() + reader.join(FIRST_LINE_TIMEOUT) + still_running = proc.poll() is None + + # The probe sleeps for a minute after writing, so reaching us at + # all means the line was flushed rather than released at exit. + assert first_line == ["Compiling main.cpp\n"], ( + f"runner stderr: {'' if still_running else proc.stderr.read()}" + ) + assert still_running + finally: + proc.kill() + proc.wait() + # Join before leaving the block, so the reader is done rather than + # racing ``Popen`` closing the pipe under it. + reader.join(1.0) diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py index 16cee9564f..4e993ff4f3 100644 --- a/tests/unit_tests/test_external_files.py +++ b/tests/unit_tests/test_external_files.py @@ -3,6 +3,7 @@ import os from pathlib import Path import time +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -26,19 +27,21 @@ def _seed_etag(cache_file: Path, etag: str) -> Path: @pytest.fixture def mock_requests_head() -> MagicMock: - """Patch `external_files.requests.head` so the conditional HEAD-request - validator can be tested without doing real HTTP. + """Patch `requests.head` so the conditional HEAD-request validator can + be tested without doing real HTTP. Patched on the requests module + because external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.head") as m: + with patch("requests.head") as m: yield m @pytest.fixture def mock_requests_get() -> MagicMock: - """Patch `external_files.requests.get` so the download path can be - tested without doing real HTTP. + """Patch `requests.get` so the download path can be tested without + doing real HTTP. Patched on the requests module because + external_files imports it lazily inside the function. """ - with patch("esphome.external_files.requests.get") as m: + with patch("requests.get") as m: yield m @@ -549,6 +552,10 @@ def test_download_content_skip_external_update_uses_cache( assert result == cached_content mock_has_remote_file_changed.assert_not_called() mock_requests_get.assert_not_called() + # Deliberately unchecked is memoized for the run but never "fresh". + assert not external_files.is_fresh_this_run(test_file) + assert external_files.download_content(url, test_file) == cached_content + mock_has_remote_file_changed.assert_not_called() def test_download_content_skip_external_update_downloads_when_missing( @@ -587,10 +594,16 @@ def test_download_content_many_single_item_avoids_pool( mock_download_content: MagicMock, setup_core: Path ) -> None: """A single item should be downloaded inline (no thread pool overhead).""" - item = ("https://example.com/file.txt", setup_core / "f.txt") + item = external_files.RemoteFile( + "https://example.com/file.txt", setup_core / "f.txt" + ) external_files.download_content_many([item]) mock_download_content.assert_called_once_with( - item[0], item[1], external_files.NETWORK_TIMEOUT + item.url, + item.path, + external_files.NETWORK_TIMEOUT, + allow_stale=True, + return_content=False, ) @@ -602,7 +615,12 @@ def test_download_content_many_runs_in_parallel( barrier = threading.Barrier(3) - def slow_download(url: str, path: Path, timeout: int) -> bytes: + def slow_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: # If calls were serial this would deadlock (third caller never arrives # while the first is blocked at the barrier). barrier.wait(timeout=2.0) @@ -610,9 +628,9 @@ def test_download_content_many_runs_in_parallel( mock_download_content.side_effect = slow_download items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] external_files.download_content_many(items, max_workers=4) assert mock_download_content.call_count == 3 @@ -625,15 +643,20 @@ def test_download_content_many_propagates_single_error( it in a `MultipleInvalid` that the caller would have to unpack. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("bad"): raise Invalid(f"could not download {url}") return b"" mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad", setup_core / "bad"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad", setup_core / "bad"), ] with pytest.raises(Invalid, match="could not download") as exc_info: external_files.download_content_many(items) @@ -648,16 +671,21 @@ def test_download_content_many_aggregates_multiple_errors( them one network round-trip at a time. """ - def fake_download(url: str, path: Path, timeout: int) -> bytes: + def fake_download( + url: str, + path: Path, + *args: Any, + **kwargs: Any, + ) -> bytes: if url.endswith("ok"): return b"" raise Invalid(f"could not download {url}") mock_download_content.side_effect = fake_download items = [ - ("https://example.com/ok", setup_core / "ok"), - ("https://example.com/bad1", setup_core / "bad1"), - ("https://example.com/bad2", setup_core / "bad2"), + external_files.RemoteFile("https://example.com/ok", setup_core / "ok"), + external_files.RemoteFile("https://example.com/bad1", setup_core / "bad1"), + external_files.RemoteFile("https://example.com/bad2", setup_core / "bad2"), ] with pytest.raises(MultipleInvalid) as exc_info: external_files.download_content_many(items) @@ -678,9 +706,9 @@ def test_download_content_many_dedupes_by_path( """ path = setup_core / "shared" items = [ - ("https://example.com/a", path), - ("https://example.com/b", path), - ("https://example.com/a", path), + external_files.RemoteFile("https://example.com/a", path), + external_files.RemoteFile("https://example.com/b", path), + external_files.RemoteFile("https://example.com/a", path), ] external_files.download_content_many(items) assert mock_download_content.call_count == 1 @@ -695,8 +723,8 @@ def test_download_content_many_clamps_invalid_max_workers( be clamped up to at least 1 worker. """ items = [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/b", setup_core / "b"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/b", setup_core / "b"), ] external_files.download_content_many(items, max_workers=0) assert mock_download_content.call_count == 2 @@ -724,8 +752,8 @@ def test_download_web_files_in_config_filters_and_dispatches( assert result is config mock_download_content_many.assert_called_once() assert list(mock_download_content_many.call_args[0][0]) == [ - ("https://example.com/a", setup_core / "a"), - ("https://example.com/c", setup_core / "c"), + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile("https://example.com/c", setup_core / "c"), ] @@ -799,3 +827,264 @@ def test_download_content_atomic_write_no_partial_on_failure( # into the cache directory either way. leftover_tmps = list(setup_core.glob("tmp*")) assert leftover_tmps == [] + + +def test_download_content_memoizes_fresh_path( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A path downloaded once this run skips all network on later calls.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"fresh content" + assert external_files.download_content(url, test_file) == b"fresh content" + + mock_has_remote_file_changed.assert_called_once() + mock_requests_get.assert_called_once() + + +def test_download_content_memo_revalidates_deleted_file( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A memoized path whose file vanished is downloaded again.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_response = MagicMock() + mock_response.content = b"fresh content" + mock_response.headers = {} + mock_requests_get.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.download_content(url, test_file) + test_file.unlink() + external_files.download_content(url, test_file) + + assert mock_requests_get.call_count == 2 + + +def test_download_content_failure_fails_fast_on_retry( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A failed download is remembered; a retry raises without network.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + with pytest.raises(Invalid, match="boom"): + external_files.download_content(url, test_file) + + mock_requests_get.assert_called_once() + + +def test_download_content_failed_path_revalidates_when_file_appears( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A recorded failure is dropped once the file exists on disk.""" + test_file = setup_core / "memo.txt" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid): + external_files.download_content(url, test_file) + + # Another writer produced the file; the cached failure no longer applies + # and the network error now falls back to the on-disk copy. + test_file.write_bytes(b"appeared") + assert external_files.download_content(url, test_file) == b"appeared" + + +def test_download_content_network_error_fallback_memoizes( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """Falling back to a cached file memoizes, so a flaky host is hit once.""" + test_file = setup_core / "memo.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_called_once() + + +def test_download_content_not_changed_uses_cache( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A 304 not-changed check serves the cached file without a GET.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + mock_has_remote_file_changed.return_value = False + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_get.assert_not_called() + + +def test_head_failure_fallback_is_stale_not_fresh( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A HEAD network failure serves the copy once and memoizes it as stale.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + assert external_files.download_content(url, test_file) == b"cached content" + + mock_requests_head.assert_called_once() + mock_requests_get.assert_not_called() + + +def test_allow_stale_false_rejects_unverified_copy( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False raises instead of building from an unverified copy.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + + # A strict caller gets its own attempt at the network rather than + # inheriting the stale memo's verdict. + with pytest.raises(Invalid, match="Could not download"): + external_files.download_content(url, test_file, allow_stale=False) + assert mock_requests_get.call_count == 2 + + # A caller that tolerates stale copies still gets the cached bytes. + assert external_files.download_content(url, test_file) == b"cached content" + + +def test_allow_stale_false_rejects_head_failure_fallback( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """allow_stale=False also rejects a copy the HEAD could not confirm.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + mock_requests_head.side_effect = requests.exceptions.RequestException("boom") + + url = "https://example.com/file.txt" + with pytest.raises(Invalid, match="cannot be verified"): + external_files.download_content(url, test_file, allow_stale=False) + mock_requests_get.assert_not_called() + + +def test_download_content_many_forwards_per_file_allow_stale( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """Each RemoteFile's own allow_stale reaches download_content.""" + files = [ + external_files.RemoteFile("https://example.com/a", setup_core / "a"), + external_files.RemoteFile( + "https://example.com/b", setup_core / "b", allow_stale=False + ), + ] + external_files.download_content_many(files) + forwarded = { + call.args[1]: call.kwargs["allow_stale"] + for call in mock_download_content.call_args_list + } + assert forwarded == {setup_core / "a": True, setup_core / "b": False} + + +def test_download_content_many_dedupe_keeps_strictest( + mock_download_content: MagicMock, setup_core: Path +) -> None: + """A strict duplicate wins over a permissive one for the same path.""" + path = setup_core / "fw.bin" + files = [ + external_files.RemoteFile("https://example.com/fw", path, allow_stale=False), + external_files.RemoteFile("https://example.com/fw", path), + ] + external_files.download_content_many(files) + mock_download_content.assert_called_once() + assert mock_download_content.call_args.kwargs["allow_stale"] is False + + +def test_successful_head_revalidation_clears_stale( + mock_requests_head: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A confirmed 304 supersedes an earlier failed revalidation.""" + test_file = setup_core / "cached.txt" + test_file.write_bytes(b"cached content") + + ok_304 = MagicMock(status_code=304, headers={}) + mock_requests_head.side_effect = [ + requests.exceptions.RequestException("blip"), + ok_304, + ] + + url = "https://example.com/file.txt" + assert external_files.download_content(url, test_file) == b"cached content" + # The stale memo short-circuits tolerant callers; a strict caller + # triggers a fresh HEAD, which now succeeds and clears the marker. + assert ( + external_files.download_content(url, test_file, allow_stale=False) + == b"cached content" + ) + # Verified now: served from the fresh memo with no more network. + assert external_files.download_content(url, test_file) == b"cached content" + assert mock_requests_head.call_count == 2 + mock_requests_get.assert_not_called() + + +def test_failed_path_replay_names_the_other_url( + mock_has_remote_file_changed: MagicMock, + mock_requests_get: MagicMock, + setup_core: Path, +) -> None: + """A shared cache path replays the failure naming the original URL.""" + test_file = setup_core / "shared.bin" + + mock_has_remote_file_changed.return_value = True + mock_requests_get.side_effect = requests.exceptions.RequestException("boom") + + with pytest.raises(Invalid, match="first-url"): + external_files.download_content("https://example.com/first-url", test_file) + with pytest.raises(Invalid, match="earlier download of.*first-url"): + external_files.download_content("https://example.com/second-url", test_file) + mock_requests_get.assert_called_once() diff --git a/tests/unit_tests/test_git.py b/tests/unit_tests/test_git.py index ec1becf3e8..e296d48a46 100644 --- a/tests/unit_tests/test_git.py +++ b/tests/unit_tests/test_git.py @@ -247,6 +247,347 @@ def test_run_git_command_strips_fatal_prefix( assert "repository not found" in str(exc_info.value) +def _git_failure(stderr: bytes, returncode: int = 128) -> Mock: + """Build a failed subprocess.run result with the given stderr.""" + return Mock(returncode=returncode, stdout=b"", stderr=stderr) + + +_GIT_OK = Mock(returncode=0, stdout=b"ok", stderr=b"") + + +def test_run_git_command_network_retries_transient_then_succeeds( + mock_subprocess_run: Mock, +) -> None: + """A transient network failure is retried and the retry's result returned.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep") as mock_sleep: + result = git.run_git_command( + ["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"], + network=True, + ) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + mock_sleep.assert_called_once_with(2) + + +def test_run_git_command_network_gives_up_after_max_attempts( + mock_subprocess_run: Mock, +) -> None: + """A persistent transient-looking failure raises after the final attempt.""" + mock_subprocess_run.side_effect = lambda *args, **kwargs: _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"server certificate verification failed. CAfile: none CRLfile: none\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="certificate verification failed"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 3 + assert [c.args[0] for c in mock_sleep.call_args_list] == [2, 4] + + +@pytest.mark.parametrize( + ("stderr", "transient"), + [ + # Transient: DNS, TLS, dropped connections, server-side errors + ("unable to access 'https://x/': The requested URL returned error: 502", True), + ("unable to access 'https://x/': Could not resolve host: github.com", True), + ("unable to access 'https://x/': Failed to connect: Timed out", True), + ("unable to access 'https://x/': Recv failure: Connection reset", True), + ("unable to access 'https://x/': Connection refused", True), + ("fatal: early EOF\nfatal: fetch-pack: invalid index-pack output", True), + ( + ( + "error: RPC failed; HTTP 500 curl 22 The requested URL returned " + "error: 500\nfatal: expected flush after ref listing" + ), + True, + ), + ( + ( + "unable to access 'https://x/': server certificate verification " + "failed. CAfile: none CRLfile: none" + ), + True, + ), + ( + ( + "error: RPC failed; curl 56 GnuTLS recv error (-110)\n" + "fatal: the remote end hung up unexpectedly" + ), + True, + ), + ( + ( + "fetch-pack: unexpected disconnect while reading sideband packet\n" + "fatal: early EOF" + ), + True, + ), + # 429 rate limiting is the one retryable 4xx, in both curl forms + ("unable to access 'https://x/': The requested URL returned error: 429", True), + ("error: RPC failed; HTTP 429 curl 22\nfatal: expected flush", True), + ( + ( + "unable to access 'https://x/': OpenSSL SSL_read: error:0A000126:" + "SSL routines::unexpected eof while reading, errno 0" + ), + True, + ), + # Permanent: missing repo, auth, bad ref, other 4xx + ("fatal: repository 'https://github.com/test/repo/' not found", False), + ( + ( + "fatal: could not read Username for 'https://github.com': " + "terminal prompts disabled" + ), + False, + ), + ("fatal: couldn't find remote ref refs/heads/nope", False), + ( + ( + "unable to access 'https://github.com/org/private.git/': " + "The requested URL returned error: 403" + ), + False, + ), + ("fatal: Authentication failed for 'https://github.com/test/repo/'", False), + # Smart-HTTP (HTTP/2) 4xx form has no "returned error:" text and + # mixes in transient-looking wording; still permanent + ( + ( + "error: RPC failed; HTTP 403 curl 92 HTTP/2 stream 5 was not " + "closed cleanly: CANCEL (err 8)\nfatal: expected flush after " + "ref listing" + ), + False, + ), + ( + ( + "error: RPC failed; HTTP 404 curl 22\n" + "fatal: the remote end hung up unexpectedly" + ), + False, + ), + ( + ( + "fatal: unable to access 'https://x/': gnutls_handshake() " + "failed: The TLS connection was non-properly terminated." + ), + True, + ), + # Transient-looking tokens in the URL must not classify as transient + ("fatal: repository 'https://github.com/x/esp32_ssl_reader/' not found", False), + ("fatal: repository 'https://gitlab.com/gnutls/gnutls.git/' not found", False), + ("", False), + ], +) +def test_is_transient_git_error(stderr: str, transient: bool) -> None: + """Real-world stderr outputs classify correctly as transient or permanent.""" + assert git._is_transient_git_error(stderr) is transient + + +def test_run_git_command_network_no_retry_on_permanent_error( + mock_subprocess_run: Mock, +) -> None: + """Permanent failures (missing repo, auth, bad ref) fail on the first try.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: repository 'https://github.com/test/repo/' not found\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_network_no_retry_when_git_missing( + mock_subprocess_run: Mock, +) -> None: + """A missing git binary is not transient and must not be retried.""" + from esphome.git import GitNotInstalledError + + mock_subprocess_run.side_effect = FileNotFoundError("git not found") + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitNotInstalledError), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_no_retry_by_default(mock_subprocess_run: Mock) -> None: + """Without network=True even a transient-looking failure is not retried.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError), + ): + git.run_git_command(["git", "status"]) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_network_retry_matches_full_stderr_not_last_line( + mock_subprocess_run: Mock, +) -> None: + """The transient marker often sits above the final fatal line; the retry + decision must look at the full stderr, not just the extracted message.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"error: RPC failed; curl 56 GnuTLS recv error (-54)\n" + b"fatal: fetch-pack: invalid index-pack output\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep"): + result = git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + + +def test_run_git_command_retry_warning_redacts_credentials( + mock_subprocess_run: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """The retry warning embeds the git error, which embeds the URL; embedded + credentials must be redacted since warnings end up in pasted logs.""" + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://user:hunter2@github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with ( + patch("esphome.git.time.sleep"), + caplog.at_level(logging.WARNING, logger="esphome.git"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + + +def test_run_git_command_clone_retry_removes_leftover_destination( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """A partial clone destination left by a failed attempt is removed before + the retry, so the retry cannot fail on 'destination path already exists'.""" + dest = tmp_path / "leftover_clone" + dest.mkdir() + (dest / "partial").write_text("x") + + mock_subprocess_run.side_effect = [ + _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ), + _GIT_OK, + ] + + with patch("esphome.git.time.sleep"): + result = git.run_git_command( + [ + "git", + "clone", + "--depth=1", + "--", + "https://github.com/test/repo", + str(dest), + ], + network=True, + retry_cleanup=dest, + ) + + assert result == "ok" + assert mock_subprocess_run.call_count == 2 + assert not dest.exists() + + +def test_run_git_command_cleanup_failure_reraises_original_error( + tmp_path: Path, mock_subprocess_run: Mock +) -> None: + """When the pre-retry cleanup fails, the git error stays the reported + cause instead of being replaced by the cleanup OSError.""" + dest = tmp_path / "leftover_clone" + dest.mkdir() + + mock_subprocess_run.return_value = _git_failure( + b"fatal: unable to access 'https://github.com/test/repo/': " + b"Could not resolve host: github.com\n" + ) + + with ( + patch("esphome.git.rmtree", side_effect=OSError("locked")), + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="Could not resolve host"), + ): + git.run_git_command( + ["git", "clone", "--depth=1", "--", "https://github.com/test/repo", "x"], + network=True, + retry_cleanup=dest, + ) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_no_retry_on_empty_stderr_failure( + mock_subprocess_run: Mock, +) -> None: + """A failure with no stderr (e.g. git killed by a signal) is not retried.""" + mock_subprocess_run.return_value = _git_failure(b"", returncode=1) + + with ( + patch("esphome.git.time.sleep") as mock_sleep, + pytest.raises(GitCommandError, match="git exited with code 1"), + ): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + mock_sleep.assert_not_called() + + +def test_run_git_command_non_utf8_stderr_does_not_crash( + mock_subprocess_run: Mock, +) -> None: + """Locale-encoded (non-UTF-8) stderr must not raise UnicodeDecodeError.""" + mock_subprocess_run.return_value = _git_failure( + b"fatal: repositorio no encontrado \xe9\xff\n" + ) + + with pytest.raises(GitCommandError, match="repositorio no encontrado"): + git.run_git_command(["git", "fetch", "--", "origin"], network=True) + + assert mock_subprocess_run.call_count == 1 + + def test_run_git_command_without_git_dir(mock_subprocess_run: Mock) -> None: """Test that run_git_command works without git_dir (clone case).""" # Configure mock to return success @@ -677,10 +1018,10 @@ def test_clone_or_update_with_none_refresh_always_updates( "ambiguous argument 'HEAD': unknown revision or path not in the working tree.", ), ("stash", "fatal: unable to write new index file"), - ( - "fetch", - "fatal: unable to access 'https://github.com/test/repo/': Could not resolve host", - ), + # The fetch failure must be non-transient: a transient one (e.g. + # "Could not resolve host") now keeps the existing clone instead of + # triggering recovery. + ("fetch", "fatal: couldn't find remote ref main"), ("reset", "fatal: Could not reset index file to revision 'FETCH_HEAD'"), ], ) @@ -747,6 +1088,236 @@ def test_clone_or_update_recovers_from_git_failures( assert result_dir == repo_dir +@pytest.mark.parametrize("fetch_head_preexists", [True, False]) +def test_clone_or_update_transient_fetch_keeps_existing_clone( + tmp_path: Path, + mock_run_git_command: Mock, + caplog: pytest.LogCaptureFixture, + fetch_head_preexists: bool, +) -> None: + """A transient network failure while refreshing a verified clone falls back + to the existing clone instead of destroying it with a recovery re-clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + _setup_old_repo(repo_dir) + if not fetch_head_preexists: + # First-ever refresh: age comes from HEAD, FETCH_HEAD absent + (repo_dir / ".git" / "FETCH_HEAD").unlink() + head = repo_dir / ".git" / "HEAD" + head.write_text("test") + old_time = time.time() - 2 * 86400 + os.utime(head, (old_time, old_time)) + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "fetch": + # A failed fetch still freshens FETCH_HEAD, like real git + (repo_dir / ".git" / "FETCH_HEAD").touch() + stderr = ( + "fatal: unable to access " + "'https://user:hunter2@github.com/test/repo/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + with caplog.at_level(logging.WARNING, logger="esphome.git"): + result_dir, revert = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + # The existing clone is returned, not removed or re-cloned + assert result_dir == repo_dir + assert repo_dir.is_dir() + assert revert is None + assert not any( + _get_git_command_type(c[0][0]) == "clone" + for c in mock_run_git_command.call_args_list + ) + # The completion marker must be restored, or the next run treats the + # entry as an incomplete clone and removes it + assert _marker_path(repo_dir).is_file() + # The warning must say what the build will actually use and how stale it is + assert "using the existing clone at abc123" in caplog.text + assert "ago" in caplog.text + # Credentials embedded in the URL must not reach the warning log + assert "hunter2" not in caplog.text + assert "://***@github.com/test/repo" in caplog.text + # The FETCH_HEAD the failed fetch freshened must not survive, or the + # refresh window would suppress retrying the update on subsequent runs + fetch_head = repo_dir / ".git" / "FETCH_HEAD" + if fetch_head_preexists: + assert time.time() - fetch_head.stat().st_mtime > refresh.total_seconds + else: + assert not fetch_head.exists() + + +def test_clone_or_update_timestamp_restore_failure_routes_to_recovery( + tmp_path: Path, mock_run_git_command: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """If the FETCH_HEAD restore fails, the fallback cannot stay honest, so + the git error must route through recovery instead of a raw OSError.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + _setup_old_repo(repo_dir) + + call_counts: dict[str, int] = {} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "fetch" and call_counts[cmd_type] == 1: + stderr = ( + "fatal: unable to access 'https://github.com/test/repo/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + with ( + patch("esphome.git.os.utime", side_effect=OSError("read-only")), + caplog.at_level(logging.WARNING, logger="esphome.git"), + ): + result_dir, _ = git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + ) + + assert result_dir == repo_dir + assert "Could not restore the refresh timestamp" in caplog.text + # Recovery re-cloned rather than surfacing the OSError + assert call_counts.get("clone", 0) == 1 + + +@pytest.mark.parametrize( + "refresh", [None, TimePeriodSeconds(days=1)], ids=["clone", "refresh"] +) +def test_clone_or_update_network_commands_carry_retry_flag( + tmp_path: Path, mock_run_git_command: Mock, refresh: TimePeriodSeconds | None +) -> None: + """clone/fetch/submodule opt into transient-failure retry; local commands + (rev-parse, stash, reset) must not, so a refactor cannot silently drop or + widen the retry wiring.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + ref = "main" + domain = "test" + repo_dir = _compute_repo_dir(url, ref, domain) + + if refresh is None: + mock_run_git_command.side_effect = _make_clone_side_effect( + repo_dir, gitmodules=True + ) + else: + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + mock_run_git_command.return_value = "abc123" + + git.clone_or_update( + url=url, + ref=ref, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + seen: set[str] = set() + for call in mock_run_git_command.call_args_list: + cmd_type = _get_git_command_type(call.args[0]) + seen.add(cmd_type) + if cmd_type in ("clone", "fetch", "submodule"): + assert call.kwargs.get("network") is True, cmd_type + else: + assert "network" not in call.kwargs, cmd_type + if cmd_type == "clone": + assert call.kwargs.get("retry_cleanup") == repo_dir + + expected = {"fetch", "reset", "submodule"} + expected |= {"clone"} if refresh is None else {"rev-parse", "stash"} + assert expected <= seen + + +def test_clone_or_update_transient_submodule_failure_still_recovers( + tmp_path: Path, mock_run_git_command: Mock +) -> None: + """A transient failure after the reset (submodules) leaves a half-updated + tree, so it must route through recovery instead of keeping the clone.""" + CORE.config_path = tmp_path / "test.yaml" + + url = "https://github.com/test/repo" + domain = "test" + repo_dir = _compute_repo_dir(url, None, domain) + _setup_old_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + + call_counts: dict[str, int] = {} + + def git_command_side_effect( + cmd: list[str], cwd: str | None = None, **kwargs: Any + ) -> str: + cmd_type = _get_git_command_type(cmd) + if cmd_type: + call_counts[cmd_type] = call_counts.get(cmd_type, 0) + 1 + if cmd_type == "rev-parse": + return "abc123" + if cmd_type == "submodule" and call_counts[cmd_type] == 1: + stderr = ( + "fatal: unable to access 'https://github.com/test/sub/': " + "Could not resolve host: github.com" + ) + raise GitCommandError(stderr, stderr=stderr) + if cmd_type == "clone": + _simulate_cloned_repo(repo_dir) + (repo_dir / ".gitmodules").write_text("test") + return "" + + mock_run_git_command.side_effect = git_command_side_effect + + refresh = TimePeriodSeconds(days=1) + result_dir, _ = git.clone_or_update( + url=url, + ref=None, + refresh=refresh, + domain=domain, + init_submodules=True, + ) + + assert result_dir == repo_dir + # The half-updated tree must be recovered via re-clone, not kept + assert call_counts.get("clone", 0) == 1 + + def test_clone_or_update_fails_when_recovery_also_fails( tmp_path: Path, mock_run_git_command: Mock ) -> None: diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 211fbf5112..6e00e5b80f 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -14,7 +14,7 @@ import pytest from esphome import helpers from esphome.address_cache import AddressCache from esphome.core import CORE, EsphomeError -from esphome.helpers import ProgressBar +from esphome.helpers import ProgressBar, format_ip_url @pytest.mark.parametrize( @@ -135,6 +135,22 @@ def test_is_ip_address__invalid(host): assert actual is False +@pytest.mark.parametrize( + ("family", "sockaddr", "expected"), + ( + (socket.AF_INET, ("192.168.1.5", 80), "http://192.168.1.5:80/events"), + (socket.AF_INET6, ("2001:db8::1", 80, 0, 0), "http://[2001:db8::1]:80/events"), + ( + socket.AF_INET6, + ("fe80::1", 8080, 0, 7), + "http://[fe80::1%257]:8080/events", + ), + ), +) +def test_format_ip_url(family, sockaddr, expected): + assert format_ip_url(family, sockaddr, sockaddr[1], "/events") == expected + + @settings(deadline=None) @given(value=ip_addresses(v=4).map(str)) def test_is_ip_address__valid(value): diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 6c13cd5f12..23bfdbcd69 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -18,6 +18,7 @@ import pytest from pytest import CaptureFixture from zeroconf import ServiceStateChange +from esphome import __main__ as main from esphome.__main__ import ( Purpose, _get_configured_xtal_freq, @@ -51,6 +52,7 @@ from esphome.__main__ import ( has_non_ip_address, has_ota, has_resolvable_address, + has_web_server_logging, has_web_server_ota, mqtt_get_ip, parse_args, @@ -79,6 +81,7 @@ from esphome.const import ( CONF_DISABLED, CONF_ESPHOME, CONF_LEVEL, + CONF_LOG, CONF_LOG_TOPIC, CONF_LOGGER, CONF_MDNS, @@ -93,6 +96,7 @@ from esphome.const import ( CONF_TOPIC, CONF_USE_ADDRESS, CONF_USERNAME, + CONF_VERSION, CONF_WEB_SERVER, CONF_WIFI, KEY_CORE, @@ -815,6 +819,30 @@ def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> Non assert result == ["192.168.1.100"] +def test_choose_upload_log_host_logging_web_server_only_ip() -> None: + """A web_server-only device with a static IP resolves to that IP for logs.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_logging_web_server_only_mdns() -> None: + """A web_server-only device with a .local name resolves to that hostname.""" + setup_core(config={CONF_WEB_SERVER: {}}, address="test.local") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["test.local"] + + def test_choose_upload_log_host_logging_without_api_reports_missing_api() -> None: """A resolvable device with only ota: fails logs with a missing-api message.""" setup_core( @@ -854,6 +882,17 @@ def test_unresolved_default_error_unresolvable_keeps_dashboard_hint() -> None: assert "set 'use_address'" in msg +def test_unresolved_default_error_logging_suggests_web_server() -> None: + """The missing-api log message lists web_server among the remediations.""" + setup_core( + config={CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}]}, address="192.168.1.100" + ) + + msg = _unresolved_default_error(Purpose.LOGGING, ["OTA"]) + assert "no 'api:' component is configured" in msg + assert "'web_server:'" in msg + + def test_unresolved_default_error_upload_with_ota_is_generic() -> None: """With ota: present the upload error stays generic, not transport-specific.""" setup_core( @@ -2533,6 +2572,30 @@ def test_has_web_server_ota_returns_false_without_config() -> None: assert has_ota() is True +def test_has_web_server_logging_default() -> None: + """has_web_server_logging is True for a default web_server (v2, log on).""" + setup_core(config={CONF_WEB_SERVER: {}}) + assert has_web_server_logging() is True + + +def test_has_web_server_logging_without_config() -> None: + """has_web_server_logging is False when web_server is not configured.""" + setup_core(config={CONF_API: {}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_v1_has_no_events_stream() -> None: + """has_web_server_logging is False for v1, which has no /events endpoint.""" + setup_core(config={CONF_WEB_SERVER: {CONF_VERSION: 1}}) + assert has_web_server_logging() is False + + +def test_has_web_server_logging_respects_log_disabled() -> None: + """has_web_server_logging is False when the web_server log option is off.""" + setup_core(config={CONF_WEB_SERVER: {CONF_LOG: False}}) + assert has_web_server_logging() is False + + def test_upload_program_web_server_only_auto_dispatches( mock_run_web_server_ota: Mock, mock_run_ota: Mock, @@ -3101,6 +3164,77 @@ def test_show_logs_network_with_mqtt_only( ) +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server( + mock_run_logs: Mock, +) -> None: + """A web_server-only device streams logs over the HTTP SSE endpoint.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 80, None, None) + + +@patch("esphome.web_server_logs.run_logs") +def test_show_logs_web_server_with_auth_and_port( + mock_run_logs: Mock, +) -> None: + """web_server port and basic-auth credentials are forwarded to the streamer.""" + setup_core( + config={ + "logger": {}, + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + }, + }, + platform=PLATFORM_ESP32, + ) + mock_run_logs.return_value = 0 + + result = show_logs(CORE.config, MockArgs(), ["192.168.1.100"]) + + assert result == 0 + mock_run_logs.assert_called_once_with(["192.168.1.100"], 8080, "admin", "secret") + + +@patch("esphome.web_server_logs.run_logs") +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt_preferred_over_web_server( + mock_mqtt_show_logs: Mock, + mock_run_logs: Mock, +) -> None: + """With both MQTT logging and web_server, MQTT wins (API > MQTT > web_server).""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + CONF_WEB_SERVER: {CONF_PORT: 80}, + }, + platform=PLATFORM_ESP32, + ) + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", username="user", password="pass", client_id="client" + ) + result = show_logs(CORE.config, args, ["192.168.1.100"]) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once() + mock_run_logs.assert_not_called() + + def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( @@ -6760,3 +6894,144 @@ def test_check_permissions_unreadable_port() -> None: pytest.raises(EsphomeError, match="read or write permission"), ): check_permissions("/dev/ttyUSB99") + + +def _make_checkout(root: Path) -> Path: + """Create a directory that looks like an esphome checkout.""" + (root / "esphome").mkdir(parents=True) + (root / "esphome" / "__main__.py").write_text("", encoding="utf-8") + return root + + +def test_warn_source_tree_mismatch_warns_for_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in a checkout other than the one being run warns.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + assert "main" in caplog.text + + +def test_warn_source_tree_mismatch_silent_in_same_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Standing in the tree that is running is the normal case and is silent.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_outside_checkout( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """An ordinary install run from a config directory never warns.""" + running = _make_checkout(tmp_path / "main") + config_dir = tmp_path / "configs" + config_dir.mkdir() + monkeypatch.chdir(config_dir) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_silent_in_subdirectory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A subdirectory of the running tree resolves to that tree, so no warning.""" + tree = _make_checkout(tmp_path / "main") + subdir = tree / "esphome" / "components" + subdir.mkdir(parents=True) + monkeypatch.chdir(subdir) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_warns_when_stat_fails_on_other_tree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """The samefile() fallback must still warn when the trees really differ.""" + standing_in = _make_checkout(tmp_path / "worktree") + running = _make_checkout(tmp_path / "main") + monkeypatch.chdir(standing_in) + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert "worktree" in caplog.text + + +def test_warn_source_tree_mismatch_silent_when_cwd_is_gone( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """A deleted working directory must not turn the diagnostic into a traceback.""" + running = _make_checkout(tmp_path / "main") + monkeypatch.setattr(main, "__file__", str(running / "esphome" / "__main__.py")) + + def raise_filenotfound() -> Path: + raise FileNotFoundError("cwd is gone") + + monkeypatch.setattr(Path, "cwd", staticmethod(raise_filenotfound)) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + assert not caplog.text + + +def test_warn_source_tree_mismatch_falls_back_when_stat_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """If samefile() cannot stat, fall back to comparing the paths.""" + tree = _make_checkout(tmp_path / "main") + monkeypatch.chdir(tree) + monkeypatch.setattr(main, "__file__", str(tree / "esphome" / "__main__.py")) + + def raise_oserror(self: Path, other: Path) -> bool: + raise OSError("stat failed") + + monkeypatch.setattr(Path, "samefile", raise_oserror) + + with caplog.at_level(logging.WARNING): + main._warn_if_source_tree_mismatch() + + # Same tree, so the path comparison still finds them equal and stays silent + assert not caplog.text diff --git a/tests/unit_tests/test_platformio_runner.py b/tests/unit_tests/test_platformio_runner.py new file mode 100644 index 0000000000..f375aa457a --- /dev/null +++ b/tests/unit_tests/test_platformio_runner.py @@ -0,0 +1,93 @@ +"""Tests for esphome.platformio.runner.""" + +from __future__ import annotations + +from collections.abc import Callable +import io +import sys +from types import ModuleType + +import pytest + +from esphome.platformio import runner + + +def _prepare_main( + monkeypatch: pytest.MonkeyPatch, pio_main: Callable[[], int] +) -> io.BytesIO: + """Point ``runner.main()`` at a fake PlatformIO with a fake stdout. + + The real ``main`` patches PlatformIO internals and then hands control to + it; both are stubbed out so only the stream wrapping is exercised. The + fake stdout is block buffered like a pipe, so the caller can see what + actually left the wrapper. + """ + buf = io.BytesIO() + stream = io.TextIOWrapper(buf, encoding="utf-8", newline="\n", line_buffering=False) + + monkeypatch.setattr(sys, "argv", ["pio", "run"]) + monkeypatch.setattr(sys, "stdout", stream) + monkeypatch.setattr(sys, "stderr", stream) + monkeypatch.setattr(runner, "patch_structhash", lambda: None) + monkeypatch.setattr(runner, "patch_file_downloader", lambda: None) + + platformio = ModuleType("platformio") + platformio_main = ModuleType("platformio.__main__") + platformio_main.main = pio_main # type: ignore[attr-defined] + platformio.__main__ = platformio_main # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "platformio", platformio) + monkeypatch.setitem(sys.modules, "platformio.__main__", platformio_main) + + return buf + + +def test_main_drains_a_partial_line_on_a_clean_run( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A build ending mid line still shows that line.""" + + def pio_main() -> int: + print("Linking .pioenvs/firmware.elf\n", end="") + print("Building took 12.4 seconds", end="") + return 0 + + buf = _prepare_main(monkeypatch, pio_main) + + assert runner.main() == 0 + assert buf.getvalue().decode("utf-8") == ( + "Linking .pioenvs/firmware.elf\nBuilding took 12.4 seconds\n" + ) + + +def test_main_drains_when_platformio_exits_early( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Leaving through ``sys.exit`` still drains, because it runs in a finally.""" + + def pio_main() -> int: + print("*** [.pioenvs/firmware.elf] Error 1", end="") + sys.exit(1) + + buf = _prepare_main(monkeypatch, pio_main) + + with pytest.raises(SystemExit) as excinfo: + runner.main() + + assert excinfo.value.code == 1 + assert buf.getvalue().decode("utf-8") == "*** [.pioenvs/firmware.elf] Error 1\n" + + +def test_main_still_filters_a_drained_partial_line( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Releasing a held line does not smuggle noise past the filter.""" + + def pio_main() -> int: + # Matches FILTER_PLATFORMIO_LINES, and arrives without a terminator. + print("Verbose mode can be enabled via `-v, --verbose` option", end="") + return 0 + + buf = _prepare_main(monkeypatch, pio_main) + + assert runner.main() == 0 + assert buf.getvalue() == b"" diff --git a/tests/unit_tests/test_platformio_toolchain.py b/tests/unit_tests/test_platformio_toolchain.py index 9450e8e0e1..02c11b4e45 100644 --- a/tests/unit_tests/test_platformio_toolchain.py +++ b/tests/unit_tests/test_platformio_toolchain.py @@ -16,9 +16,10 @@ from unittest.mock import MagicMock, Mock, call, patch import pytest +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM from esphome.core import CORE, EsphomeError from esphome.platformio import runner, toolchain -from esphome.util import FlashImage +from esphome.util import ESP32_ARDUINO_ENV, FlashImage def test_idedata_firmware_elf_path(setup_core: Path) -> None: @@ -328,6 +329,66 @@ def test_idedata_null_section_raises_esphome_error(setup_core: Path) -> None: _ = toolchain.IDEData({"extra": None}).extra_flash_images +@pytest.mark.parametrize( + ("platform", "framework", "expected"), + [ + ("esp32", "arduino", "1"), + ("esp32", "esp-idf", None), + ("esp8266", "arduino", None), + ], +) +def test_run_platformio_cli_flags_an_esp32_arduino_build( + setup_core: Path, + mock_run_external_process: Mock, + platform: str, + framework: str, + expected: str | None, +) -> None: + """Only an ESP32 Arduino build is flagged, and an inherited one is cleared.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: platform, + KEY_TARGET_FRAMEWORK: framework, + } + + with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert env.get(ESP32_ARDUINO_ENV) == expected + # Only the subprocess env is touched; ours is left as it was. + assert os.environ[ESP32_ARDUINO_ENV] == "1" + + +def test_run_platformio_cli_ignores_an_inherited_flag_without_core( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """An inherited flag must not end up answering for CORE.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data.pop(KEY_CORE, None) + + with patch.dict(os.environ, {ESP32_ARDUINO_ENV: "1"}, clear=False): + mock_run_external_process.return_value = 0 + toolchain.run_platformio_cli("test", "arg") + + env = mock_run_external_process.call_args[1]["env"] + assert ESP32_ARDUINO_ENV not in env + + +def test_run_platformio_cli_raises_on_a_half_filled_core( + setup_core: Path, mock_run_external_process: Mock +) -> None: + """A CORE set up but left incomplete must surface, not fall back.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.data[KEY_CORE] = {} + + with patch.dict(os.environ, {}, clear=False): + mock_run_external_process.return_value = 0 + with pytest.raises(KeyError): + toolchain.run_platformio_cli("test", "arg") + + def test_run_platformio_cli_sets_environment_variables( setup_core: Path, mock_run_external_process: Mock ) -> None: diff --git a/tests/unit_tests/test_util.py b/tests/unit_tests/test_util.py index 02309fbff8..a4b091b7c2 100644 --- a/tests/unit_tests/test_util.py +++ b/tests/unit_tests/test_util.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable import io +import logging from pathlib import Path import subprocess import sys @@ -13,6 +14,8 @@ from unittest.mock import MagicMock, patch import pytest from esphome import util +from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM +from esphome.core import CORE def test_list_yaml_files_with_files_and_directories(tmp_path: Path) -> None: @@ -422,6 +425,199 @@ def _make_redirect( return redirect, buf +def test_redirect_text_flushes_so_piped_output_streams() -> None: + """Regression: in-process esptool progress must reach the pipe right away. + + ``run_external_command`` runs esptool inside our own process, so its + progress output goes through ``RedirectText.write``. That used to be + flushed only because ``colorama.init()`` wrapped stdout in a stream that + flushed after every write. + """ + buf = io.BytesIO() + piped_stream = io.TextIOWrapper( + buf, encoding="utf-8", newline="\n", line_buffering=False + ) + redirect = util.RedirectText(piped_stream) + + redirect.write("Writing at 0x00010000 (50%)\r") + + # No explicit flush here on purpose: RedirectText has to do it. + assert buf.getvalue() == b"Writing at 0x00010000 (50%)\r" + + +@pytest.mark.parametrize( + "break_char", + ["\x0c", "\x0b", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"], + ids=["formfeed", "vtab", "fs", "gs", "rs", "nel", "lsep", "psep"], +) +def test_redirect_text_keeps_output_after_an_exotic_break_character( + break_char: str, +) -> None: + r"""Only ``\n`` and ``\r`` end a line; the rest is ordinary text. + + ``str.splitlines`` treats all of these as line breaks. Splitting on them + used to strand the fragment in the buffer and drop every complete line + that came after it, which for a form feed in toolchain output meant + losing the rest of the build log. + """ + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write(f"first{break_char}second\nthird\n") + + assert buf.getvalue() == f"first{break_char}second\nthird\n" + + +def test_redirect_text_treats_crlf_as_one_terminator() -> None: + r"""``\r\n``, a lone ``\r`` and a lone ``\n`` each end exactly one line.""" + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("one\r\ntwo\rthree\nfour") + + # "four" has no terminator yet, so it is held back. + assert buf.getvalue() == "one\r\ntwo\rthree\n" + + redirect.drain() + + assert buf.getvalue() == "one\r\ntwo\rthree\nfour\n" + + +def test_redirect_text_drain_releases_held_partial_line() -> None: + """A last line with no terminator must still reach the user. + + A tool that dies part way through a line leaves that text in the buffer, + and it is usually the message saying what went wrong. + """ + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + redirect.write("FATAL: ld returned 1 exit status") + + # Still held: no terminator has arrived. + assert buf.getvalue() == "" + + redirect.drain() + + assert buf.getvalue() == "FATAL: ld returned 1 exit status\n" + + +def test_redirect_text_drain_still_applies_the_filter() -> None: + """Releasing a held line does not smuggle noise past the filter.""" + redirect, buf = _make_redirect(filter_lines=["Verbose mode can be enabled"]) + redirect.write("Verbose mode can be enabled") + + redirect.drain() + + assert buf.getvalue() == "" + + +def test_redirect_text_drain_is_a_no_op_when_nothing_is_held() -> None: + """Draining twice, or with an empty buffer, writes nothing extra.""" + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + redirect.write("complete line\n") + + redirect.drain() + redirect.drain() + + assert buf.getvalue() == "complete line\n" + + +def test_flash_error_help_is_quiet_when_core_is_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: reading the platform used to raise in the runner.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + + assert util.get_esp32_arduino_flash_error_help() is None + + +def test_flash_error_help_reads_the_env_var_when_core_is_unconfigured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The parent tells the subprocess what it cannot work out for itself.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1") + + help_msg = util.get_esp32_arduino_flash_error_help() + + assert help_msg is not None + assert "esp-idf" in help_msg + + +def test_is_esp32_arduino_build_raises_on_a_half_filled_core( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A half filled in CORE is a bug, so it must raise, not fall back.""" + + monkeypatch.setattr(CORE, "data", {KEY_CORE: {}}) + monkeypatch.setenv(util.ESP32_ARDUINO_ENV, "1") + + with pytest.raises(KeyError): + util.is_esp32_arduino_build() + + +@pytest.mark.parametrize( + ("platform", "framework", "expected"), + [ + ("esp32", "arduino", True), + ("esp32", "esp-idf", False), + ("esp8266", "arduino", False), + ], +) +def test_is_esp32_arduino_build_from_a_configured_core( + monkeypatch: pytest.MonkeyPatch, platform: str, framework: str, expected: bool +) -> None: + """With CORE set up, it is the source of truth and the env var is ignored.""" + + monkeypatch.setattr( + CORE, + "data", + {KEY_CORE: {KEY_TARGET_PLATFORM: platform, KEY_TARGET_FRAMEWORK: framework}}, + ) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + + assert util.is_esp32_arduino_build() is expected + + +def test_redirect_text_survives_a_flash_error_without_core( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The overflow line goes through even from a process with no CORE.""" + + monkeypatch.setattr(CORE, "data", {}) + monkeypatch.delenv(util.ESP32_ARDUINO_ENV, raising=False) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n" + + +def test_redirect_text_adds_flash_size_help(monkeypatch: pytest.MonkeyPatch) -> None: + """An out-of-flash error gets the how-to-fix note appended.""" + monkeypatch.setattr( + util, "get_esp32_arduino_flash_error_help", lambda: "TIP: switch to esp-idf\n" + ) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert "Error: The program size" in buf.getvalue() + assert "TIP: switch to esp-idf" in buf.getvalue() + + +def test_redirect_text_skips_flash_size_help_on_other_platforms( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The note is ESP32-with-Arduino only, so elsewhere the line stands alone.""" + monkeypatch.setattr(util, "get_esp32_arduino_flash_error_help", lambda: None) + redirect, buf = _make_redirect(filter_lines=["ignore me"]) + + redirect.write("Error: The program size is greater than maximum allowed\n") + + assert buf.getvalue() == "Error: The program size is greater than maximum allowed\n" + + def test_redirect_text_callback_called_on_matching_line() -> None: """Test that a line callback is called and its output is written.""" results: list[str] = [] @@ -551,6 +747,140 @@ def test_run_external_command_line_callbacks(capsys: pytest.CaptureFixture) -> N assert "CALLBACK FIRED" in captured.out +def test_run_external_command_drains_partial_line( + capsys: pytest.CaptureFixture, +) -> None: + """A command that stops mid line still shows that line. + + esptool runs in-process here, so a message it writes without a trailing + newline would otherwise be dropped when the streams are put back. + """ + + def fake_main() -> int: + print("A fatal error occurred: no serial data", end="") + return 1 + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 1 + assert "A fatal error occurred: no serial data" in capsys.readouterr().out + + +def test_run_external_command_drains_on_early_exit( + capsys: pytest.CaptureFixture, +) -> None: + """The drain also happens when the command exits through ``sys.exit``.""" + + def fake_main() -> int: + print("Fatal: bailing out", end="") + sys.exit(3) + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 3 + assert "Fatal: bailing out" in capsys.readouterr().out + + +def test_run_external_command_capture_stdout_has_nothing_to_drain() -> None: + """With ``capture_stdout`` there is nothing held to write out. + + The stdout wrapper still gets built, but ``sys.stdout`` is replaced by + the capture buffer right after, so the wrapper never sees a write and + draining it does nothing. + """ + + def fake_main() -> int: + print("captured output", end="") + return 0 + + out = util.run_external_command( + fake_main, "fake", capture_stdout=True, filter_lines=["ignore me"] + ) + + assert out == "captured output" + + +def test_run_external_command_survives_a_command_that_swaps_stdout( + capsys: pytest.CaptureFixture, +) -> None: + """Draining must not depend on what the command left in ``sys.stdout``. + + A command is free to replace the stream; reaching for ``drain`` on + whatever it left there would raise from the cleanup path and bury the + real exit code. + """ + + def fake_main() -> int: + print("before the swap", end="") + sys.stdout = io.StringIO() + sys.exit(7) + + rc = util.run_external_command(fake_main, "fake", filter_lines=["ignore me"]) + + assert rc == 7 + assert "before the swap" in capsys.readouterr().out + + +def test_drain_reports_the_lost_line_instead_of_raising( + caplog: pytest.LogCaptureFixture, +) -> None: + """A broken stream during cleanup is reported, not raised. + + The warning carries the held text, because the stream we were asked to + write it to is the one that just failed. + """ + caplog.set_level(logging.WARNING, logger=util.__name__) + out = MagicMock() + out.write.side_effect = BrokenPipeError("pipe is gone") + redirect = util.RedirectText(out, filter_lines=["ignore me"]) + redirect.write("FATAL: ld returned 1 exit status") + + redirect.drain() + + assert "pipe is gone" in caplog.text + assert "FATAL: ld returned 1 exit status" in caplog.text + + +def test_drain_lets_other_errors_through() -> None: + """Only an unusable stream is tolerated; a bug still has to be visible.""" + + def broken_callback(line: str) -> str | None: + raise TypeError("a line callback is broken") + + redirect, _buf = _make_redirect(line_callbacks=[broken_callback]) + redirect.write("a line with no terminator") + + with pytest.raises(TypeError): + redirect.drain() + + +def test_run_external_command_drains_stderr_even_if_stdout_drain_raises( + capsys: pytest.CaptureFixture, +) -> None: + """One stream failing must not strand the other's held line. + + ``drain`` deliberately lets anything that is not a stream error through, + so a broken line callback would otherwise skip the stderr drain and take + that line down with it. + """ + + def broken_on_stdout(line: str) -> str | None: + if "stdout" in line: + raise TypeError("a line callback is broken") + return None + + def fake_main() -> int: + print("stdout partial", end="") + print("stderr FATAL: the real reason", end="", file=sys.stderr) + return 0 + + with pytest.raises(TypeError): + util.run_external_command(fake_main, "fake", line_callbacks=[broken_on_stdout]) + + # The bug still surfaces, but stderr's held line was written first. + assert "stderr FATAL: the real reason" in capsys.readouterr().err + + def test_run_external_process_line_callbacks() -> None: """Test that run_external_process passes line_callbacks to RedirectText.""" results: list[str] = [] @@ -717,7 +1047,6 @@ class TestSafePrint: @pytest.fixture(autouse=True) def _no_dashboard(self, monkeypatch: pytest.MonkeyPatch) -> None: """Default ``CORE.dashboard`` to False so each test starts hermetic.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", False) @@ -739,12 +1068,36 @@ class TestSafePrint: monkeypatch: pytest.MonkeyPatch, ) -> None: r"""Dashboard mode escapes raw ``\033`` ESC bytes to literal ``\\033``.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", True) util.safe_print("\033[0;32mhi\033[0m") assert capsys.readouterr().out == "\\033[0;32mhi\\033[0m\n" + def test_flushes_so_piped_output_streams( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression: each line must reach the OS pipe right away. + + The dashboard runs ``esphome logs`` with stdout as a pipe, which + Python block buffers at 8 KiB. Log lines used to be flushed only + because ``colorama.init()`` wrapped stdout in a stream that flushed + after every write; once that wrapping was skipped for dashboard runs + the lines sat in the buffer and the log view stayed empty until + enough output piled up to fill it. + """ + buf = io.BytesIO() + # newline="\n" keeps Windows from rewriting the terminator to "\r\n"; + # this test is about flushing, not about line endings. + piped_stream = io.TextIOWrapper( + buf, encoding="utf-8", newline="\n", line_buffering=False + ) + monkeypatch.setattr(sys, "stdout", piped_stream) + + util.safe_print("live log line") + + # No explicit flush here on purpose: safe_print has to do it. + assert buf.getvalue() == b"live log line\n" + def test_fallback_writes_string_not_bytes_repr( self, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -764,7 +1117,7 @@ class TestSafePrint: monkeypatch.setattr(sys, "stdout", cp1252_stream) util.safe_print("bars: \u2582\u2584\u2586\u2588 done") - cp1252_stream.flush() + # No explicit flush: the fallback path has to flush too. output = buf.getvalue().decode("cp1252") # Output is a clean line, not the bytes repr. @@ -781,7 +1134,6 @@ class TestSafePrint: self, monkeypatch: pytest.MonkeyPatch ) -> None: """Dashboard ESC escaping + cp1252 fallback compose correctly.""" - from esphome.core import CORE monkeypatch.setattr(CORE, "dashboard", True) buf = io.BytesIO() @@ -789,7 +1141,7 @@ class TestSafePrint: monkeypatch.setattr(sys, "stdout", cp1252_stream) util.safe_print("\033[0;32m\u2582\u2584\u2586\u2588\033[0m") - cp1252_stream.flush() + # No explicit flush: the fallback path has to flush too. output = buf.getvalue().decode("cp1252") # Dashboard escaping turned ESC into literal "\033" (5 chars), which diff --git a/tests/unit_tests/test_web_server_helpers.py b/tests/unit_tests/test_web_server_helpers.py new file mode 100644 index 0000000000..0280630d69 --- /dev/null +++ b/tests/unit_tests/test_web_server_helpers.py @@ -0,0 +1,64 @@ +"""Unit tests for esphome.web_server_helpers module.""" + +from __future__ import annotations + +import socket + +import pytest + +from esphome.const import ( + CONF_AUTH, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_WEB_SERVER, +) +from esphome.core import EsphomeError +from esphome.web_server_helpers import ( + get_web_server_connection, + resolve_web_server_urls, +) + + +def test_resolve_web_server_urls_maps_ipv4_and_ipv6( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each resolved address becomes an (ip, url) pair with IPv6 bracketing.""" + addr_infos = [ + (socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80)), + (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 7)), + ] + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + assert resolve_web_server_urls("dev.local", 80, "/events") == [ + ("192.168.1.5", "http://192.168.1.5:80/events"), + ("fe80::1", "http://[fe80::1%257]:80/events"), + ] + + +def test_get_web_server_connection_without_auth() -> None: + """Port is returned and credentials are None when no auth is configured.""" + config = {CONF_WEB_SERVER: {CONF_PORT: 80}} + + assert get_web_server_connection(config) == (80, None, None) + + +def test_get_web_server_connection_with_auth() -> None: + """Port and HTTP Basic credentials are returned when auth is configured.""" + config = { + CONF_WEB_SERVER: { + CONF_PORT: 8080, + CONF_AUTH: {CONF_USERNAME: "admin", CONF_PASSWORD: "secret"}, + } + } + + assert get_web_server_connection(config) == (8080, "admin", "secret") + + +def test_get_web_server_connection_missing_component() -> None: + """A config without web_server raises a clear error.""" + with pytest.raises(EsphomeError, match="web_server.*not configured"): + get_web_server_connection({}) diff --git a/tests/unit_tests/test_web_server_logs.py b/tests/unit_tests/test_web_server_logs.py new file mode 100644 index 0000000000..bbdf37bed7 --- /dev/null +++ b/tests/unit_tests/test_web_server_logs.py @@ -0,0 +1,397 @@ +"""Unit tests for esphome.web_server_logs module.""" + +from __future__ import annotations + +from collections.abc import Iterator +import logging +import socket +from typing import Self +from unittest.mock import MagicMock + +import pytest +import requests +from requests.auth import HTTPBasicAuth + +from esphome import web_server_logs +from esphome.core import EsphomeError +from esphome.web_server_logs import ( + EVENTS_PATH, + WebServerLogsError, + _build_urls, + _consume, + _stream, + run_logs, +) + +# A realistic slice of the web_server /events SSE stream: an initial ping +# carrying the config, a state frame, two log frames (one multi-line), plus +# comment/id/retry lines that must be ignored. +SSE_LINES = [ + "retry: 30000", + "id: 12345", + "event: ping", + 'data: {"title":"dev","log":true}', + "", + "event: state", + 'data: {"id":"sensor-x","state":"ON"}', + "", + "event: log", + "data: \x1b[0;32m[I][main:001]: hello\x1b[0m", + "", + ": keepalive-comment", + "event: log", + "data: line one", + "data: line two", + "", +] + + +class _FakeResponse: + """Minimal stand-in for a streamed ``requests`` response.""" + + def __init__(self, status_code: int, lines: list[str]) -> None: + self.status_code = status_code + self._lines = lines + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def iter_lines(self) -> Iterator[bytes]: + for line in self._lines: + yield line.encode("utf8") + + +@pytest.fixture +def fake_parser() -> MagicMock: + """A LogParser whose parse_line returns the raw line unchanged.""" + parser = MagicMock() + parser.parse_line.side_effect = lambda line, time_str: line + return parser + + +def _patch_resolve( + monkeypatch: pytest.MonkeyPatch, + addr_infos: list[tuple[int, int, int, str, tuple]], +) -> None: + monkeypatch.setattr( + "esphome.web_server_helpers.resolve_ip_address", + lambda *args, **kwargs: addr_infos, + ) + + +# --------------------------------------------------------------------------- +# _build_urls +# --------------------------------------------------------------------------- + + +def test_build_urls_ipv4(monkeypatch: pytest.MonkeyPatch) -> None: + """An IPv4 host resolves to a plain http://ip:port/events URL.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("192.168.1.5", 80))], + ) + + assert _build_urls(["dev.local"], 80) == [ + ("192.168.1.5", f"http://192.168.1.5:80{EVENTS_PATH}") + ] + + +def test_build_urls_ipv6_brackets_and_zone(monkeypatch: pytest.MonkeyPatch) -> None: + """IPv6 literals are bracketed; link-local addresses get a %25 zone index.""" + _patch_resolve( + monkeypatch, + [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 8080, 0, 7))], + ) + + assert _build_urls(["dev.local"], 8080) == [ + ("fe80::1", f"http://[fe80::1%257]:8080{EVENTS_PATH}") + ] + + +def test_build_urls_dedups_and_skips_unresolvable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Duplicate resolved IPs collapse to one URL; resolve errors are skipped.""" + calls: list[str] = [] + + def fake_resolve(host: str, port: int, **kwargs: object) -> list[tuple]: + calls.append(host) + if host == "bad": + raise EsphomeError("nope") + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("10.0.0.1", port))] + + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", fake_resolve) + + # "good" and "dup" both resolve to 10.0.0.1, "bad" raises. + assert _build_urls(["good", "bad", "dup"], 80) == [ + ("10.0.0.1", f"http://10.0.0.1:80{EVENTS_PATH}") + ] + assert calls == ["good", "bad", "dup"] + + +# --------------------------------------------------------------------------- +# _consume (SSE parsing) +# --------------------------------------------------------------------------- + + +def test_consume_emits_only_log_frames( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """Only event: log data lines are printed; ping/state/comments are ignored.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, SSE_LINES), fake_parser) + + assert printed == [ + "\x1b[0;32m[I][main:001]: hello\x1b[0m", + "line one", + "line two", + ] + + +def test_consume_ignores_unterminated_trailing_frame( + monkeypatch: pytest.MonkeyPatch, fake_parser: MagicMock +) -> None: + """A log frame without its terminating blank line is not emitted.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + _consume(_FakeResponse(200, ["event: log", "data: dangling"]), fake_parser) + + assert printed == [] + + +# --------------------------------------------------------------------------- +# _stream +# --------------------------------------------------------------------------- + + +def test_stream_returns_false_when_connect_fails( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A failed connection logs a warning and reports not-connected.""" + + def boom(*args: object, **kwargs: object) -> _FakeResponse: + raise requests.ConnectionError("refused") + + monkeypatch.setattr(requests, "get", boom) + + with caplog.at_level(logging.WARNING): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is False + ) + assert "Could not connect to 10.0.0.1" in caplog.text + + +def test_stream_returns_true_when_established_then_dropped( + monkeypatch: pytest.MonkeyPatch, + fake_parser: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A mid-stream drop after connecting reports connected so we reconnect.""" + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + + class _DroppingResponse(_FakeResponse): + def iter_lines(self) -> Iterator[bytes]: + yield b"event: log" + yield b"data: before-drop" + yield b"" + raise requests.exceptions.ChunkedEncodingError("connection lost") + + monkeypatch.setattr(requests, "get", lambda *a, **kw: _DroppingResponse(200, [])) + + with caplog.at_level(logging.INFO): + assert ( + _stream("http://10.0.0.1:80/events", "10.0.0.1", None, fake_parser) is True + ) + assert printed == ["before-drop"] + assert "reconnecting" in caplog.text + + +# --------------------------------------------------------------------------- +# run_logs +# --------------------------------------------------------------------------- + + +def test_run_logs_streams_then_reconnects_until_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dropped stream reconnects; KeyboardInterrupt during the pause exits 0.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + printed: list[str] = [] + monkeypatch.setattr(web_server_logs, "safe_print", printed.append) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(200, SSE_LINES)) + + def stop(_delay: float) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", stop) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # The single stream was consumed before the reconnect pause interrupted us. + # run_logs renders through the real LogParser, which prefixes a timestamp, + # so assert on the payloads rather than exact equality. + assert len(printed) == 3 + assert "[I][main:001]: hello" in printed[0] + assert "line one" in printed[1] + assert "line two" in printed[2] + + +def test_run_logs_passes_basic_auth(monkeypatch: pytest.MonkeyPatch) -> None: + """Username + password are forwarded as HTTP Basic auth on the request.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + captured["url"] = url + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, "admin", "secret") == 0 + auth = captured["auth"] + assert isinstance(auth, HTTPBasicAuth) + assert (auth.username, auth.password) == ("admin", "secret") + assert captured["stream"] is True + assert captured["headers"] == {"Accept": "text/event-stream"} + + +def test_run_logs_no_auth_when_credentials_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No auth object is sent when username/password are not configured.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(web_server_logs, "safe_print", lambda line: None) + captured: dict[str, object] = {} + + def fake_get(url: str, **kwargs: object) -> _FakeResponse: + captured.update(kwargs) + return _FakeResponse(200, SSE_LINES) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + assert run_logs(["dev.local"], 80, None, None) == 0 + assert captured["auth"] is None + + +def test_run_logs_raises_on_auth_failure(monkeypatch: pytest.MonkeyPatch) -> None: + """HTTP 401 aborts with a clear error rather than reconnecting forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(401, [])) + + with pytest.raises(WebServerLogsError, match="Authentication failed"): + run_logs(["dev.local"], 80, "admin", "bad") + + +def test_run_logs_retries_on_transient_status( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A transient non-200 (e.g. 503) is logged and the loop retries.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(503, [])) + monkeypatch.setattr( + web_server_logs.time, + "sleep", + lambda _d: (_ for _ in ()).throw(KeyboardInterrupt()), + ) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert "Unexpected HTTP 503" in caplog.text + + +@pytest.mark.parametrize("status", (403, 404)) +def test_run_logs_raises_on_permanent_status( + monkeypatch: pytest.MonkeyPatch, status: int +) -> None: + """A permanent 403/404 aborts instead of retrying the endpoint forever.""" + monkeypatch.setattr( + web_server_logs, + "_build_urls", + lambda hosts, port: [("10.0.0.1", "http://10.0.0.1:80/events")], + ) + monkeypatch.setattr(requests, "get", lambda *a, **kw: _FakeResponse(status, [])) + + with pytest.raises(WebServerLogsError, match=str(status)): + run_logs(["dev.local"], 80, None, None) + + +def test_run_logs_backs_off_on_repeated_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Consecutive unreachable attempts grow the reconnect delay up to the cap.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + delays: list[float] = [] + + def record(delay: float) -> None: + delays.append(delay) + if len(delays) >= 4: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", record) + + assert run_logs(["dev.local"], 80, None, None) == 0 + # 1 -> 2 -> 4 -> 8 ... doubling, capped at MAX_RECONNECT_DELAY (10.0). + assert delays == [2.0, 4.0, 8.0, 10.0] + + +def test_run_logs_reports_unresolvable( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """When no host resolves, an error is logged and the loop pauses/retries.""" + monkeypatch.setattr(web_server_logs, "_build_urls", lambda hosts, port: []) + + # Let the first reconnect pause pass so the loop continues, then interrupt + # on the second so the retry path (the ``continue``) is exercised. + sleeps = {"n": 0} + + def sleep(_delay: float) -> None: + sleeps["n"] += 1 + if sleeps["n"] >= 2: + raise KeyboardInterrupt + + monkeypatch.setattr(web_server_logs.time, "sleep", sleep) + + with caplog.at_level(logging.ERROR): + assert run_logs(["dev.local"], 80, None, None) == 0 + assert sleeps["n"] == 2 + assert "Could not resolve" in caplog.text diff --git a/tests/unit_tests/test_web_server_ota.py b/tests/unit_tests/test_web_server_ota.py index 606905e36e..bde04f4db7 100644 --- a/tests/unit_tests/test_web_server_ota.py +++ b/tests/unit_tests/test_web_server_ota.py @@ -46,7 +46,7 @@ def _patch_resolve( for host, port in hosts ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) @@ -475,7 +475,7 @@ def test_run_ota_resolution_failure( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -491,7 +491,7 @@ def test_run_ota_resolution_failure_dashboard_mode( def _raise(*_args, **_kwargs): raise EsphomeError("dns failed") - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _raise) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _raise) monkeypatch.setattr(CORE, "dashboard", True) try: exit_code, host = run_ota(["does.not.exist"], 80, None, None, firmware) @@ -541,7 +541,7 @@ def test_run_ota_multiple_hosts_first_fails( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) with patch( "esphome.web_server_ota.requests.post", @@ -570,7 +570,7 @@ def test_run_ota_all_hosts_return_failure_no_exception( def _resolve(host, port, address_cache=None): # noqa: ARG001 return addr_lookup[host] - monkeypatch.setattr("esphome.web_server_ota.resolve_ip_address", _resolve) + monkeypatch.setattr("esphome.web_server_helpers.resolve_ip_address", _resolve) exit_code, host = run_ota(["a.local", "b.local"], 80, None, None, firmware) @@ -633,7 +633,7 @@ def test_run_ota_ipv6_url_brackets_host( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::1", 80, 0, 0)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch( @@ -656,7 +656,7 @@ def test_run_ota_ipv6_link_local_includes_scope_id( (socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("fe80::1", 80, 0, 3)), ] monkeypatch.setattr( - "esphome.web_server_ota.resolve_ip_address", lambda *a, **kw: addr_infos + "esphome.web_server_helpers.resolve_ip_address", lambda *a, **kw: addr_infos ) with patch(