diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml new file mode 100644 index 0000000000..4c98a47ecd --- /dev/null +++ b/.github/actions/build-image/action.yaml @@ -0,0 +1,97 @@ +name: Build Image +inputs: + platform: + description: "Platform to build for" + required: true + example: "linux/amd64" + target: + description: "Target to build" + required: true + example: "docker" + baseimg: + description: "Base image type" + required: true + example: "docker" + suffix: + description: "Suffix to add to tags" + required: true + version: + description: "Version to build" + required: true + example: "2023.12.0" +runs: + using: "composite" + steps: + - name: Generate short tags + id: tags + shell: bash + run: | + output=$(docker/generate_tags.py \ + --tag "${{ inputs.version }}" \ + --suffix "${{ inputs.suffix }}") + echo $output + for l in $output; do + echo $l >> $GITHUB_OUTPUT + done + + - name: Build and push to ghcr by digest + id: build-ghcr + uses: docker/build-push-action@v5.0.0 + with: + context: . + file: ./docker/Dockerfile + platforms: ${{ inputs.platform }} + target: ${{ inputs.target }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BASEIMGTYPE=${{ inputs.baseimg }} + BUILD_VERSION=${{ inputs.version }} + outputs: | + type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true + + - name: Export ghcr digests + shell: bash + run: | + mkdir -p /tmp/digests/${{ inputs.target }}/ghcr + digest="${{ steps.build-ghcr.outputs.digest }}" + touch "/tmp/digests/${{ inputs.target }}/ghcr/${digest#sha256:}" + + - name: Upload ghcr digest + uses: actions/upload-artifact@v3.1.3 + with: + name: digests-${{ inputs.target }}-ghcr + path: /tmp/digests/${{ inputs.target }}/ghcr/* + if-no-files-found: error + retention-days: 1 + + - name: Build and push to dockerhub by digest + id: build-dockerhub + uses: docker/build-push-action@v5.0.0 + with: + context: . + file: ./docker/Dockerfile + platforms: ${{ inputs.platform }} + target: ${{ inputs.target }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BASEIMGTYPE=${{ inputs.baseimg }} + BUILD_VERSION=${{ inputs.version }} + outputs: | + type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true + + - name: Export dockerhub digests + shell: bash + run: | + mkdir -p /tmp/digests/${{ inputs.target }}/dockerhub + digest="${{ steps.build-dockerhub.outputs.digest }}" + touch "/tmp/digests/${{ inputs.target }}/dockerhub/${digest#sha256:}" + + - name: Upload dockerhub digest + uses: actions/upload-artifact@v3.1.3 + with: + name: digests-${{ inputs.target }}-dockerhub + path: /tmp/digests/${{ inputs.target }}/dockerhub/* + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/needs-docs.yml b/.github/workflows/needs-docs.yml index 5019d64752..628b5cc5e3 100644 --- a/.github/workflows/needs-docs.yml +++ b/.github/workflows/needs-docs.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check for needs-docs label - uses: actions/github-script@v6.4.1 + uses: actions/github-script@v7.0.1 with: script: | const { data: labels } = await github.rest.issues.listLabelsOnIssue({ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14dbeee7b7..69456619e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,30 +63,20 @@ jobs: run: twine upload dist/* deploy-docker: - name: Build and publish ESPHome ${{ matrix.image.title}} + name: Build ESPHome ${{ matrix.platform }} if: github.repository == 'esphome/esphome' permissions: contents: read packages: write runs-on: ubuntu-latest - continue-on-error: ${{ matrix.image.title == 'lint' }} needs: [init] strategy: fail-fast: false matrix: - image: - - title: "ha-addon" - suffix: "hassio" - target: "hassio" - baseimg: "hassio" - - title: "docker" - suffix: "" - target: "docker" - baseimg: "docker" - - title: "lint" - suffix: "lint" - target: "lint" - baseimg: "docker" + platform: + - linux/amd64 + - linux/arm/v7 + - linux/arm64 steps: - uses: actions/checkout@v4.1.1 - name: Set up Python @@ -97,6 +87,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3.0.0 - name: Set up QEMU + if: matrix.platform != 'linux/amd64' uses: docker/setup-qemu-action@v3.0.0 - name: Log in to docker hub @@ -111,37 +102,108 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Build docker + uses: ./.github/actions/build-image + with: + platform: ${{ matrix.platform }} + target: docker + baseimg: docker + suffix: "" + version: ${{ needs.init.outputs.tag }} + + - name: Build ha-addon + uses: ./.github/actions/build-image + with: + platform: ${{ matrix.platform }} + target: hassio + baseimg: hassio + suffix: "hassio" + version: ${{ needs.init.outputs.tag }} + + - name: Build lint + uses: ./.github/actions/build-image + with: + platform: ${{ matrix.platform }} + target: lint + baseimg: docker + suffix: lint + version: ${{ needs.init.outputs.tag }} + + deploy-manifest: + name: Publish ESPHome ${{ matrix.image.title }} to ${{ matrix.registry }} + runs-on: ubuntu-latest + needs: + - init + - deploy-docker + if: github.repository == 'esphome/esphome' + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + image: + - title: "ha-addon" + target: "hassio" + suffix: "hassio" + - title: "docker" + target: "docker" + suffix: "" + - title: "lint" + target: "lint" + suffix: "lint" + registry: + - ghcr + - dockerhub + steps: + - uses: actions/checkout@v4.1.1 + - name: Download digests + uses: actions/download-artifact@v3.0.2 + with: + name: digests-${{ matrix.image.target }}-${{ matrix.registry }} + path: /tmp/digests + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.0.0 + + - name: Log in to docker hub + if: matrix.registry == 'dockerhub' + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKER_USER }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Log in to the GitHub container registry + if: matrix.registry == 'ghcr' + uses: docker/login-action@v3.0.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Generate short tags id: tags run: | - docker/generate_tags.py \ + output=$(docker/generate_tags.py \ --tag "${{ needs.init.outputs.tag }}" \ - --suffix "${{ matrix.image.suffix }}" + --suffix "${{ matrix.image.suffix }}" \ + --registry "${{ matrix.registry }}") + echo $output + for l in $output; do + echo $l >> $GITHUB_OUTPUT + done - - name: Build and push - uses: docker/build-push-action@v5.0.0 - with: - context: . - file: ./docker/Dockerfile - platforms: linux/amd64,linux/arm/v7,linux/arm64 - target: ${{ matrix.image.target }} - push: true - # yamllint disable rule:line-length - cache-from: type=registry,ref=ghcr.io/${{ steps.tags.outputs.image }}:cache-${{ steps.tags.outputs.channel }} - cache-to: type=registry,ref=ghcr.io/${{ steps.tags.outputs.image }}:cache-${{ steps.tags.outputs.channel }},mode=max - # yamllint enable rule:line-length - tags: ${{ steps.tags.outputs.tags }} - build-args: | - BASEIMGTYPE=${{ matrix.image.baseimg }} - BUILD_VERSION=${{ needs.init.outputs.tag }} + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \ + $(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *) deploy-ha-addon-repo: if: github.repository == 'esphome/esphome' && github.event_name == 'release' runs-on: ubuntu-latest - needs: [deploy-docker] + needs: [deploy-manifest] steps: - name: Trigger Workflow - uses: actions/github-script@v6.4.1 + uses: actions/github-script@v7.0.1 with: github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} script: | diff --git a/CODEOWNERS b/CODEOWNERS index 918ba09801..48dfcdfc6f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -90,6 +90,7 @@ esphome/components/duty_time/* @dudanov esphome/components/ee895/* @Stock-M esphome/components/ektf2232/* @jesserockz esphome/components/emc2101/* @ellull +esphome/components/ens160/* @vincentscode esphome/components/ens210/* @itn3rd77 esphome/components/esp32/* @esphome/core esphome/components/esp32_ble/* @Rapsssito @jesserockz diff --git a/docker/Dockerfile b/docker/Dockerfile index 7ca633a982..ee7c70bb0f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -38,6 +38,7 @@ RUN \ openssh-client=1:9.2p1-2+deb12u1 \ python3-cffi=1.15.1-5 \ libcairo2=1.16.0-7 \ + libmagic1=1:5.44-3 \ patch=2.7.6-7; \ if [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]; then \ apt-get install -y --no-install-recommends \ @@ -48,6 +49,8 @@ RUN \ libfreetype-dev=2.12.1+dfsg-5 \ libssl-dev=3.0.11-1~deb12u2 \ libffi-dev=3.4.4-1 \ + libopenjp2-7=2.5.0-2 \ + libtiff6=4.5.0-6 \ cargo=0.66.0+ds1-1 \ pkg-config=1.8.1-1 \ gcc-arm-linux-gnueabihf=4:12.2.0-3; \ @@ -68,7 +71,7 @@ ENV \ # See: https://unix.stackexchange.com/questions/553743/correct-way-to-add-lib-ld-linux-so-3-in-debian RUN \ if [ "$TARGETARCH$TARGETVARIANT" = "armv7" ]; then \ - ln -s /lib/arm-linux-gnueabihf/ld-linux.so.3 /lib/ld-linux.so.3; \ + ln -s /lib/arm-linux-gnueabihf/ld-linux-armhf.so.3 /lib/ld-linux.so.3; \ fi RUN \ diff --git a/docker/generate_tags.py b/docker/generate_tags.py index 71d0735526..3fc787d485 100755 --- a/docker/generate_tags.py +++ b/docker/generate_tags.py @@ -1,13 +1,14 @@ #!/usr/bin/env python3 import re -import os import argparse -import json CHANNEL_DEV = "dev" CHANNEL_BETA = "beta" CHANNEL_RELEASE = "release" +GHCR = "ghcr" +DOCKERHUB = "dockerhub" + parser = argparse.ArgumentParser() parser.add_argument( "--tag", @@ -21,21 +22,31 @@ parser.add_argument( required=True, help="The suffix of the tag.", ) +parser.add_argument( + "--registry", + type=str, + choices=[GHCR, DOCKERHUB], + required=False, + action="append", + help="The registry to build tags for.", +) def main(): args = parser.parse_args() # detect channel from tag - match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) + match = re.match(r"^(\d+\.\d+)(?:\.\d+)(?:(b\d+)|(-dev\d+))?$", args.tag) major_minor_version = None - if match is None: + if match is None: # eg 2023.12.0-dev20231109-testbranch + channel = None # Ran with custom tag for a branch etc + elif match.group(3) is not None: # eg 2023.12.0-dev20231109 channel = CHANNEL_DEV - elif match.group(2) is None: + elif match.group(2) is not None: # eg 2023.12.0b1 + channel = CHANNEL_BETA + else: # eg 2023.12.0 major_minor_version = match.group(1) channel = CHANNEL_RELEASE - else: - channel = CHANNEL_BETA tags_to_push = [args.tag] if channel == CHANNEL_DEV: @@ -53,15 +64,28 @@ def main(): suffix = f"-{args.suffix}" if args.suffix else "" - with open(os.environ["GITHUB_OUTPUT"], "w") as f: - print(f"channel={channel}", file=f) - print(f"image=esphome/esphome{suffix}", file=f) - full_tags = [] + image_name = f"esphome/esphome{suffix}" - for tag in tags_to_push: - full_tags += [f"ghcr.io/esphome/esphome{suffix}:{tag}"] - full_tags += [f"esphome/esphome{suffix}:{tag}"] - print(f"tags={','.join(full_tags)}", file=f) + print(f"channel={channel}") + + if args.registry is None: + args.registry = [GHCR, DOCKERHUB] + elif len(args.registry) == 1: + if GHCR in args.registry: + print(f"image=ghcr.io/{image_name}") + if DOCKERHUB in args.registry: + print(f"image=docker.io/{image_name}") + + print(f"image_name={image_name}") + + full_tags = [] + + for tag in tags_to_push: + if GHCR in args.registry: + full_tags += [f"ghcr.io/{image_name}:{tag}"] + if DOCKERHUB in args.registry: + full_tags += [f"docker.io/{image_name}:{tag}"] + print(f"tags={','.join(full_tags)}") if __name__ == "__main__": diff --git a/esphome/__main__.py b/esphome/__main__.py index a253fc78a0..e5456cf8e5 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -514,7 +514,7 @@ def command_clean(args, config): def command_dashboard(args): from esphome.dashboard import dashboard - return dashboard.start_web_server(args) + return dashboard.start_dashboard(args) def command_update_all(args): diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index 2c43eca70c..dd013c8c34 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -8,7 +8,6 @@ from typing import Any from aioesphomeapi import APIClient from aioesphomeapi.api_pb2 import SubscribeLogsResponse from aioesphomeapi.log_runner import async_run -from zeroconf.asyncio import AsyncZeroconf from esphome.const import CONF_KEY, CONF_PASSWORD, CONF_PORT, __version__ from esphome.core import CORE @@ -18,24 +17,22 @@ from . import CONF_ENCRYPTION _LOGGER = logging.getLogger(__name__) -async def async_run_logs(config, address): +async def async_run_logs(config: dict[str, Any], address: str) -> None: """Run the logs command in the event loop.""" conf = config["api"] + name = config["esphome"]["name"] port: int = int(conf[CONF_PORT]) password: str = conf[CONF_PASSWORD] noise_psk: str | None = None if CONF_ENCRYPTION in conf: noise_psk = conf[CONF_ENCRYPTION][CONF_KEY] _LOGGER.info("Starting log output from %s using esphome API", address) - aiozc = AsyncZeroconf() - cli = APIClient( address, port, password, client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, - zeroconf_instance=aiozc.zeroconf, ) dashboard = CORE.dashboard @@ -48,12 +45,10 @@ async def async_run_logs(config, address): text = text.replace("\033", "\\033") print(f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}]{text}") - stop = await async_run(cli, on_log, aio_zeroconf_instance=aiozc) + stop = await async_run(cli, on_log, name=name) try: - while True: - await asyncio.sleep(60) + await asyncio.Event().wait() finally: - await aiozc.async_close() await stop() diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 0cf1339971..c7c286d679 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -1,38 +1,37 @@ import esphome.codegen as cg import esphome.config_validation as cv -from esphome.components import ( - climate, - remote_transmitter, - remote_receiver, - sensor, - remote_base, -) -from esphome.components.remote_base import CONF_RECEIVER_ID, CONF_TRANSMITTER_ID +from esphome.components import climate, sensor, remote_base from esphome.const import CONF_SUPPORTS_COOL, CONF_SUPPORTS_HEAT, CONF_SENSOR +DEPENDENCIES = ["remote_transmitter"] AUTO_LOAD = ["sensor", "remote_base"] CODEOWNERS = ["@glmnet"] climate_ir_ns = cg.esphome_ns.namespace("climate_ir") ClimateIR = climate_ir_ns.class_( - "ClimateIR", climate.Climate, cg.Component, remote_base.RemoteReceiverListener + "ClimateIR", + climate.Climate, + cg.Component, + remote_base.RemoteReceiverListener, + remote_base.RemoteTransmittable, ) -CLIMATE_IR_SCHEMA = climate.CLIMATE_SCHEMA.extend( - { - cv.GenerateID(CONF_TRANSMITTER_ID): cv.use_id( - remote_transmitter.RemoteTransmitterComponent - ), - cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean, - cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, - cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), - } -).extend(cv.COMPONENT_SCHEMA) +CLIMATE_IR_SCHEMA = ( + climate.CLIMATE_SCHEMA.extend( + { + cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean, + cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, + cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(remote_base.REMOTE_TRANSMITTABLE_SCHEMA) +) CLIMATE_IR_WITH_RECEIVER_SCHEMA = CLIMATE_IR_SCHEMA.extend( { - cv.Optional(CONF_RECEIVER_ID): cv.use_id( - remote_receiver.RemoteReceiverComponent + cv.Optional(remote_base.CONF_RECEIVER_ID): cv.use_id( + remote_base.RemoteReceiverBase ), } ) @@ -41,15 +40,11 @@ CLIMATE_IR_WITH_RECEIVER_SCHEMA = CLIMATE_IR_SCHEMA.extend( async def register_climate_ir(var, config): await cg.register_component(var, config) await climate.register_climate(var, config) - + await remote_base.register_transmittable(var, config) cg.add(var.set_supports_cool(config[CONF_SUPPORTS_COOL])) cg.add(var.set_supports_heat(config[CONF_SUPPORTS_HEAT])) + if remote_base.CONF_RECEIVER_ID in config: + await remote_base.register_listener(var, config) if sensor_id := config.get(CONF_SENSOR): sens = await cg.get_variable(sensor_id) cg.add(var.set_sensor(sens)) - if receiver_id := config.get(CONF_RECEIVER_ID): - receiver = await cg.get_variable(receiver_id) - cg.add(receiver.register_listener(var)) - - transmitter = await cg.get_variable(config[CONF_TRANSMITTER_ID]) - cg.add(var.set_transmitter(transmitter)) diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 5be4fc06f5..ea0656121f 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -18,7 +18,10 @@ namespace climate_ir { Likewise to decode a IR into the AC state, implement bool RemoteReceiverListener::on_receive(remote_base::RemoteReceiveData data) and return true */ -class ClimateIR : public climate::Climate, public Component, public remote_base::RemoteReceiverListener { +class ClimateIR : public Component, + public climate::Climate, + public remote_base::RemoteReceiverListener, + public remote_base::RemoteTransmittable { public: ClimateIR(float minimum_temperature, float maximum_temperature, float temperature_step = 1.0f, bool supports_dry = false, bool supports_fan_only = false, std::set fan_modes = {}, @@ -35,9 +38,6 @@ class ClimateIR : public climate::Climate, public Component, public remote_base: void setup() override; void dump_config() override; - void set_transmitter(remote_transmitter::RemoteTransmitterComponent *transmitter) { - this->transmitter_ = transmitter; - } void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } @@ -64,7 +64,6 @@ class ClimateIR : public climate::Climate, public Component, public remote_base: std::set swing_modes_ = {}; std::set presets_ = {}; - remote_transmitter::RemoteTransmitterComponent *transmitter_; sensor::Sensor *sensor_{nullptr}; }; diff --git a/esphome/components/coolix/coolix.cpp b/esphome/components/coolix/coolix.cpp index f4309419a4..22b3431c3e 100644 --- a/esphome/components/coolix/coolix.cpp +++ b/esphome/components/coolix/coolix.cpp @@ -102,11 +102,7 @@ void CoolixClimate::transmit_state() { } } ESP_LOGV(TAG, "Sending coolix code: 0x%06" PRIX32, remote_state); - - auto transmit = this->transmitter_->transmit(); - auto *data = transmit.get_data(); - remote_base::CoolixProtocol().encode(data, remote_state); - transmit.perform(); + this->transmit_(remote_state); } bool CoolixClimate::on_coolix(climate::Climate *parent, remote_base::RemoteReceiveData data) { diff --git a/esphome/components/ens160/__init__.py b/esphome/components/ens160/__init__.py new file mode 100644 index 0000000000..d26770a89d --- /dev/null +++ b/esphome/components/ens160/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@vincentscode"] diff --git a/esphome/components/ens160/ens160.cpp b/esphome/components/ens160/ens160.cpp new file mode 100644 index 0000000000..c7a6ccbb73 --- /dev/null +++ b/esphome/components/ens160/ens160.cpp @@ -0,0 +1,321 @@ +// ENS160 sensor with I2C interface from ScioSense +// +// Datasheet: https://www.sciosense.com/wp-content/uploads/documents/SC-001224-DS-7-ENS160-Datasheet.pdf +// +// Implementation based on: +// https://github.com/sciosense/ENS160_driver + +#include "ens160.h" +#include "esphome/core/log.h" +#include "esphome/core/hal.h" + +namespace esphome { +namespace ens160 { + +static const char *const TAG = "ens160"; + +static const uint8_t ENS160_BOOTING = 10; + +static const uint16_t ENS160_PART_ID = 0x0160; + +static const uint8_t ENS160_REG_PART_ID = 0x00; +static const uint8_t ENS160_REG_OPMODE = 0x10; +static const uint8_t ENS160_REG_CONFIG = 0x11; +static const uint8_t ENS160_REG_COMMAND = 0x12; +static const uint8_t ENS160_REG_TEMP_IN = 0x13; +static const uint8_t ENS160_REG_DATA_STATUS = 0x20; +static const uint8_t ENS160_REG_DATA_AQI = 0x21; +static const uint8_t ENS160_REG_DATA_TVOC = 0x22; +static const uint8_t ENS160_REG_DATA_ECO2 = 0x24; + +static const uint8_t ENS160_REG_GPR_READ_0 = 0x48; +static const uint8_t ENS160_REG_GPR_READ_4 = ENS160_REG_GPR_READ_0 + 4; + +static const uint8_t ENS160_COMMAND_NOP = 0x00; +static const uint8_t ENS160_COMMAND_CLRGPR = 0xCC; +static const uint8_t ENS160_COMMAND_GET_APPVER = 0x0E; + +static const uint8_t ENS160_OPMODE_RESET = 0xF0; +static const uint8_t ENS160_OPMODE_IDLE = 0x01; +static const uint8_t ENS160_OPMODE_STD = 0x02; + +static const uint8_t ENS160_DATA_STATUS_STATAS = 0x80; +static const uint8_t ENS160_DATA_STATUS_STATER = 0x40; +static const uint8_t ENS160_DATA_STATUS_VALIDITY = 0x0C; +static const uint8_t ENS160_DATA_STATUS_NEWDAT = 0x02; +static const uint8_t ENS160_DATA_STATUS_NEWGPR = 0x01; + +// helps remove reserved bits in aqi data register +static const uint8_t ENS160_DATA_AQI = 0x07; + +void ENS160Component::setup() { + ESP_LOGCONFIG(TAG, "Setting up ENS160..."); + + // check part_id + uint16_t part_id; + if (!this->read_bytes(ENS160_REG_PART_ID, reinterpret_cast(&part_id), 2)) { + this->error_code_ = COMMUNICATION_FAILED; + this->mark_failed(); + return; + } + if (part_id != ENS160_PART_ID) { + this->error_code_ = INVALID_ID; + this->mark_failed(); + return; + } + + // set mode to reset + if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_RESET)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + delay(ENS160_BOOTING); + + // check status + uint8_t status_value; + if (!this->read_byte(ENS160_REG_DATA_STATUS, &status_value)) { + this->error_code_ = READ_FAILED; + this->mark_failed(); + return; + } + this->validity_flag_ = static_cast((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); + + if (this->validity_flag_ == INVALID_OUTPUT) { + this->error_code_ = VALIDITY_INVALID; + this->mark_failed(); + return; + } + + // set mode to idle + if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_IDLE)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + // clear command + if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_NOP)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_CLRGPR)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + + // read firmware version + if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_GET_APPVER)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + uint8_t version_data[3]; + if (!this->read_bytes(ENS160_REG_GPR_READ_4, version_data, 3)) { + this->error_code_ = READ_FAILED; + this->mark_failed(); + return; + } + this->firmware_ver_major_ = version_data[0]; + this->firmware_ver_minor_ = version_data[1]; + this->firmware_ver_build_ = version_data[2]; + + // set mode to standard + if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD)) { + this->error_code_ = WRITE_FAILED; + this->mark_failed(); + return; + } + + // read opmode and check standard mode is achieved before finishing Setup + uint8_t op_mode; + if (!this->read_byte(ENS160_REG_OPMODE, &op_mode)) { + this->error_code_ = READ_FAILED; + this->mark_failed(); + return; + } + + if (op_mode != ENS160_OPMODE_STD) { + this->error_code_ = STD_OPMODE_FAILED; + this->mark_failed(); + return; + } +} + +void ENS160Component::update() { + uint8_t status_value, data_ready; + + if (!this->read_byte(ENS160_REG_DATA_STATUS, &status_value)) { + ESP_LOGW(TAG, "Error reading status register"); + this->status_set_warning(); + return; + } + + // verbose status logging + ESP_LOGV(TAG, "Status: ENS160 STATAS bit 0x%x", + (ENS160_DATA_STATUS_STATAS & (status_value)) == ENS160_DATA_STATUS_STATAS); + ESP_LOGV(TAG, "Status: ENS160 STATER bit 0x%x", + (ENS160_DATA_STATUS_STATER & (status_value)) == ENS160_DATA_STATUS_STATER); + ESP_LOGV(TAG, "Status: ENS160 VALIDITY FLAG 0x%02x", (ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); + ESP_LOGV(TAG, "Status: ENS160 NEWDAT bit 0x%x", + (ENS160_DATA_STATUS_NEWDAT & (status_value)) == ENS160_DATA_STATUS_NEWDAT); + ESP_LOGV(TAG, "Status: ENS160 NEWGPR bit 0x%x", + (ENS160_DATA_STATUS_NEWGPR & (status_value)) == ENS160_DATA_STATUS_NEWGPR); + + data_ready = ENS160_DATA_STATUS_NEWDAT & status_value; + this->validity_flag_ = static_cast((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2); + + switch (validity_flag_) { + case NORMAL_OPERATION: + if (data_ready != ENS160_DATA_STATUS_NEWDAT) { + ESP_LOGD(TAG, "ENS160 readings unavailable - Normal Operation but readings not ready"); + return; + } + break; + case INITIAL_STARTUP: + if (!this->initial_startup_) { + this->initial_startup_ = true; + ESP_LOGI(TAG, "ENS160 readings unavailable - 1 hour startup required after first power on"); + } + return; + case WARMING_UP: + if (!this->warming_up_) { + this->warming_up_ = true; + ESP_LOGI(TAG, "ENS160 readings not available yet - Warming up requires 3 minutes"); + this->send_env_data_(); + } + return; + case INVALID_OUTPUT: + ESP_LOGE(TAG, "ENS160 Invalid Status - No Invalid Output"); + this->status_set_warning(); + return; + } + + // read new data + uint16_t data_eco2; + if (!this->read_bytes(ENS160_REG_DATA_ECO2, reinterpret_cast(&data_eco2), 2)) { + ESP_LOGW(TAG, "Error reading eCO2 data register"); + this->status_set_warning(); + return; + } + if (this->co2_ != nullptr) { + this->co2_->publish_state(data_eco2); + } + + uint16_t data_tvoc; + if (!this->read_bytes(ENS160_REG_DATA_TVOC, reinterpret_cast(&data_tvoc), 2)) { + ESP_LOGW(TAG, "Error reading TVOC data register"); + this->status_set_warning(); + return; + } + if (this->tvoc_ != nullptr) { + this->tvoc_->publish_state(data_tvoc); + } + + uint8_t data_aqi; + if (!this->read_byte(ENS160_REG_DATA_AQI, &data_aqi)) { + ESP_LOGW(TAG, "Error reading AQI data register"); + this->status_set_warning(); + return; + } + if (this->aqi_ != nullptr) { + // remove reserved bits, just in case they are used in future + data_aqi = ENS160_DATA_AQI & data_aqi; + + this->aqi_->publish_state(data_aqi); + } + + this->status_clear_warning(); + + // set temperature and humidity compensation data + this->send_env_data_(); +} + +void ENS160Component::send_env_data_() { + if (this->temperature_ == nullptr && this->humidity_ == nullptr) + return; + + float temperature = NAN; + if (this->temperature_ != nullptr) + temperature = this->temperature_->state; + + if (std::isnan(temperature) || temperature < -40.0f || temperature > 85.0f) { + ESP_LOGW(TAG, "Invalid external temperature - compensation values not updated"); + return; + } else { + ESP_LOGV(TAG, "External temperature compensation: %.1f°C", temperature); + } + + float humidity = NAN; + if (this->humidity_ != nullptr) + humidity = this->humidity_->state; + + if (std::isnan(humidity) || humidity < 0.0f || humidity > 100.0f) { + ESP_LOGW(TAG, "Invalid external humidity - compensation values not updated"); + return; + } else { + ESP_LOGV(TAG, "External humidity compensation: %.1f%%", humidity); + } + + uint16_t t = (uint16_t) ((temperature + 273.15f) * 64.0f); + uint16_t h = (uint16_t) (humidity * 512.0f); + + uint8_t data[4]; + data[0] = t & 0xff; + data[1] = (t >> 8) & 0xff; + data[2] = h & 0xff; + data[3] = (h >> 8) & 0xff; + + if (!this->write_bytes(ENS160_REG_TEMP_IN, data, 4)) { + ESP_LOGE(TAG, "Error writing compensation values"); + this->status_set_warning(); + return; + } +} + +void ENS160Component::dump_config() { + ESP_LOGCONFIG(TAG, "ENS160:"); + + switch (this->error_code_) { + case COMMUNICATION_FAILED: + ESP_LOGE(TAG, "Communication failed! Is the sensor connected?"); + break; + case READ_FAILED: + ESP_LOGE(TAG, "Error reading from register"); + break; + case WRITE_FAILED: + ESP_LOGE(TAG, "Error writing to register"); + break; + case INVALID_ID: + ESP_LOGE(TAG, "Sensor reported an invalid ID. Is this a ENS160?"); + break; + case VALIDITY_INVALID: + ESP_LOGE(TAG, "Invalid Device Status - No valid output"); + break; + case STD_OPMODE_FAILED: + ESP_LOGE(TAG, "Device failed to achieve Standard Operating Mode"); + break; + case NONE: + ESP_LOGD(TAG, "Setup successful"); + break; + } + ESP_LOGI(TAG, "Firmware Version: %d.%d.%d", this->firmware_ver_major_, this->firmware_ver_minor_, + this->firmware_ver_build_); + + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + LOG_SENSOR(" ", "CO2 Sensor:", this->co2_); + LOG_SENSOR(" ", "TVOC Sensor:", this->tvoc_); + LOG_SENSOR(" ", "AQI Sensor:", this->aqi_); + + if (this->temperature_ != nullptr && this->humidity_ != nullptr) { + LOG_SENSOR(" ", " Temperature Compensation:", this->temperature_); + LOG_SENSOR(" ", " Humidity Compensation:", this->humidity_); + } else { + ESP_LOGCONFIG(TAG, " Compensation: Not configured"); + } +} + +} // namespace ens160 +} // namespace esphome diff --git a/esphome/components/ens160/ens160.h b/esphome/components/ens160/ens160.h new file mode 100644 index 0000000000..88bc8e3501 --- /dev/null +++ b/esphome/components/ens160/ens160.h @@ -0,0 +1,60 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome { +namespace ens160 { + +class ENS160Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor { + public: + void set_co2(sensor::Sensor *co2) { co2_ = co2; } + void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; } + void set_aqi(sensor::Sensor *aqi) { aqi_ = aqi; } + + void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; } + void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; } + + void setup() override; + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + void send_env_data_(); + + enum ErrorCode { + NONE = 0, + COMMUNICATION_FAILED, + INVALID_ID, + VALIDITY_INVALID, + READ_FAILED, + WRITE_FAILED, + STD_OPMODE_FAILED, + } error_code_{NONE}; + + enum ValidityFlag { + NORMAL_OPERATION = 0, + WARMING_UP, + INITIAL_STARTUP, + INVALID_OUTPUT, + } validity_flag_; + + bool warming_up_{false}; + bool initial_startup_{false}; + + uint8_t firmware_ver_major_{0}; + uint8_t firmware_ver_minor_{0}; + uint8_t firmware_ver_build_{0}; + + sensor::Sensor *co2_{nullptr}; + sensor::Sensor *tvoc_{nullptr}; + sensor::Sensor *aqi_{nullptr}; + + sensor::Sensor *humidity_{nullptr}; + sensor::Sensor *temperature_{nullptr}; +}; + +} // namespace ens160 +} // namespace esphome diff --git a/esphome/components/ens160/sensor.py b/esphome/components/ens160/sensor.py new file mode 100644 index 0000000000..55f0ff7b6f --- /dev/null +++ b/esphome/components/ens160/sensor.py @@ -0,0 +1,88 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import i2c, sensor +from esphome.const import ( + CONF_ECO2, + CONF_HUMIDITY, + CONF_ID, + CONF_TEMPERATURE, + CONF_TVOC, + DEVICE_CLASS_AQI, + DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + ICON_CHEMICAL_WEAPON, + ICON_MOLECULE_CO2, + ICON_RADIATOR, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_BILLION, + UNIT_PARTS_PER_MILLION, +) + +CODEOWNERS = ["@vincentscode"] +DEPENDENCIES = ["i2c"] + +ens160_ns = cg.esphome_ns.namespace("ens160") +ENS160Component = ens160_ns.class_( + "ENS160Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor +) + +CONF_AQI = "aqi" +CONF_COMPENSATION = "compensation" +UNIT_INDEX = "index" + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ENS160Component), + cv.Required(CONF_ECO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Required(CONF_TVOC): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_BILLION, + icon=ICON_RADIATOR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Required(CONF_AQI): sensor.sensor_schema( + unit_of_measurement=UNIT_INDEX, + icon=ICON_CHEMICAL_WEAPON, + accuracy_decimals=0, + device_class=DEVICE_CLASS_AQI, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_COMPENSATION): cv.Schema( + { + cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor), + cv.Required(CONF_HUMIDITY): cv.use_id(sensor.Sensor), + } + ), + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x53)) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + + sens = await sensor.new_sensor(config[CONF_ECO2]) + cg.add(var.set_co2(sens)) + sens = await sensor.new_sensor(config[CONF_TVOC]) + cg.add(var.set_tvoc(sens)) + sens = await sensor.new_sensor(config[CONF_AQI]) + cg.add(var.set_aqi(sens)) + + if CONF_COMPENSATION in config: + compensation_config = config[CONF_COMPENSATION] + sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE]) + cg.add(var.set_temperature(sens)) + sens = await cg.get_variable(compensation_config[CONF_HUMIDITY]) + cg.add(var.set_humidity(sens)) diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index cda02f80c2..fd5e9377dd 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -3,23 +3,26 @@ from typing import Union, Optional from pathlib import Path import logging import os +import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, write_file_if_changed, mkdir_p from esphome.const import ( + CONF_ADVANCED, CONF_BOARD, CONF_COMPONENTS, + CONF_ESPHOME, CONF_FRAMEWORK, + CONF_IGNORE_EFUSE_MAC_CRC, CONF_NAME, + CONF_PATH, + CONF_PLATFORMIO_OPTIONS, + CONF_REF, + CONF_REFRESH, CONF_SOURCE, CONF_TYPE, + CONF_URL, CONF_VARIANT, CONF_VERSION, - CONF_ADVANCED, - CONF_REFRESH, - CONF_PATH, - CONF_URL, - CONF_REF, - CONF_IGNORE_EFUSE_MAC_CRC, KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_NAME, @@ -327,6 +330,32 @@ def _detect_variant(value): return value +def final_validate(config): + if CONF_PLATFORMIO_OPTIONS not in fv.full_config.get()[CONF_ESPHOME]: + return config + + pio_flash_size_key = "board_upload.flash_size" + pio_partitions_key = "board_build.partitions" + if ( + CONF_PARTITIONS in config + and pio_partitions_key + in fv.full_config.get()[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS] + ): + raise cv.Invalid( + f"Do not specify '{pio_partitions_key}' in '{CONF_PLATFORMIO_OPTIONS}' with '{CONF_PARTITIONS}' in esp32" + ) + + if ( + pio_flash_size_key + in fv.full_config.get()[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS] + ): + raise cv.Invalid( + f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" + ) + + return config + + CONF_PLATFORM_VERSION = "platform_version" ARDUINO_FRAMEWORK_SCHEMA = cv.All( @@ -386,10 +415,24 @@ FRAMEWORK_SCHEMA = cv.typed_schema( ) +FLASH_SIZES = [ + "2MB", + "4MB", + "8MB", + "16MB", + "32MB", +] + +CONF_FLASH_SIZE = "flash_size" +CONF_PARTITIONS = "partitions" CONFIG_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_BOARD): cv.string_strict, + cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of( + *FLASH_SIZES, upper=True + ), + cv.Optional(CONF_PARTITIONS): cv.file_, cv.Optional(CONF_VARIANT): cv.one_of(*VARIANTS, upper=True), cv.Optional(CONF_FRAMEWORK, default={}): FRAMEWORK_SCHEMA, } @@ -399,8 +442,12 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = cv.Schema(final_validate) + + async def to_code(config): cg.add_platformio_option("board", config[CONF_BOARD]) + cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) cg.add_build_flag("-DUSE_ESP32") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{config[CONF_VARIANT]}") @@ -450,7 +497,10 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False) - cg.add_platformio_option("board_build.partitions", "partitions.csv") + if CONF_PARTITIONS in config: + cg.add_platformio_option("board_build.partitions", config[CONF_PARTITIONS]) + else: + cg.add_platformio_option("board_build.partitions", "partitions.csv") for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) @@ -495,7 +545,10 @@ async def to_code(config): [f"platformio/framework-arduinoespressif32@{conf[CONF_SOURCE]}"], ) - cg.add_platformio_option("board_build.partitions", "partitions.csv") + if CONF_PARTITIONS in config: + cg.add_platformio_option("board_build.partitions", config[CONF_PARTITIONS]) + else: + cg.add_platformio_option("board_build.partitions", "partitions.csv") cg.add_define( "USE_ARDUINO_VERSION_CODE", @@ -505,24 +558,47 @@ async def to_code(config): ) -ARDUINO_PARTITIONS_CSV = """\ -nvs, data, nvs, 0x009000, 0x005000, -otadata, data, ota, 0x00e000, 0x002000, -app0, app, ota_0, 0x010000, 0x1C0000, -app1, app, ota_1, 0x1D0000, 0x1C0000, -eeprom, data, 0x99, 0x390000, 0x001000, -spiffs, data, spiffs, 0x391000, 0x00F000 +APP_PARTITION_SIZES = { + "2MB": 0x0C0000, # 768 KB + "4MB": 0x1C0000, # 1792 KB + "8MB": 0x3C0000, # 3840 KB + "16MB": 0x7C0000, # 7936 KB + "32MB": 0xFC0000, # 16128 KB +} + + +def get_arduino_partition_csv(flash_size): + app_partition_size = APP_PARTITION_SIZES[flash_size] + eeprom_partition_size = 0x1000 # 4 KB + spiffs_partition_size = 0xF000 # 60 KB + + app0_partition_start = 0x010000 # 64 KB + app1_partition_start = app0_partition_start + app_partition_size + eeprom_partition_start = app1_partition_start + app_partition_size + spiffs_partition_start = eeprom_partition_start + eeprom_partition_size + + partition_csv = f"""\ +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xE000, 0x2000, +app0, app, ota_0, 0x{app0_partition_start:X}, 0x{app_partition_size:X}, +app1, app, ota_1, 0x{app1_partition_start:X}, 0x{app_partition_size:X}, +eeprom, data, 0x99, 0x{eeprom_partition_start:X}, 0x{eeprom_partition_size:X}, +spiffs, data, spiffs, 0x{spiffs_partition_start:X}, 0x{spiffs_partition_size:X} """ + return partition_csv -IDF_PARTITIONS_CSV = """\ -# Name, Type, SubType, Offset, Size, Flags +def get_idf_partition_csv(flash_size): + app_partition_size = APP_PARTITION_SIZES[flash_size] + + partition_csv = f"""\ otadata, data, ota, , 0x2000, phy_init, data, phy, , 0x1000, -app0, app, ota_0, , 0x1C0000, -app1, app, ota_1, , 0x1C0000, -nvs, data, nvs, , 0x6d000, +app0, app, ota_0, , 0x{app_partition_size:X}, +app1, app, ota_1, , 0x{app_partition_size:X}, +nvs, data, nvs, , 0x6D000, """ + return partition_csv def _format_sdkconfig_val(value: SdkconfigValueType) -> str: @@ -565,13 +641,17 @@ def copy_files(): if CORE.using_arduino: write_file_if_changed( CORE.relative_build_path("partitions.csv"), - ARDUINO_PARTITIONS_CSV, + get_arduino_partition_csv( + CORE.platformio_options.get("board_upload.flash_size") + ), ) if CORE.using_esp_idf: _write_sdkconfig() write_file_if_changed( CORE.relative_build_path("partitions.csv"), - IDF_PARTITIONS_CSV, + get_idf_partition_csv( + CORE.platformio_options.get("board_upload.flash_size") + ), ) # IDF build scripts look for version string to put in the build. # However, if the build path does not have an initialized git repo, diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index 7e34dff22d..22a5f6b2c5 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -67,13 +67,13 @@ def validate_pillow_installed(value): except ImportError as err: raise cv.Invalid( "Please install the pillow python package to use this feature. " - '(pip install "pillow==10.0.1")' + '(pip install "pillow==10.1.0")' ) from err - if version.parse(PIL.__version__) != version.parse("10.0.1"): + if version.parse(PIL.__version__) != version.parse("10.1.0"): raise cv.Invalid( - "Please update your pillow installation to 10.0.1. " - '(pip install "pillow==10.0.1")' + "Please update your pillow installation to 10.1.0. " + '(pip install "pillow==10.1.0")' ) return value diff --git a/esphome/components/haier/climate.py b/esphome/components/haier/climate.py index d796f13581..49d42a231f 100644 --- a/esphome/components/haier/climate.py +++ b/esphome/components/haier/climate.py @@ -38,16 +38,20 @@ PROTOCOL_MIN_TEMPERATURE = 16.0 PROTOCOL_MAX_TEMPERATURE = 30.0 PROTOCOL_TARGET_TEMPERATURE_STEP = 1.0 PROTOCOL_CURRENT_TEMPERATURE_STEP = 0.5 +PROTOCOL_CONTROL_PACKET_SIZE = 10 CODEOWNERS = ["@paveldn"] AUTO_LOAD = ["sensor"] DEPENDENCIES = ["climate", "uart"] -CONF_WIFI_SIGNAL = "wifi_signal" +CONF_ALTERNATIVE_SWING_CONTROL = "alternative_swing_control" CONF_ANSWER_TIMEOUT = "answer_timeout" +CONF_CONTROL_METHOD = "control_method" +CONF_CONTROL_PACKET_SIZE = "control_packet_size" CONF_DISPLAY = "display" +CONF_HORIZONTAL_AIRFLOW = "horizontal_airflow" CONF_OUTDOOR_TEMPERATURE = "outdoor_temperature" CONF_VERTICAL_AIRFLOW = "vertical_airflow" -CONF_HORIZONTAL_AIRFLOW = "horizontal_airflow" +CONF_WIFI_SIGNAL = "wifi_signal" PROTOCOL_HON = "HON" PROTOCOL_SMARTAIR2 = "SMARTAIR2" @@ -107,6 +111,13 @@ SUPPORTED_CLIMATE_PRESETS_HON_OPTIONS = { "SLEEP": ClimatePreset.CLIMATE_PRESET_SLEEP, } +HonControlMethod = haier_ns.enum("HonControlMethod", True) +SUPPORTED_HON_CONTROL_METHODS = { + "MONITOR_ONLY": HonControlMethod.MONITOR_ONLY, + "SET_GROUP_PARAMETERS": HonControlMethod.SET_GROUP_PARAMETERS, + "SET_SINGLE_PARAMETER": HonControlMethod.SET_SINGLE_PARAMETER, +} + def validate_visual(config): if CONF_VISUAL in config: @@ -184,6 +195,9 @@ CONFIG_SCHEMA = cv.All( PROTOCOL_SMARTAIR2: BASE_CONFIG_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(Smartair2Climate), + cv.Optional( + CONF_ALTERNATIVE_SWING_CONTROL, default=False + ): cv.boolean, cv.Optional( CONF_SUPPORTED_PRESETS, default=list( @@ -197,7 +211,15 @@ CONFIG_SCHEMA = cv.All( PROTOCOL_HON: BASE_CONFIG_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(HonClimate), + cv.Optional( + CONF_CONTROL_METHOD, default="SET_GROUP_PARAMETERS" + ): cv.ensure_list( + cv.enum(SUPPORTED_HON_CONTROL_METHODS, upper=True) + ), cv.Optional(CONF_BEEPER, default=True): cv.boolean, + cv.Optional( + CONF_CONTROL_PACKET_SIZE, default=PROTOCOL_CONTROL_PACKET_SIZE + ): cv.int_range(min=PROTOCOL_CONTROL_PACKET_SIZE, max=50), cv.Optional( CONF_SUPPORTED_PRESETS, default=list(SUPPORTED_CLIMATE_PRESETS_HON_OPTIONS.keys()), @@ -408,6 +430,8 @@ async def to_code(config): await climate.register_climate(var, config) cg.add(var.set_send_wifi(config[CONF_WIFI_SIGNAL])) + if CONF_CONTROL_METHOD in config: + cg.add(var.set_control_method(config[CONF_CONTROL_METHOD])) if CONF_BEEPER in config: cg.add(var.set_beeper_state(config[CONF_BEEPER])) if CONF_DISPLAY in config: @@ -423,5 +447,15 @@ async def to_code(config): cg.add(var.set_supported_presets(config[CONF_SUPPORTED_PRESETS])) if CONF_ANSWER_TIMEOUT in config: cg.add(var.set_answer_timeout(config[CONF_ANSWER_TIMEOUT])) + if CONF_ALTERNATIVE_SWING_CONTROL in config: + cg.add( + var.set_alternative_swing_control(config[CONF_ALTERNATIVE_SWING_CONTROL]) + ) + if CONF_CONTROL_PACKET_SIZE in config: + cg.add( + var.set_extra_control_packet_bytes_size( + config[CONF_CONTROL_PACKET_SIZE] - PROTOCOL_CONTROL_PACKET_SIZE + ) + ) # https://github.com/paveldn/HaierProtocol - cg.add_library("pavlodn/HaierProtocol", "0.9.20") + cg.add_library("pavlodn/HaierProtocol", "0.9.24") diff --git a/esphome/components/haier/haier_base.cpp b/esphome/components/haier/haier_base.cpp index 22899b1a70..6943fc7d9c 100644 --- a/esphome/components/haier/haier_base.cpp +++ b/esphome/components/haier/haier_base.cpp @@ -19,56 +19,45 @@ constexpr size_t STATUS_REQUEST_INTERVAL_MS = 5000; constexpr size_t PROTOCOL_INITIALIZATION_INTERVAL = 10000; constexpr size_t DEFAULT_MESSAGES_INTERVAL_MS = 2000; constexpr size_t CONTROL_MESSAGES_INTERVAL_MS = 400; -constexpr size_t CONTROL_TIMEOUT_MS = 7000; -constexpr size_t NO_COMMAND = 0xFF; // Indicate that there is no command supplied -#if (HAIER_LOG_LEVEL > 4) -// To reduce size of binary this function only available when log level is Verbose const char *HaierClimateBase::phase_to_string_(ProtocolPhases phase) { static const char *phase_names[] = { "SENDING_INIT_1", - "WAITING_INIT_1_ANSWER", "SENDING_INIT_2", - "WAITING_INIT_2_ANSWER", "SENDING_FIRST_STATUS_REQUEST", - "WAITING_FIRST_STATUS_ANSWER", "SENDING_ALARM_STATUS_REQUEST", - "WAITING_ALARM_STATUS_ANSWER", "IDLE", - "UNKNOWN", "SENDING_STATUS_REQUEST", - "WAITING_STATUS_ANSWER", "SENDING_UPDATE_SIGNAL_REQUEST", - "WAITING_UPDATE_SIGNAL_ANSWER", "SENDING_SIGNAL_LEVEL", - "WAITING_SIGNAL_LEVEL_ANSWER", "SENDING_CONTROL", - "WAITING_CONTROL_ANSWER", - "SENDING_POWER_ON_COMMAND", - "WAITING_POWER_ON_ANSWER", - "SENDING_POWER_OFF_COMMAND", - "WAITING_POWER_OFF_ANSWER", + "SENDING_ACTION_COMMAND", "UNKNOWN" // Should be the last! }; + static_assert( + (sizeof(phase_names) / sizeof(char *)) == (((int) ProtocolPhases::NUM_PROTOCOL_PHASES) + 1), + "Wrong phase_names array size. Please, make sure that this array is aligned with the enum ProtocolPhases"); int phase_index = (int) phase; if ((phase_index > (int) ProtocolPhases::NUM_PROTOCOL_PHASES) || (phase_index < 0)) phase_index = (int) ProtocolPhases::NUM_PROTOCOL_PHASES; return phase_names[phase_index]; } -#endif + +bool check_timeout(std::chrono::steady_clock::time_point now, std::chrono::steady_clock::time_point tpoint, + size_t timeout) { + return std::chrono::duration_cast(now - tpoint).count() > timeout; +} HaierClimateBase::HaierClimateBase() : haier_protocol_(*this), protocol_phase_(ProtocolPhases::SENDING_INIT_1), - action_request_(ActionRequest::NO_ACTION), display_status_(true), health_mode_(false), force_send_control_(false), - forced_publish_(false), forced_request_status_(false), - first_control_attempt_(false), reset_protocol_request_(false), - send_wifi_signal_(true) { + send_wifi_signal_(true), + use_crc_(false) { this->traits_ = climate::ClimateTraits(); this->traits_.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_COOL, climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_FAN_ONLY, climate::CLIMATE_MODE_DRY, @@ -84,42 +73,43 @@ HaierClimateBase::~HaierClimateBase() {} void HaierClimateBase::set_phase(ProtocolPhases phase) { if (this->protocol_phase_ != phase) { -#if (HAIER_LOG_LEVEL > 4) ESP_LOGV(TAG, "Phase transition: %s => %s", phase_to_string_(this->protocol_phase_), phase_to_string_(phase)); -#else - ESP_LOGV(TAG, "Phase transition: %d => %d", (int) this->protocol_phase_, (int) phase); -#endif this->protocol_phase_ = phase; } } -bool HaierClimateBase::check_timeout_(std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::time_point tpoint, size_t timeout) { - return std::chrono::duration_cast(now - tpoint).count() > timeout; +void HaierClimateBase::reset_phase_() { + this->set_phase((this->protocol_phase_ >= ProtocolPhases::IDLE) ? ProtocolPhases::IDLE + : ProtocolPhases::SENDING_INIT_1); +} + +void HaierClimateBase::reset_to_idle_() { + this->force_send_control_ = false; + if (this->current_hvac_settings_.valid) + this->current_hvac_settings_.reset(); + this->forced_request_status_ = true; + this->set_phase(ProtocolPhases::IDLE); + this->action_request_.reset(); } bool HaierClimateBase::is_message_interval_exceeded_(std::chrono::steady_clock::time_point now) { - return this->check_timeout_(now, this->last_request_timestamp_, DEFAULT_MESSAGES_INTERVAL_MS); + return check_timeout(now, this->last_request_timestamp_, DEFAULT_MESSAGES_INTERVAL_MS); } bool HaierClimateBase::is_status_request_interval_exceeded_(std::chrono::steady_clock::time_point now) { - return this->check_timeout_(now, this->last_status_request_, STATUS_REQUEST_INTERVAL_MS); -} - -bool HaierClimateBase::is_control_message_timeout_exceeded_(std::chrono::steady_clock::time_point now) { - return this->check_timeout_(now, this->control_request_timestamp_, CONTROL_TIMEOUT_MS); + return check_timeout(now, this->last_status_request_, STATUS_REQUEST_INTERVAL_MS); } bool HaierClimateBase::is_control_message_interval_exceeded_(std::chrono::steady_clock::time_point now) { - return this->check_timeout_(now, this->last_request_timestamp_, CONTROL_MESSAGES_INTERVAL_MS); + return check_timeout(now, this->last_request_timestamp_, CONTROL_MESSAGES_INTERVAL_MS); } bool HaierClimateBase::is_protocol_initialisation_interval_exceeded_(std::chrono::steady_clock::time_point now) { - return this->check_timeout_(now, this->last_request_timestamp_, PROTOCOL_INITIALIZATION_INTERVAL); + return check_timeout(now, this->last_request_timestamp_, PROTOCOL_INITIALIZATION_INTERVAL); } #ifdef USE_WIFI -haier_protocol::HaierMessage HaierClimateBase::get_wifi_signal_message_(uint8_t message_type) { +haier_protocol::HaierMessage HaierClimateBase::get_wifi_signal_message_() { static uint8_t wifi_status_data[4] = {0x00, 0x00, 0x00, 0x00}; if (wifi::global_wifi_component->is_connected()) { wifi_status_data[1] = 0; @@ -131,7 +121,8 @@ haier_protocol::HaierMessage HaierClimateBase::get_wifi_signal_message_(uint8_t wifi_status_data[1] = 1; wifi_status_data[3] = 0; } - return haier_protocol::HaierMessage(message_type, wifi_status_data, sizeof(wifi_status_data)); + return haier_protocol::HaierMessage(haier_protocol::FrameType::REPORT_NETWORK_STATUS, wifi_status_data, + sizeof(wifi_status_data)); } #endif @@ -140,7 +131,7 @@ bool HaierClimateBase::get_display_state() const { return this->display_status_; void HaierClimateBase::set_display_state(bool state) { if (this->display_status_ != state) { this->display_status_ = state; - this->set_force_send_control_(true); + this->force_send_control_ = true; } } @@ -149,15 +140,24 @@ bool HaierClimateBase::get_health_mode() const { return this->health_mode_; } void HaierClimateBase::set_health_mode(bool state) { if (this->health_mode_ != state) { this->health_mode_ = state; - this->set_force_send_control_(true); + this->force_send_control_ = true; } } -void HaierClimateBase::send_power_on_command() { this->action_request_ = ActionRequest::TURN_POWER_ON; } +void HaierClimateBase::send_power_on_command() { + this->action_request_ = + PendingAction({ActionRequest::TURN_POWER_ON, esphome::optional()}); +} -void HaierClimateBase::send_power_off_command() { this->action_request_ = ActionRequest::TURN_POWER_OFF; } +void HaierClimateBase::send_power_off_command() { + this->action_request_ = + PendingAction({ActionRequest::TURN_POWER_OFF, esphome::optional()}); +} -void HaierClimateBase::toggle_power() { this->action_request_ = ActionRequest::TOGGLE_POWER; } +void HaierClimateBase::toggle_power() { + this->action_request_ = + PendingAction({ActionRequest::TOGGLE_POWER, esphome::optional()}); +} void HaierClimateBase::set_supported_swing_modes(const std::set &modes) { this->traits_.set_supported_swing_modes(modes); @@ -165,9 +165,7 @@ void HaierClimateBase::set_supported_swing_modes(const std::settraits_.add_supported_swing_mode(climate::CLIMATE_SWING_OFF); } -void HaierClimateBase::set_answer_timeout(uint32_t timeout) { - this->answer_timeout_ = std::chrono::milliseconds(timeout); -} +void HaierClimateBase::set_answer_timeout(uint32_t timeout) { this->haier_protocol_.set_answer_timeout(timeout); } void HaierClimateBase::set_supported_modes(const std::set &modes) { this->traits_.set_supported_modes(modes); @@ -183,29 +181,42 @@ void HaierClimateBase::set_supported_presets(const std::setsend_wifi_signal_ = send_wifi; } -haier_protocol::HandlerError HaierClimateBase::answer_preprocess_(uint8_t request_message_type, - uint8_t expected_request_message_type, - uint8_t answer_message_type, - uint8_t expected_answer_message_type, - ProtocolPhases expected_phase) { +void HaierClimateBase::send_custom_command(const haier_protocol::HaierMessage &message) { + this->action_request_ = PendingAction({ActionRequest::SEND_CUSTOM_COMMAND, message}); +} + +haier_protocol::HandlerError HaierClimateBase::answer_preprocess_( + haier_protocol::FrameType request_message_type, haier_protocol::FrameType expected_request_message_type, + haier_protocol::FrameType answer_message_type, haier_protocol::FrameType expected_answer_message_type, + ProtocolPhases expected_phase) { haier_protocol::HandlerError result = haier_protocol::HandlerError::HANDLER_OK; - if ((expected_request_message_type != NO_COMMAND) && (request_message_type != expected_request_message_type)) + if ((expected_request_message_type != haier_protocol::FrameType::UNKNOWN_FRAME_TYPE) && + (request_message_type != expected_request_message_type)) result = haier_protocol::HandlerError::UNSUPPORTED_MESSAGE; - if ((expected_answer_message_type != NO_COMMAND) && (answer_message_type != expected_answer_message_type)) + if ((expected_answer_message_type != haier_protocol::FrameType::UNKNOWN_FRAME_TYPE) && + (answer_message_type != expected_answer_message_type)) result = haier_protocol::HandlerError::UNSUPPORTED_MESSAGE; - if ((expected_phase != ProtocolPhases::UNKNOWN) && (expected_phase != this->protocol_phase_)) + if (!this->haier_protocol_.is_waiting_for_answer() || + ((expected_phase != ProtocolPhases::UNKNOWN) && (expected_phase != this->protocol_phase_))) result = haier_protocol::HandlerError::UNEXPECTED_MESSAGE; - if (is_message_invalid(answer_message_type)) + if (answer_message_type == haier_protocol::FrameType::INVALID) result = haier_protocol::HandlerError::INVALID_ANSWER; return result; } -haier_protocol::HandlerError HaierClimateBase::timeout_default_handler_(uint8_t request_type) { -#if (HAIER_LOG_LEVEL > 4) - ESP_LOGW(TAG, "Answer timeout for command %02X, phase %s", request_type, phase_to_string_(this->protocol_phase_)); -#else - ESP_LOGW(TAG, "Answer timeout for command %02X, phase %d", request_type, (int) this->protocol_phase_); -#endif +haier_protocol::HandlerError HaierClimateBase::report_network_status_answer_handler_( + haier_protocol::FrameType request_type, haier_protocol::FrameType message_type, const uint8_t *data, + size_t data_size) { + haier_protocol::HandlerError result = + this->answer_preprocess_(request_type, haier_protocol::FrameType::REPORT_NETWORK_STATUS, message_type, + haier_protocol::FrameType::CONFIRM, ProtocolPhases::SENDING_SIGNAL_LEVEL); + this->set_phase(ProtocolPhases::IDLE); + return result; +} + +haier_protocol::HandlerError HaierClimateBase::timeout_default_handler_(haier_protocol::FrameType request_type) { + ESP_LOGW(TAG, "Answer timeout for command %02X, phase %s", (uint8_t) request_type, + phase_to_string_(this->protocol_phase_)); if (this->protocol_phase_ > ProtocolPhases::IDLE) { this->set_phase(ProtocolPhases::IDLE); } else { @@ -219,79 +230,95 @@ void HaierClimateBase::setup() { // Set timestamp here to give AC time to boot this->last_request_timestamp_ = std::chrono::steady_clock::now(); this->set_phase(ProtocolPhases::SENDING_INIT_1); - this->set_handlers(); this->haier_protocol_.set_default_timeout_handler( std::bind(&esphome::haier::HaierClimateBase::timeout_default_handler_, this, std::placeholders::_1)); + this->set_handlers(); } void HaierClimateBase::dump_config() { LOG_CLIMATE("", "Haier Climate", this); - ESP_LOGCONFIG(TAG, " Device communication status: %s", - (this->protocol_phase_ >= ProtocolPhases::IDLE) ? "established" : "none"); + ESP_LOGCONFIG(TAG, " Device communication status: %s", this->valid_connection() ? "established" : "none"); } void HaierClimateBase::loop() { std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now(); if ((std::chrono::duration_cast(now - this->last_valid_status_timestamp_).count() > COMMUNICATION_TIMEOUT_MS) || - (this->reset_protocol_request_)) { + (this->reset_protocol_request_ && (!this->haier_protocol_.is_waiting_for_answer()))) { + this->last_valid_status_timestamp_ = now; if (this->protocol_phase_ >= ProtocolPhases::IDLE) { // No status too long, reseting protocol + // No need to reset protocol if we didn't pass initialization phase if (this->reset_protocol_request_) { this->reset_protocol_request_ = false; ESP_LOGW(TAG, "Protocol reset requested"); } else { ESP_LOGW(TAG, "Communication timeout, reseting protocol"); } - this->last_valid_status_timestamp_ = now; - this->set_force_send_control_(false); - if (this->hvac_settings_.valid) - this->hvac_settings_.reset(); - this->set_phase(ProtocolPhases::SENDING_INIT_1); + this->process_protocol_reset(); return; - } else { - // No need to reset protocol if we didn't pass initialization phase - this->last_valid_status_timestamp_ = now; } }; - if ((this->protocol_phase_ == ProtocolPhases::IDLE) || - (this->protocol_phase_ == ProtocolPhases::SENDING_STATUS_REQUEST) || - (this->protocol_phase_ == ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST) || - (this->protocol_phase_ == ProtocolPhases::SENDING_SIGNAL_LEVEL)) { + if ((!this->haier_protocol_.is_waiting_for_answer()) && + ((this->protocol_phase_ == ProtocolPhases::IDLE) || + (this->protocol_phase_ == ProtocolPhases::SENDING_STATUS_REQUEST) || + (this->protocol_phase_ == ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST) || + (this->protocol_phase_ == ProtocolPhases::SENDING_SIGNAL_LEVEL))) { // If control message or action is pending we should send it ASAP unless we are in initialisation // procedure or waiting for an answer - if (this->action_request_ != ActionRequest::NO_ACTION) { - this->process_pending_action(); - } else if (this->hvac_settings_.valid || this->force_send_control_) { + if (this->action_request_.has_value() && this->prepare_pending_action()) { + this->set_phase(ProtocolPhases::SENDING_ACTION_COMMAND); + } else if (this->next_hvac_settings_.valid || this->force_send_control_) { ESP_LOGV(TAG, "Control packet is pending..."); this->set_phase(ProtocolPhases::SENDING_CONTROL); + if (this->next_hvac_settings_.valid) { + this->current_hvac_settings_ = this->next_hvac_settings_; + this->next_hvac_settings_.reset(); + } else { + this->current_hvac_settings_.reset(); + } } } this->process_phase(now); this->haier_protocol_.loop(); } -void HaierClimateBase::process_pending_action() { - ActionRequest request = this->action_request_; - if (this->action_request_ == ActionRequest::TOGGLE_POWER) { - request = this->mode == CLIMATE_MODE_OFF ? ActionRequest::TURN_POWER_ON : ActionRequest::TURN_POWER_OFF; - } - switch (request) { - case ActionRequest::TURN_POWER_ON: - this->set_phase(ProtocolPhases::SENDING_POWER_ON_COMMAND); - break; - case ActionRequest::TURN_POWER_OFF: - this->set_phase(ProtocolPhases::SENDING_POWER_OFF_COMMAND); - break; - case ActionRequest::TOGGLE_POWER: - case ActionRequest::NO_ACTION: - // shouldn't get here, do nothing - break; - default: - ESP_LOGW(TAG, "Unsupported action: %d", (uint8_t) this->action_request_); - break; - } - this->action_request_ = ActionRequest::NO_ACTION; +void HaierClimateBase::process_protocol_reset() { + this->force_send_control_ = false; + if (this->current_hvac_settings_.valid) + this->current_hvac_settings_.reset(); + if (this->next_hvac_settings_.valid) + this->next_hvac_settings_.reset(); + this->mode = CLIMATE_MODE_OFF; + this->current_temperature = NAN; + this->target_temperature = NAN; + this->fan_mode.reset(); + this->preset.reset(); + this->publish_state(); + this->set_phase(ProtocolPhases::SENDING_INIT_1); +} + +bool HaierClimateBase::prepare_pending_action() { + if (this->action_request_.has_value()) { + switch (this->action_request_.value().action) { + case ActionRequest::SEND_CUSTOM_COMMAND: + return true; + case ActionRequest::TURN_POWER_ON: + this->action_request_.value().message = this->get_power_message(true); + return true; + case ActionRequest::TURN_POWER_OFF: + this->action_request_.value().message = this->get_power_message(false); + return true; + case ActionRequest::TOGGLE_POWER: + this->action_request_.value().message = this->get_power_message(this->mode == ClimateMode::CLIMATE_MODE_OFF); + return true; + default: + ESP_LOGW(TAG, "Unsupported action: %d", (uint8_t) this->action_request_.value().action); + this->action_request_.reset(); + return false; + } + } else + return false; } ClimateTraits HaierClimateBase::traits() { return traits_; } @@ -302,23 +329,22 @@ void HaierClimateBase::control(const ClimateCall &call) { ESP_LOGW(TAG, "Can't send control packet, first poll answer not received"); return; // cancel the control, we cant do it without a poll answer. } - if (this->hvac_settings_.valid) { - ESP_LOGW(TAG, "Overriding old valid settings before they were applied!"); + if (this->current_hvac_settings_.valid) { + ESP_LOGW(TAG, "New settings come faster then processed!"); } { if (call.get_mode().has_value()) - this->hvac_settings_.mode = call.get_mode(); + this->next_hvac_settings_.mode = call.get_mode(); if (call.get_fan_mode().has_value()) - this->hvac_settings_.fan_mode = call.get_fan_mode(); + this->next_hvac_settings_.fan_mode = call.get_fan_mode(); if (call.get_swing_mode().has_value()) - this->hvac_settings_.swing_mode = call.get_swing_mode(); + this->next_hvac_settings_.swing_mode = call.get_swing_mode(); if (call.get_target_temperature().has_value()) - this->hvac_settings_.target_temperature = call.get_target_temperature(); + this->next_hvac_settings_.target_temperature = call.get_target_temperature(); if (call.get_preset().has_value()) - this->hvac_settings_.preset = call.get_preset(); - this->hvac_settings_.valid = true; + this->next_hvac_settings_.preset = call.get_preset(); + this->next_hvac_settings_.valid = true; } - this->first_control_attempt_ = true; } void HaierClimateBase::HvacSettings::reset() { @@ -330,19 +356,9 @@ void HaierClimateBase::HvacSettings::reset() { this->preset.reset(); } -void HaierClimateBase::set_force_send_control_(bool status) { - this->force_send_control_ = status; - if (status) { - this->first_control_attempt_ = true; - } -} - -void HaierClimateBase::send_message_(const haier_protocol::HaierMessage &command, bool use_crc) { - if (this->answer_timeout_.has_value()) { - this->haier_protocol_.send_message(command, use_crc, this->answer_timeout_.value()); - } else { - this->haier_protocol_.send_message(command, use_crc); - } +void HaierClimateBase::send_message_(const haier_protocol::HaierMessage &command, bool use_crc, uint8_t num_repeats, + std::chrono::milliseconds interval) { + this->haier_protocol_.send_message(command, use_crc, num_repeats, interval); this->last_request_timestamp_ = std::chrono::steady_clock::now(); } diff --git a/esphome/components/haier/haier_base.h b/esphome/components/haier/haier_base.h index b2446d6fb5..75abbc20fb 100644 --- a/esphome/components/haier/haier_base.h +++ b/esphome/components/haier/haier_base.h @@ -11,7 +11,7 @@ namespace esphome { namespace haier { enum class ActionRequest : uint8_t { - NO_ACTION = 0, + SEND_CUSTOM_COMMAND = 0, TURN_POWER_ON = 1, TURN_POWER_OFF = 2, TOGGLE_POWER = 3, @@ -33,7 +33,6 @@ class HaierClimateBase : public esphome::Component, void control(const esphome::climate::ClimateCall &call) override; void dump_config() override; float get_setup_priority() const override { return esphome::setup_priority::HARDWARE; } - void set_fahrenheit(bool fahrenheit); void set_display_state(bool state); bool get_display_state() const; void set_health_mode(bool state); @@ -45,6 +44,7 @@ class HaierClimateBase : public esphome::Component, void set_supported_modes(const std::set &modes); void set_supported_swing_modes(const std::set &modes); void set_supported_presets(const std::set &presets); + bool valid_connection() { return this->protocol_phase_ >= ProtocolPhases::IDLE; }; size_t available() noexcept override { return esphome::uart::UARTDevice::available(); }; size_t read_array(uint8_t *data, size_t len) noexcept override { return esphome::uart::UARTDevice::read_array(data, len) ? len : 0; @@ -55,63 +55,56 @@ class HaierClimateBase : public esphome::Component, bool can_send_message() const { return haier_protocol_.get_outgoing_queue_size() == 0; }; void set_answer_timeout(uint32_t timeout); void set_send_wifi(bool send_wifi); + void send_custom_command(const haier_protocol::HaierMessage &message); protected: enum class ProtocolPhases { UNKNOWN = -1, // INITIALIZATION SENDING_INIT_1 = 0, - WAITING_INIT_1_ANSWER = 1, - SENDING_INIT_2 = 2, - WAITING_INIT_2_ANSWER = 3, - SENDING_FIRST_STATUS_REQUEST = 4, - WAITING_FIRST_STATUS_ANSWER = 5, - SENDING_ALARM_STATUS_REQUEST = 6, - WAITING_ALARM_STATUS_ANSWER = 7, + SENDING_INIT_2, + SENDING_FIRST_STATUS_REQUEST, + SENDING_ALARM_STATUS_REQUEST, // FUNCTIONAL STATE - IDLE = 8, - SENDING_STATUS_REQUEST = 10, - WAITING_STATUS_ANSWER = 11, - SENDING_UPDATE_SIGNAL_REQUEST = 12, - WAITING_UPDATE_SIGNAL_ANSWER = 13, - SENDING_SIGNAL_LEVEL = 14, - WAITING_SIGNAL_LEVEL_ANSWER = 15, - SENDING_CONTROL = 16, - WAITING_CONTROL_ANSWER = 17, - SENDING_POWER_ON_COMMAND = 18, - WAITING_POWER_ON_ANSWER = 19, - SENDING_POWER_OFF_COMMAND = 20, - WAITING_POWER_OFF_ANSWER = 21, + IDLE, + SENDING_STATUS_REQUEST, + SENDING_UPDATE_SIGNAL_REQUEST, + SENDING_SIGNAL_LEVEL, + SENDING_CONTROL, + SENDING_ACTION_COMMAND, NUM_PROTOCOL_PHASES }; -#if (HAIER_LOG_LEVEL > 4) const char *phase_to_string_(ProtocolPhases phase); -#endif virtual void set_handlers() = 0; virtual void process_phase(std::chrono::steady_clock::time_point now) = 0; virtual haier_protocol::HaierMessage get_control_message() = 0; - virtual bool is_message_invalid(uint8_t message_type) = 0; - virtual void process_pending_action(); + virtual haier_protocol::HaierMessage get_power_message(bool state) = 0; + virtual bool prepare_pending_action(); + virtual void process_protocol_reset(); esphome::climate::ClimateTraits traits() override; - // Answers handlers - haier_protocol::HandlerError answer_preprocess_(uint8_t request_message_type, uint8_t expected_request_message_type, - uint8_t answer_message_type, uint8_t expected_answer_message_type, + // Answer handlers + haier_protocol::HandlerError answer_preprocess_(haier_protocol::FrameType request_message_type, + haier_protocol::FrameType expected_request_message_type, + haier_protocol::FrameType answer_message_type, + haier_protocol::FrameType expected_answer_message_type, ProtocolPhases expected_phase); + haier_protocol::HandlerError report_network_status_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, + const uint8_t *data, size_t data_size); // Timeout handler - haier_protocol::HandlerError timeout_default_handler_(uint8_t request_type); + haier_protocol::HandlerError timeout_default_handler_(haier_protocol::FrameType request_type); // Helper functions - void set_force_send_control_(bool status); - void send_message_(const haier_protocol::HaierMessage &command, bool use_crc); + void send_message_(const haier_protocol::HaierMessage &command, bool use_crc, uint8_t num_repeats = 0, + std::chrono::milliseconds interval = std::chrono::milliseconds::zero()); virtual void set_phase(ProtocolPhases phase); - bool check_timeout_(std::chrono::steady_clock::time_point now, std::chrono::steady_clock::time_point tpoint, - size_t timeout); + void reset_phase_(); + void reset_to_idle_(); bool is_message_interval_exceeded_(std::chrono::steady_clock::time_point now); bool is_status_request_interval_exceeded_(std::chrono::steady_clock::time_point now); - bool is_control_message_timeout_exceeded_(std::chrono::steady_clock::time_point now); bool is_control_message_interval_exceeded_(std::chrono::steady_clock::time_point now); bool is_protocol_initialisation_interval_exceeded_(std::chrono::steady_clock::time_point now); #ifdef USE_WIFI - haier_protocol::HaierMessage get_wifi_signal_message_(uint8_t message_type); + haier_protocol::HaierMessage get_wifi_signal_message_(); #endif struct HvacSettings { @@ -122,29 +115,34 @@ class HaierClimateBase : public esphome::Component, esphome::optional preset; bool valid; HvacSettings() : valid(false){}; + HvacSettings(const HvacSettings &) = default; + HvacSettings &operator=(const HvacSettings &) = default; void reset(); }; + struct PendingAction { + ActionRequest action; + esphome::optional message; + }; haier_protocol::ProtocolHandler haier_protocol_; ProtocolPhases protocol_phase_; - ActionRequest action_request_; + esphome::optional action_request_; uint8_t fan_mode_speed_; uint8_t other_modes_fan_speed_; bool display_status_; bool health_mode_; bool force_send_control_; - bool forced_publish_; bool forced_request_status_; - bool first_control_attempt_; bool reset_protocol_request_; + bool send_wifi_signal_; + bool use_crc_; esphome::climate::ClimateTraits traits_; - HvacSettings hvac_settings_; + HvacSettings current_hvac_settings_; + HvacSettings next_hvac_settings_; + std::unique_ptr last_status_message_; std::chrono::steady_clock::time_point last_request_timestamp_; // For interval between messages std::chrono::steady_clock::time_point last_valid_status_timestamp_; // For protocol timeout std::chrono::steady_clock::time_point last_status_request_; // To request AC status - std::chrono::steady_clock::time_point control_request_timestamp_; // To send control message - optional answer_timeout_; // Message answer timeout - bool send_wifi_signal_; - std::chrono::steady_clock::time_point last_signal_request_; // To send WiFI signal level + std::chrono::steady_clock::time_point last_signal_request_; // To send WiFI signal level }; } // namespace haier diff --git a/esphome/components/haier/hon_climate.cpp b/esphome/components/haier/hon_climate.cpp index d4944410f7..09f90fffa8 100644 --- a/esphome/components/haier/hon_climate.cpp +++ b/esphome/components/haier/hon_climate.cpp @@ -14,6 +14,8 @@ namespace haier { static const char *const TAG = "haier.climate"; constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; constexpr int PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET = -64; +constexpr uint8_t CONTROL_MESSAGE_RETRIES = 5; +constexpr std::chrono::milliseconds CONTROL_MESSAGE_RETRIES_INTERVAL = std::chrono::milliseconds(500); hon_protocol::VerticalSwingMode get_vertical_swing_mode(AirflowVerticalDirection direction) { switch (direction) { @@ -48,14 +50,11 @@ hon_protocol::HorizontalSwingMode get_horizontal_swing_mode(AirflowHorizontalDir } HonClimate::HonClimate() - : last_status_message_(new uint8_t[sizeof(hon_protocol::HaierPacketControl)]), - cleaning_status_(CleaningState::NO_CLEANING), + : cleaning_status_(CleaningState::NO_CLEANING), got_valid_outdoor_temp_(false), - hvac_hardware_info_available_(false), - hvac_functions_{false, false, false, false, false}, - use_crc_(hvac_functions_[2]), active_alarms_{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, outdoor_sensor_(nullptr) { + last_status_message_ = std::unique_ptr(new uint8_t[sizeof(hon_protocol::HaierPacketControl)]); this->fan_mode_speed_ = (uint8_t) hon_protocol::FanMode::FAN_MID; this->other_modes_fan_speed_ = (uint8_t) hon_protocol::FanMode::FAN_AUTO; } @@ -72,14 +71,14 @@ AirflowVerticalDirection HonClimate::get_vertical_airflow() const { return this- void HonClimate::set_vertical_airflow(AirflowVerticalDirection direction) { this->vertical_direction_ = direction; - this->set_force_send_control_(true); + this->force_send_control_ = true; } AirflowHorizontalDirection HonClimate::get_horizontal_airflow() const { return this->horizontal_direction_; } void HonClimate::set_horizontal_airflow(AirflowHorizontalDirection direction) { this->horizontal_direction_ = direction; - this->set_force_send_control_(true); + this->force_send_control_ = true; } std::string HonClimate::get_cleaning_status_text() const { @@ -98,35 +97,35 @@ CleaningState HonClimate::get_cleaning_status() const { return this->cleaning_st void HonClimate::start_self_cleaning() { if (this->cleaning_status_ == CleaningState::NO_CLEANING) { ESP_LOGI(TAG, "Sending self cleaning start request"); - this->action_request_ = ActionRequest::START_SELF_CLEAN; - this->set_force_send_control_(true); + this->action_request_ = + PendingAction({ActionRequest::START_SELF_CLEAN, esphome::optional()}); } } void HonClimate::start_steri_cleaning() { if (this->cleaning_status_ == CleaningState::NO_CLEANING) { ESP_LOGI(TAG, "Sending steri cleaning start request"); - this->action_request_ = ActionRequest::START_STERI_CLEAN; - this->set_force_send_control_(true); + this->action_request_ = + PendingAction({ActionRequest::START_STERI_CLEAN, esphome::optional()}); } } -haier_protocol::HandlerError HonClimate::get_device_version_answer_handler_(uint8_t request_type, uint8_t message_type, +haier_protocol::HandlerError HonClimate::get_device_version_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size) { // Should check this before preprocess - if (message_type == (uint8_t) hon_protocol::FrameType::INVALID) { + if (message_type == haier_protocol::FrameType::INVALID) { ESP_LOGW(TAG, "It looks like your ESPHome Haier climate configuration is wrong. You should use the smartAir2 " "protocol instead of hOn"); this->set_phase(ProtocolPhases::SENDING_INIT_1); return haier_protocol::HandlerError::INVALID_ANSWER; } - haier_protocol::HandlerError result = this->answer_preprocess_( - request_type, (uint8_t) hon_protocol::FrameType::GET_DEVICE_VERSION, message_type, - (uint8_t) hon_protocol::FrameType::GET_DEVICE_VERSION_RESPONSE, ProtocolPhases::WAITING_INIT_1_ANSWER); + haier_protocol::HandlerError result = + this->answer_preprocess_(request_type, haier_protocol::FrameType::GET_DEVICE_VERSION, message_type, + haier_protocol::FrameType::GET_DEVICE_VERSION_RESPONSE, ProtocolPhases::SENDING_INIT_1); if (result == haier_protocol::HandlerError::HANDLER_OK) { if (data_size < sizeof(hon_protocol::DeviceVersionAnswer)) { // Wrong structure - this->set_phase(ProtocolPhases::SENDING_INIT_1); return haier_protocol::HandlerError::WRONG_MESSAGE_STRUCTURE; } // All OK @@ -134,54 +133,57 @@ haier_protocol::HandlerError HonClimate::get_device_version_answer_handler_(uint char tmp[9]; tmp[8] = 0; strncpy(tmp, answr->protocol_version, 8); - this->hvac_protocol_version_ = std::string(tmp); + this->hvac_hardware_info_ = HardwareInfo(); + this->hvac_hardware_info_.value().protocol_version_ = std::string(tmp); strncpy(tmp, answr->software_version, 8); - this->hvac_software_version_ = std::string(tmp); + this->hvac_hardware_info_.value().software_version_ = std::string(tmp); strncpy(tmp, answr->hardware_version, 8); - this->hvac_hardware_version_ = std::string(tmp); + this->hvac_hardware_info_.value().hardware_version_ = std::string(tmp); strncpy(tmp, answr->device_name, 8); - this->hvac_device_name_ = std::string(tmp); - this->hvac_functions_[0] = (answr->functions[1] & 0x01) != 0; // interactive mode support - this->hvac_functions_[1] = (answr->functions[1] & 0x02) != 0; // controller-device mode support - this->hvac_functions_[2] = (answr->functions[1] & 0x04) != 0; // crc support - this->hvac_functions_[3] = (answr->functions[1] & 0x08) != 0; // multiple AC support - this->hvac_functions_[4] = (answr->functions[1] & 0x20) != 0; // roles support - this->hvac_hardware_info_available_ = true; + this->hvac_hardware_info_.value().device_name_ = std::string(tmp); + this->hvac_hardware_info_.value().functions_[0] = (answr->functions[1] & 0x01) != 0; // interactive mode support + this->hvac_hardware_info_.value().functions_[1] = + (answr->functions[1] & 0x02) != 0; // controller-device mode support + this->hvac_hardware_info_.value().functions_[2] = (answr->functions[1] & 0x04) != 0; // crc support + this->hvac_hardware_info_.value().functions_[3] = (answr->functions[1] & 0x08) != 0; // multiple AC support + this->hvac_hardware_info_.value().functions_[4] = (answr->functions[1] & 0x20) != 0; // roles support + this->use_crc_ = this->hvac_hardware_info_.value().functions_[2]; this->set_phase(ProtocolPhases::SENDING_INIT_2); return result; } else { - this->set_phase((this->protocol_phase_ >= ProtocolPhases::IDLE) ? ProtocolPhases::IDLE - : ProtocolPhases::SENDING_INIT_1); + this->reset_phase_(); return result; } } -haier_protocol::HandlerError HonClimate::get_device_id_answer_handler_(uint8_t request_type, uint8_t message_type, +haier_protocol::HandlerError HonClimate::get_device_id_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size) { - haier_protocol::HandlerError result = this->answer_preprocess_( - request_type, (uint8_t) hon_protocol::FrameType::GET_DEVICE_ID, message_type, - (uint8_t) hon_protocol::FrameType::GET_DEVICE_ID_RESPONSE, ProtocolPhases::WAITING_INIT_2_ANSWER); + haier_protocol::HandlerError result = + this->answer_preprocess_(request_type, haier_protocol::FrameType::GET_DEVICE_ID, message_type, + haier_protocol::FrameType::GET_DEVICE_ID_RESPONSE, ProtocolPhases::SENDING_INIT_2); if (result == haier_protocol::HandlerError::HANDLER_OK) { this->set_phase(ProtocolPhases::SENDING_FIRST_STATUS_REQUEST); return result; } else { - this->set_phase((this->protocol_phase_ >= ProtocolPhases::IDLE) ? ProtocolPhases::IDLE - : ProtocolPhases::SENDING_INIT_1); + this->reset_phase_(); return result; } } -haier_protocol::HandlerError HonClimate::status_handler_(uint8_t request_type, uint8_t message_type, - const uint8_t *data, size_t data_size) { +haier_protocol::HandlerError HonClimate::status_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, + size_t data_size) { haier_protocol::HandlerError result = - this->answer_preprocess_(request_type, (uint8_t) hon_protocol::FrameType::CONTROL, message_type, - (uint8_t) hon_protocol::FrameType::STATUS, ProtocolPhases::UNKNOWN); + this->answer_preprocess_(request_type, haier_protocol::FrameType::CONTROL, message_type, + haier_protocol::FrameType::STATUS, ProtocolPhases::UNKNOWN); if (result == haier_protocol::HandlerError::HANDLER_OK) { result = this->process_status_message_(data, data_size); if (result != haier_protocol::HandlerError::HANDLER_OK) { ESP_LOGW(TAG, "Error %d while parsing Status packet", (int) result); - this->set_phase((this->protocol_phase_ >= ProtocolPhases::IDLE) ? ProtocolPhases::IDLE - : ProtocolPhases::SENDING_INIT_1); + this->reset_phase_(); + this->action_request_.reset(); + this->force_send_control_ = false; } else { if (data_size >= sizeof(hon_protocol::HaierPacketControl) + 2) { memcpy(this->last_status_message_.get(), data + 2, sizeof(hon_protocol::HaierPacketControl)); @@ -189,36 +191,48 @@ haier_protocol::HandlerError HonClimate::status_handler_(uint8_t request_type, u ESP_LOGW(TAG, "Status packet too small: %d (should be >= %d)", data_size, sizeof(hon_protocol::HaierPacketControl)); } - if (this->protocol_phase_ == ProtocolPhases::WAITING_FIRST_STATUS_ANSWER) { - ESP_LOGI(TAG, "First HVAC status received"); - this->set_phase(ProtocolPhases::SENDING_ALARM_STATUS_REQUEST); - } else if ((this->protocol_phase_ == ProtocolPhases::WAITING_STATUS_ANSWER) || - (this->protocol_phase_ == ProtocolPhases::WAITING_POWER_ON_ANSWER) || - (this->protocol_phase_ == ProtocolPhases::WAITING_POWER_OFF_ANSWER)) { - this->set_phase(ProtocolPhases::IDLE); - } else if (this->protocol_phase_ == ProtocolPhases::WAITING_CONTROL_ANSWER) { - this->set_phase(ProtocolPhases::IDLE); - this->set_force_send_control_(false); - if (this->hvac_settings_.valid) - this->hvac_settings_.reset(); + switch (this->protocol_phase_) { + case ProtocolPhases::SENDING_FIRST_STATUS_REQUEST: + ESP_LOGI(TAG, "First HVAC status received"); + this->set_phase(ProtocolPhases::SENDING_ALARM_STATUS_REQUEST); + break; + case ProtocolPhases::SENDING_ACTION_COMMAND: + // Do nothing, phase will be changed in process_phase + break; + case ProtocolPhases::SENDING_STATUS_REQUEST: + this->set_phase(ProtocolPhases::IDLE); + break; + case ProtocolPhases::SENDING_CONTROL: + if (!this->control_messages_queue_.empty()) + this->control_messages_queue_.pop(); + if (this->control_messages_queue_.empty()) { + this->set_phase(ProtocolPhases::IDLE); + this->force_send_control_ = false; + if (this->current_hvac_settings_.valid) + this->current_hvac_settings_.reset(); + } else { + this->set_phase(ProtocolPhases::SENDING_CONTROL); + } + break; + default: + break; } } return result; } else { - this->set_phase((this->protocol_phase_ >= ProtocolPhases::IDLE) ? ProtocolPhases::IDLE - : ProtocolPhases::SENDING_INIT_1); + this->action_request_.reset(); + this->force_send_control_ = false; + this->reset_phase_(); return result; } } -haier_protocol::HandlerError HonClimate::get_management_information_answer_handler_(uint8_t request_type, - uint8_t message_type, - const uint8_t *data, - size_t data_size) { - haier_protocol::HandlerError result = - this->answer_preprocess_(request_type, (uint8_t) hon_protocol::FrameType::GET_MANAGEMENT_INFORMATION, - message_type, (uint8_t) hon_protocol::FrameType::GET_MANAGEMENT_INFORMATION_RESPONSE, - ProtocolPhases::WAITING_UPDATE_SIGNAL_ANSWER); +haier_protocol::HandlerError HonClimate::get_management_information_answer_handler_( + haier_protocol::FrameType request_type, haier_protocol::FrameType message_type, const uint8_t *data, + size_t data_size) { + haier_protocol::HandlerError result = this->answer_preprocess_( + request_type, haier_protocol::FrameType::GET_MANAGEMENT_INFORMATION, message_type, + haier_protocol::FrameType::GET_MANAGEMENT_INFORMATION_RESPONSE, ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST); if (result == haier_protocol::HandlerError::HANDLER_OK) { this->set_phase(ProtocolPhases::SENDING_SIGNAL_LEVEL); return result; @@ -228,25 +242,16 @@ haier_protocol::HandlerError HonClimate::get_management_information_answer_handl } } -haier_protocol::HandlerError HonClimate::report_network_status_answer_handler_(uint8_t request_type, - uint8_t message_type, - const uint8_t *data, size_t data_size) { - haier_protocol::HandlerError result = - this->answer_preprocess_(request_type, (uint8_t) hon_protocol::FrameType::REPORT_NETWORK_STATUS, message_type, - (uint8_t) hon_protocol::FrameType::CONFIRM, ProtocolPhases::WAITING_SIGNAL_LEVEL_ANSWER); - this->set_phase(ProtocolPhases::IDLE); - return result; -} - -haier_protocol::HandlerError HonClimate::get_alarm_status_answer_handler_(uint8_t request_type, uint8_t message_type, +haier_protocol::HandlerError HonClimate::get_alarm_status_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size) { - if (request_type == (uint8_t) hon_protocol::FrameType::GET_ALARM_STATUS) { - if (message_type != (uint8_t) hon_protocol::FrameType::GET_ALARM_STATUS_RESPONSE) { + if (request_type == haier_protocol::FrameType::GET_ALARM_STATUS) { + if (message_type != haier_protocol::FrameType::GET_ALARM_STATUS_RESPONSE) { // Unexpected answer to request this->set_phase(ProtocolPhases::IDLE); return haier_protocol::HandlerError::UNSUPPORTED_MESSAGE; } - if (this->protocol_phase_ != ProtocolPhases::WAITING_ALARM_STATUS_ANSWER) { + if (this->protocol_phase_ != ProtocolPhases::SENDING_ALARM_STATUS_REQUEST) { // Don't expect this answer now this->set_phase(ProtocolPhases::IDLE); return haier_protocol::HandlerError::UNEXPECTED_MESSAGE; @@ -263,27 +268,27 @@ haier_protocol::HandlerError HonClimate::get_alarm_status_answer_handler_(uint8_ void HonClimate::set_handlers() { // Set handlers this->haier_protocol_.set_answer_handler( - (uint8_t) (hon_protocol::FrameType::GET_DEVICE_VERSION), + haier_protocol::FrameType::GET_DEVICE_VERSION, std::bind(&HonClimate::get_device_version_answer_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); this->haier_protocol_.set_answer_handler( - (uint8_t) (hon_protocol::FrameType::GET_DEVICE_ID), + haier_protocol::FrameType::GET_DEVICE_ID, std::bind(&HonClimate::get_device_id_answer_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); this->haier_protocol_.set_answer_handler( - (uint8_t) (hon_protocol::FrameType::CONTROL), + haier_protocol::FrameType::CONTROL, std::bind(&HonClimate::status_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); this->haier_protocol_.set_answer_handler( - (uint8_t) (hon_protocol::FrameType::GET_MANAGEMENT_INFORMATION), + haier_protocol::FrameType::GET_MANAGEMENT_INFORMATION, std::bind(&HonClimate::get_management_information_answer_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); this->haier_protocol_.set_answer_handler( - (uint8_t) (hon_protocol::FrameType::GET_ALARM_STATUS), + haier_protocol::FrameType::GET_ALARM_STATUS, std::bind(&HonClimate::get_alarm_status_answer_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); this->haier_protocol_.set_answer_handler( - (uint8_t) (hon_protocol::FrameType::REPORT_NETWORK_STATUS), + haier_protocol::FrameType::REPORT_NETWORK_STATUS, std::bind(&HonClimate::report_network_status_answer_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); } @@ -291,14 +296,18 @@ void HonClimate::set_handlers() { void HonClimate::dump_config() { HaierClimateBase::dump_config(); ESP_LOGCONFIG(TAG, " Protocol version: hOn"); - if (this->hvac_hardware_info_available_) { - ESP_LOGCONFIG(TAG, " Device protocol version: %s", this->hvac_protocol_version_.c_str()); - ESP_LOGCONFIG(TAG, " Device software version: %s", this->hvac_software_version_.c_str()); - ESP_LOGCONFIG(TAG, " Device hardware version: %s", this->hvac_hardware_version_.c_str()); - ESP_LOGCONFIG(TAG, " Device name: %s", this->hvac_device_name_.c_str()); - ESP_LOGCONFIG(TAG, " Device features:%s%s%s%s%s", (this->hvac_functions_[0] ? " interactive" : ""), - (this->hvac_functions_[1] ? " controller-device" : ""), (this->hvac_functions_[2] ? " crc" : ""), - (this->hvac_functions_[3] ? " multinode" : ""), (this->hvac_functions_[4] ? " role" : "")); + ESP_LOGCONFIG(TAG, " Control method: %d", (uint8_t) this->control_method_); + if (this->hvac_hardware_info_.has_value()) { + ESP_LOGCONFIG(TAG, " Device protocol version: %s", this->hvac_hardware_info_.value().protocol_version_.c_str()); + ESP_LOGCONFIG(TAG, " Device software version: %s", this->hvac_hardware_info_.value().software_version_.c_str()); + ESP_LOGCONFIG(TAG, " Device hardware version: %s", this->hvac_hardware_info_.value().hardware_version_.c_str()); + ESP_LOGCONFIG(TAG, " Device name: %s", this->hvac_hardware_info_.value().device_name_.c_str()); + ESP_LOGCONFIG(TAG, " Device features:%s%s%s%s%s", + (this->hvac_hardware_info_.value().functions_[0] ? " interactive" : ""), + (this->hvac_hardware_info_.value().functions_[1] ? " controller-device" : ""), + (this->hvac_hardware_info_.value().functions_[2] ? " crc" : ""), + (this->hvac_hardware_info_.value().functions_[3] ? " multinode" : ""), + (this->hvac_hardware_info_.value().functions_[4] ? " role" : "")); ESP_LOGCONFIG(TAG, " Active alarms: %s", buf_to_hex(this->active_alarms_, sizeof(this->active_alarms_)).c_str()); } } @@ -307,7 +316,6 @@ void HonClimate::process_phase(std::chrono::steady_clock::time_point now) { switch (this->protocol_phase_) { case ProtocolPhases::SENDING_INIT_1: if (this->can_send_message() && this->is_protocol_initialisation_interval_exceeded_(now)) { - this->hvac_hardware_info_available_ = false; // Indicate device capabilities: // bit 0 - if 1 module support interactive mode // bit 1 - if 1 module support controller-device mode @@ -316,109 +324,95 @@ void HonClimate::process_phase(std::chrono::steady_clock::time_point now) { // bit 4..bit 15 - not used uint8_t module_capabilities[2] = {0b00000000, 0b00000111}; static const haier_protocol::HaierMessage DEVICE_VERSION_REQUEST( - (uint8_t) hon_protocol::FrameType::GET_DEVICE_VERSION, module_capabilities, sizeof(module_capabilities)); + haier_protocol::FrameType::GET_DEVICE_VERSION, module_capabilities, sizeof(module_capabilities)); this->send_message_(DEVICE_VERSION_REQUEST, this->use_crc_); - this->set_phase(ProtocolPhases::WAITING_INIT_1_ANSWER); } break; case ProtocolPhases::SENDING_INIT_2: if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { - static const haier_protocol::HaierMessage DEVICEID_REQUEST((uint8_t) hon_protocol::FrameType::GET_DEVICE_ID); + static const haier_protocol::HaierMessage DEVICEID_REQUEST(haier_protocol::FrameType::GET_DEVICE_ID); this->send_message_(DEVICEID_REQUEST, this->use_crc_); - this->set_phase(ProtocolPhases::WAITING_INIT_2_ANSWER); } break; case ProtocolPhases::SENDING_FIRST_STATUS_REQUEST: case ProtocolPhases::SENDING_STATUS_REQUEST: if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { static const haier_protocol::HaierMessage STATUS_REQUEST( - (uint8_t) hon_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::GET_USER_DATA); + haier_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::GET_USER_DATA); this->send_message_(STATUS_REQUEST, this->use_crc_); this->last_status_request_ = now; - this->set_phase((ProtocolPhases) ((uint8_t) this->protocol_phase_ + 1)); } break; #ifdef USE_WIFI case ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST: if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { static const haier_protocol::HaierMessage UPDATE_SIGNAL_REQUEST( - (uint8_t) hon_protocol::FrameType::GET_MANAGEMENT_INFORMATION); + haier_protocol::FrameType::GET_MANAGEMENT_INFORMATION); this->send_message_(UPDATE_SIGNAL_REQUEST, this->use_crc_); this->last_signal_request_ = now; - this->set_phase(ProtocolPhases::WAITING_UPDATE_SIGNAL_ANSWER); } break; case ProtocolPhases::SENDING_SIGNAL_LEVEL: if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { - this->send_message_(this->get_wifi_signal_message_((uint8_t) hon_protocol::FrameType::REPORT_NETWORK_STATUS), - this->use_crc_); - this->set_phase(ProtocolPhases::WAITING_SIGNAL_LEVEL_ANSWER); + this->send_message_(this->get_wifi_signal_message_(), this->use_crc_); } break; - case ProtocolPhases::WAITING_UPDATE_SIGNAL_ANSWER: - case ProtocolPhases::WAITING_SIGNAL_LEVEL_ANSWER: - break; #else case ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST: case ProtocolPhases::SENDING_SIGNAL_LEVEL: - case ProtocolPhases::WAITING_UPDATE_SIGNAL_ANSWER: - case ProtocolPhases::WAITING_SIGNAL_LEVEL_ANSWER: this->set_phase(ProtocolPhases::IDLE); break; #endif case ProtocolPhases::SENDING_ALARM_STATUS_REQUEST: if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { - static const haier_protocol::HaierMessage ALARM_STATUS_REQUEST( - (uint8_t) hon_protocol::FrameType::GET_ALARM_STATUS); + static const haier_protocol::HaierMessage ALARM_STATUS_REQUEST(haier_protocol::FrameType::GET_ALARM_STATUS); this->send_message_(ALARM_STATUS_REQUEST, this->use_crc_); - this->set_phase(ProtocolPhases::WAITING_ALARM_STATUS_ANSWER); } break; case ProtocolPhases::SENDING_CONTROL: - if (this->first_control_attempt_) { - this->control_request_timestamp_ = now; - this->first_control_attempt_ = false; + if (this->control_messages_queue_.empty()) { + switch (this->control_method_) { + case HonControlMethod::SET_GROUP_PARAMETERS: { + haier_protocol::HaierMessage control_message = this->get_control_message(); + this->control_messages_queue_.push(control_message); + } break; + case HonControlMethod::SET_SINGLE_PARAMETER: + this->fill_control_messages_queue_(); + break; + case HonControlMethod::MONITOR_ONLY: + ESP_LOGI(TAG, "AC control is disabled, monitor only"); + this->reset_to_idle_(); + return; + default: + ESP_LOGW(TAG, "Unsupported control method for hOn protocol!"); + this->reset_to_idle_(); + return; + } } - if (this->is_control_message_timeout_exceeded_(now)) { - ESP_LOGW(TAG, "Sending control packet timeout!"); - this->set_force_send_control_(false); - if (this->hvac_settings_.valid) - this->hvac_settings_.reset(); - this->forced_request_status_ = true; - this->forced_publish_ = true; - this->set_phase(ProtocolPhases::IDLE); + if (this->control_messages_queue_.empty()) { + ESP_LOGW(TAG, "Control message queue is empty!"); + this->reset_to_idle_(); } else if (this->can_send_message() && this->is_control_message_interval_exceeded_(now)) { - haier_protocol::HaierMessage control_message = get_control_message(); - this->send_message_(control_message, this->use_crc_); - ESP_LOGI(TAG, "Control packet sent"); - this->set_phase(ProtocolPhases::WAITING_CONTROL_ANSWER); + ESP_LOGI(TAG, "Sending control packet, queue size %d", this->control_messages_queue_.size()); + this->send_message_(this->control_messages_queue_.front(), this->use_crc_, CONTROL_MESSAGE_RETRIES, + CONTROL_MESSAGE_RETRIES_INTERVAL); } break; - case ProtocolPhases::SENDING_POWER_ON_COMMAND: - case ProtocolPhases::SENDING_POWER_OFF_COMMAND: - if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { - uint8_t pwr_cmd_buf[2] = {0x00, 0x00}; - if (this->protocol_phase_ == ProtocolPhases::SENDING_POWER_ON_COMMAND) - pwr_cmd_buf[1] = 0x01; - haier_protocol::HaierMessage power_cmd((uint8_t) hon_protocol::FrameType::CONTROL, - ((uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER) + 1, - pwr_cmd_buf, sizeof(pwr_cmd_buf)); - this->send_message_(power_cmd, this->use_crc_); - this->set_phase(this->protocol_phase_ == ProtocolPhases::SENDING_POWER_ON_COMMAND - ? ProtocolPhases::WAITING_POWER_ON_ANSWER - : ProtocolPhases::WAITING_POWER_OFF_ANSWER); + case ProtocolPhases::SENDING_ACTION_COMMAND: + if (this->action_request_.has_value()) { + if (this->action_request_.value().message.has_value()) { + this->send_message_(this->action_request_.value().message.value(), this->use_crc_); + this->action_request_.value().message.reset(); + } else { + // Message already sent, reseting request and return to idle + this->action_request_.reset(); + this->set_phase(ProtocolPhases::IDLE); + } + } else { + ESP_LOGW(TAG, "SENDING_ACTION_COMMAND phase without action request!"); + this->set_phase(ProtocolPhases::IDLE); } break; - - case ProtocolPhases::WAITING_INIT_1_ANSWER: - case ProtocolPhases::WAITING_INIT_2_ANSWER: - case ProtocolPhases::WAITING_FIRST_STATUS_ANSWER: - case ProtocolPhases::WAITING_ALARM_STATUS_ANSWER: - case ProtocolPhases::WAITING_STATUS_ANSWER: - case ProtocolPhases::WAITING_CONTROL_ANSWER: - case ProtocolPhases::WAITING_POWER_ON_ANSWER: - case ProtocolPhases::WAITING_POWER_OFF_ANSWER: - break; case ProtocolPhases::IDLE: { if (this->forced_request_status_ || this->is_status_request_interval_exceeded_(now)) { this->set_phase(ProtocolPhases::SENDING_STATUS_REQUEST); @@ -433,26 +427,35 @@ void HonClimate::process_phase(std::chrono::steady_clock::time_point now) { } break; default: // Shouldn't get here -#if (HAIER_LOG_LEVEL > 4) ESP_LOGE(TAG, "Wrong protocol handler state: %s (%d), resetting communication", phase_to_string_(this->protocol_phase_), (int) this->protocol_phase_); -#else - ESP_LOGE(TAG, "Wrong protocol handler state: %d, resetting communication", (int) this->protocol_phase_); -#endif this->set_phase(ProtocolPhases::SENDING_INIT_1); break; } } +haier_protocol::HaierMessage HonClimate::get_power_message(bool state) { + if (state) { + static haier_protocol::HaierMessage power_on_message( + haier_protocol::FrameType::CONTROL, ((uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER) + 1, + std::initializer_list({0x00, 0x01}).begin(), 2); + return power_on_message; + } else { + static haier_protocol::HaierMessage power_off_message( + haier_protocol::FrameType::CONTROL, ((uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER) + 1, + std::initializer_list({0x00, 0x00}).begin(), 2); + return power_off_message; + } +} + haier_protocol::HaierMessage HonClimate::get_control_message() { uint8_t control_out_buffer[sizeof(hon_protocol::HaierPacketControl)]; memcpy(control_out_buffer, this->last_status_message_.get(), sizeof(hon_protocol::HaierPacketControl)); hon_protocol::HaierPacketControl *out_data = (hon_protocol::HaierPacketControl *) control_out_buffer; bool has_hvac_settings = false; - if (this->hvac_settings_.valid) { + if (this->current_hvac_settings_.valid) { has_hvac_settings = true; - HvacSettings climate_control; - climate_control = this->hvac_settings_; + HvacSettings &climate_control = this->current_hvac_settings_; if (climate_control.mode.has_value()) { switch (climate_control.mode.value()) { case CLIMATE_MODE_OFF: @@ -535,7 +538,7 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { } if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); - out_data->set_point = ((int) target_temp) - 16; // set the temperature at our offset, subtract 16. + out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; } if (out_data->ac_power == 0) { @@ -587,50 +590,28 @@ haier_protocol::HaierMessage HonClimate::get_control_message() { control_out_buffer[4] = 0; // This byte should be cleared before setting values out_data->display_status = this->display_status_ ? 1 : 0; out_data->health_mode = this->health_mode_ ? 1 : 0; - switch (this->action_request_) { - case ActionRequest::START_SELF_CLEAN: - this->action_request_ = ActionRequest::NO_ACTION; - out_data->self_cleaning_status = 1; - out_data->steri_clean = 0; - out_data->set_point = 0x06; - out_data->vertical_swing_mode = (uint8_t) hon_protocol::VerticalSwingMode::CENTER; - out_data->horizontal_swing_mode = (uint8_t) hon_protocol::HorizontalSwingMode::CENTER; - out_data->ac_power = 1; - out_data->ac_mode = (uint8_t) hon_protocol::ConditioningMode::DRY; - out_data->light_status = 0; - break; - case ActionRequest::START_STERI_CLEAN: - this->action_request_ = ActionRequest::NO_ACTION; - out_data->self_cleaning_status = 0; - out_data->steri_clean = 1; - out_data->set_point = 0x06; - out_data->vertical_swing_mode = (uint8_t) hon_protocol::VerticalSwingMode::CENTER; - out_data->horizontal_swing_mode = (uint8_t) hon_protocol::HorizontalSwingMode::CENTER; - out_data->ac_power = 1; - out_data->ac_mode = (uint8_t) hon_protocol::ConditioningMode::DRY; - out_data->light_status = 0; - break; - default: - // No change - break; - } - return haier_protocol::HaierMessage((uint8_t) hon_protocol::FrameType::CONTROL, + return haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::SET_GROUP_PARAMETERS, control_out_buffer, sizeof(hon_protocol::HaierPacketControl)); } haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t *packet_buffer, uint8_t size) { - if (size < sizeof(hon_protocol::HaierStatus)) + if (size < hon_protocol::HAIER_STATUS_FRAME_SIZE + this->extra_control_packet_bytes_) return haier_protocol::HandlerError::WRONG_MESSAGE_STRUCTURE; - hon_protocol::HaierStatus packet; - if (size < sizeof(hon_protocol::HaierStatus)) - size = sizeof(hon_protocol::HaierStatus); - memcpy(&packet, packet_buffer, size); + struct { + hon_protocol::HaierPacketControl control; + hon_protocol::HaierPacketSensors sensors; + } packet; + memcpy(&packet.control, packet_buffer + 2, sizeof(hon_protocol::HaierPacketControl)); + memcpy(&packet.sensors, + packet_buffer + 2 + sizeof(hon_protocol::HaierPacketControl) + this->extra_control_packet_bytes_, + sizeof(hon_protocol::HaierPacketSensors)); if (packet.sensors.error_status != 0) { ESP_LOGW(TAG, "HVAC error, code=0x%02X", packet.sensors.error_status); } - if ((this->outdoor_sensor_ != nullptr) && (got_valid_outdoor_temp_ || (packet.sensors.outdoor_temperature > 0))) { - got_valid_outdoor_temp_ = true; + if ((this->outdoor_sensor_ != nullptr) && + (this->got_valid_outdoor_temp_ || (packet.sensors.outdoor_temperature > 0))) { + this->got_valid_outdoor_temp_ = true; float otemp = (float) (packet.sensors.outdoor_temperature + PROTOCOL_OUTDOOR_TEMPERATURE_OFFSET); if ((!this->outdoor_sensor_->has_state()) || (this->outdoor_sensor_->get_raw_state() != otemp)) this->outdoor_sensor_->publish_state(otemp); @@ -703,7 +684,7 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * // Do something only if display status changed if (this->mode == CLIMATE_MODE_OFF) { // AC just turned on from remote need to turn off display - this->set_force_send_control_(true); + this->force_send_control_ = true; } else { this->display_status_ = disp_status; } @@ -732,7 +713,8 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * ESP_LOGD(TAG, "Cleaning status change: %d => %d", (uint8_t) this->cleaning_status_, (uint8_t) new_cleaning); if (new_cleaning == CleaningState::NO_CLEANING) { // Turning AC off after cleaning - this->action_request_ = ActionRequest::TURN_POWER_OFF; + this->action_request_ = + PendingAction({ActionRequest::TURN_POWER_OFF, esphome::optional()}); } this->cleaning_status_ = new_cleaning; } @@ -783,51 +765,257 @@ haier_protocol::HandlerError HonClimate::process_status_message_(const uint8_t * should_publish = should_publish || (old_swing_mode != this->swing_mode); } this->last_valid_status_timestamp_ = std::chrono::steady_clock::now(); - if (this->forced_publish_ || should_publish) { -#if (HAIER_LOG_LEVEL > 4) - std::chrono::high_resolution_clock::time_point _publish_start = std::chrono::high_resolution_clock::now(); -#endif + if (should_publish) { this->publish_state(); -#if (HAIER_LOG_LEVEL > 4) - ESP_LOGV(TAG, "Publish delay: %lld ms", - std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - - _publish_start) - .count()); -#endif - this->forced_publish_ = false; } if (should_publish) { ESP_LOGI(TAG, "HVAC values changed"); } - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "HVAC Mode = 0x%X", packet.control.ac_mode); - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "Fan speed Status = 0x%X", packet.control.fan_mode); - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "Horizontal Swing Status = 0x%X", packet.control.horizontal_swing_mode); - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "Vertical Swing Status = 0x%X", packet.control.vertical_swing_mode); - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "Set Point Status = 0x%X", packet.control.set_point); + int log_level = should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG; + esp_log_printf_(log_level, TAG, __LINE__, "HVAC Mode = 0x%X", packet.control.ac_mode); + esp_log_printf_(log_level, TAG, __LINE__, "Fan speed Status = 0x%X", packet.control.fan_mode); + esp_log_printf_(log_level, TAG, __LINE__, "Horizontal Swing Status = 0x%X", packet.control.horizontal_swing_mode); + esp_log_printf_(log_level, TAG, __LINE__, "Vertical Swing Status = 0x%X", packet.control.vertical_swing_mode); + esp_log_printf_(log_level, TAG, __LINE__, "Set Point Status = 0x%X", packet.control.set_point); return haier_protocol::HandlerError::HANDLER_OK; } -bool HonClimate::is_message_invalid(uint8_t message_type) { - return message_type == (uint8_t) hon_protocol::FrameType::INVALID; +void HonClimate::fill_control_messages_queue_() { + static uint8_t one_buf[] = {0x00, 0x01}; + static uint8_t zero_buf[] = {0x00, 0x00}; + if (!this->current_hvac_settings_.valid && !this->force_send_control_) + return; + this->clear_control_messages_queue_(); + HvacSettings climate_control; + climate_control = this->current_hvac_settings_; + // Beeper command + { + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::BEEPER_STATUS, + this->beeper_status_ ? zero_buf : one_buf, 2)); + } + // Health mode + { + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::HEALTH_MODE, + this->health_mode_ ? one_buf : zero_buf, 2)); + } + // Climate mode + bool new_power = this->mode != CLIMATE_MODE_OFF; + uint8_t fan_mode_buf[] = {0x00, 0xFF}; + uint8_t quiet_mode_buf[] = {0x00, 0xFF}; + if (climate_control.mode.has_value()) { + uint8_t buffer[2] = {0x00, 0x00}; + switch (climate_control.mode.value()) { + case CLIMATE_MODE_OFF: + new_power = false; + break; + case CLIMATE_MODE_HEAT_COOL: + new_power = true; + buffer[1] = (uint8_t) hon_protocol::ConditioningMode::AUTO; + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::AC_MODE, + buffer, 2)); + fan_mode_buf[1] = this->other_modes_fan_speed_; + break; + case CLIMATE_MODE_HEAT: + new_power = true; + buffer[1] = (uint8_t) hon_protocol::ConditioningMode::HEAT; + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::AC_MODE, + buffer, 2)); + fan_mode_buf[1] = this->other_modes_fan_speed_; + break; + case CLIMATE_MODE_DRY: + new_power = true; + buffer[1] = (uint8_t) hon_protocol::ConditioningMode::DRY; + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::AC_MODE, + buffer, 2)); + fan_mode_buf[1] = this->other_modes_fan_speed_; + break; + case CLIMATE_MODE_FAN_ONLY: + new_power = true; + buffer[1] = (uint8_t) hon_protocol::ConditioningMode::FAN; + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::AC_MODE, + buffer, 2)); + fan_mode_buf[1] = this->other_modes_fan_speed_; // Auto doesn't work in fan only mode + // Disabling eco mode for Fan only + quiet_mode_buf[1] = 0; + break; + case CLIMATE_MODE_COOL: + new_power = true; + buffer[1] = (uint8_t) hon_protocol::ConditioningMode::COOL; + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::AC_MODE, + buffer, 2)); + fan_mode_buf[1] = this->other_modes_fan_speed_; + break; + default: + ESP_LOGE("Control", "Unsupported climate mode"); + break; + } + } + // Climate power + { + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::AC_POWER, + new_power ? one_buf : zero_buf, 2)); + } + // CLimate preset + { + uint8_t fast_mode_buf[] = {0x00, 0xFF}; + if (!new_power) { + // If AC is off - no presets allowed + quiet_mode_buf[1] = 0x00; + fast_mode_buf[1] = 0x00; + } else if (climate_control.preset.has_value()) { + switch (climate_control.preset.value()) { + case CLIMATE_PRESET_NONE: + quiet_mode_buf[1] = 0x00; + fast_mode_buf[1] = 0x00; + break; + case CLIMATE_PRESET_ECO: + // Eco is not supported in Fan only mode + quiet_mode_buf[1] = (this->mode != CLIMATE_MODE_FAN_ONLY) ? 0x01 : 0x00; + fast_mode_buf[1] = 0x00; + break; + case CLIMATE_PRESET_BOOST: + quiet_mode_buf[1] = 0x00; + // Boost is not supported in Fan only mode + fast_mode_buf[1] = (this->mode != CLIMATE_MODE_FAN_ONLY) ? 0x01 : 0x00; + break; + default: + ESP_LOGE("Control", "Unsupported preset"); + break; + } + } + if (quiet_mode_buf[1] != 0xFF) { + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::QUIET_MODE, + quiet_mode_buf, 2)); + } + if (fast_mode_buf[1] != 0xFF) { + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::FAST_MODE, + fast_mode_buf, 2)); + } + } + // Target temperature + if (climate_control.target_temperature.has_value()) { + uint8_t buffer[2] = {0x00, 0x00}; + buffer[1] = ((uint8_t) climate_control.target_temperature.value()) - 16; + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::SET_POINT, + buffer, 2)); + } + // Fan mode + if (climate_control.fan_mode.has_value()) { + switch (climate_control.fan_mode.value()) { + case CLIMATE_FAN_LOW: + fan_mode_buf[1] = (uint8_t) hon_protocol::FanMode::FAN_LOW; + break; + case CLIMATE_FAN_MEDIUM: + fan_mode_buf[1] = (uint8_t) hon_protocol::FanMode::FAN_MID; + break; + case CLIMATE_FAN_HIGH: + fan_mode_buf[1] = (uint8_t) hon_protocol::FanMode::FAN_HIGH; + break; + case CLIMATE_FAN_AUTO: + if (mode != CLIMATE_MODE_FAN_ONLY) // if we are not in fan only mode + fan_mode_buf[1] = (uint8_t) hon_protocol::FanMode::FAN_AUTO; + break; + default: + ESP_LOGE("Control", "Unsupported fan mode"); + break; + } + if (fan_mode_buf[1] != 0xFF) { + this->control_messages_queue_.push( + haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, + (uint16_t) hon_protocol::SubcommandsControl::SET_SINGLE_PARAMETER + + (uint8_t) hon_protocol::DataParameters::FAN_MODE, + fan_mode_buf, 2)); + } + } } -void HonClimate::process_pending_action() { - switch (this->action_request_) { - case ActionRequest::START_SELF_CLEAN: - case ActionRequest::START_STERI_CLEAN: - // Will reset action with control message sending - this->set_phase(ProtocolPhases::SENDING_CONTROL); - break; +void HonClimate::clear_control_messages_queue_() { + while (!this->control_messages_queue_.empty()) + this->control_messages_queue_.pop(); +} + +bool HonClimate::prepare_pending_action() { + switch (this->action_request_.value().action) { + case ActionRequest::START_SELF_CLEAN: { + uint8_t control_out_buffer[sizeof(hon_protocol::HaierPacketControl)]; + memcpy(control_out_buffer, this->last_status_message_.get(), sizeof(hon_protocol::HaierPacketControl)); + hon_protocol::HaierPacketControl *out_data = (hon_protocol::HaierPacketControl *) control_out_buffer; + out_data->self_cleaning_status = 1; + out_data->steri_clean = 0; + out_data->set_point = 0x06; + out_data->vertical_swing_mode = (uint8_t) hon_protocol::VerticalSwingMode::CENTER; + out_data->horizontal_swing_mode = (uint8_t) hon_protocol::HorizontalSwingMode::CENTER; + out_data->ac_power = 1; + out_data->ac_mode = (uint8_t) hon_protocol::ConditioningMode::DRY; + out_data->light_status = 0; + this->action_request_.value().message = haier_protocol::HaierMessage( + haier_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::SET_GROUP_PARAMETERS, + control_out_buffer, sizeof(hon_protocol::HaierPacketControl)); + } + return true; + case ActionRequest::START_STERI_CLEAN: { + uint8_t control_out_buffer[sizeof(hon_protocol::HaierPacketControl)]; + memcpy(control_out_buffer, this->last_status_message_.get(), sizeof(hon_protocol::HaierPacketControl)); + hon_protocol::HaierPacketControl *out_data = (hon_protocol::HaierPacketControl *) control_out_buffer; + out_data->self_cleaning_status = 0; + out_data->steri_clean = 1; + out_data->set_point = 0x06; + out_data->vertical_swing_mode = (uint8_t) hon_protocol::VerticalSwingMode::CENTER; + out_data->horizontal_swing_mode = (uint8_t) hon_protocol::HorizontalSwingMode::CENTER; + out_data->ac_power = 1; + out_data->ac_mode = (uint8_t) hon_protocol::ConditioningMode::DRY; + out_data->light_status = 0; + this->action_request_.value().message = haier_protocol::HaierMessage( + haier_protocol::FrameType::CONTROL, (uint16_t) hon_protocol::SubcommandsControl::SET_GROUP_PARAMETERS, + control_out_buffer, sizeof(hon_protocol::HaierPacketControl)); + } + return true; default: - HaierClimateBase::process_pending_action(); - break; + return HaierClimateBase::prepare_pending_action(); } } +void HonClimate::process_protocol_reset() { + HaierClimateBase::process_protocol_reset(); + if (this->outdoor_sensor_ != nullptr) { + this->outdoor_sensor_->publish_state(NAN); + } + this->got_valid_outdoor_temp_ = false; + this->hvac_hardware_info_.reset(); +} + } // namespace haier } // namespace esphome diff --git a/esphome/components/haier/hon_climate.h b/esphome/components/haier/hon_climate.h index cf566e3b8e..1ba6a8e041 100644 --- a/esphome/components/haier/hon_climate.h +++ b/esphome/components/haier/hon_climate.h @@ -30,6 +30,8 @@ enum class CleaningState : uint8_t { STERI_CLEAN = 2, }; +enum class HonControlMethod { MONITOR_ONLY = 0, SET_GROUP_PARAMETERS, SET_SINGLE_PARAMETER }; + class HonClimate : public HaierClimateBase { public: HonClimate(); @@ -48,44 +50,57 @@ class HonClimate : public HaierClimateBase { CleaningState get_cleaning_status() const; void start_self_cleaning(); void start_steri_cleaning(); + void set_extra_control_packet_bytes_size(size_t size) { this->extra_control_packet_bytes_ = size; }; + void set_control_method(HonControlMethod method) { this->control_method_ = method; }; protected: void set_handlers() override; void process_phase(std::chrono::steady_clock::time_point now) override; haier_protocol::HaierMessage get_control_message() override; - bool is_message_invalid(uint8_t message_type) override; - void process_pending_action() override; + haier_protocol::HaierMessage get_power_message(bool state) override; + bool prepare_pending_action() override; + void process_protocol_reset() override; // Answers handlers - haier_protocol::HandlerError get_device_version_answer_handler_(uint8_t request_type, uint8_t message_type, + haier_protocol::HandlerError get_device_version_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size); - haier_protocol::HandlerError get_device_id_answer_handler_(uint8_t request_type, uint8_t message_type, + haier_protocol::HandlerError get_device_id_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size); - haier_protocol::HandlerError status_handler_(uint8_t request_type, uint8_t message_type, const uint8_t *data, + haier_protocol::HandlerError status_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size); - haier_protocol::HandlerError get_management_information_answer_handler_(uint8_t request_type, uint8_t message_type, + haier_protocol::HandlerError get_management_information_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size); - haier_protocol::HandlerError report_network_status_answer_handler_(uint8_t request_type, uint8_t message_type, - const uint8_t *data, size_t data_size); - haier_protocol::HandlerError get_alarm_status_answer_handler_(uint8_t request_type, uint8_t message_type, + haier_protocol::HandlerError get_alarm_status_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size); // Helper functions haier_protocol::HandlerError process_status_message_(const uint8_t *packet, uint8_t size); - std::unique_ptr last_status_message_; + void fill_control_messages_queue_(); + void clear_control_messages_queue_(); + + struct HardwareInfo { + std::string protocol_version_; + std::string software_version_; + std::string hardware_version_; + std::string device_name_; + bool functions_[5]; + }; + bool beeper_status_; CleaningState cleaning_status_; bool got_valid_outdoor_temp_; AirflowVerticalDirection vertical_direction_; AirflowHorizontalDirection horizontal_direction_; - bool hvac_hardware_info_available_; - std::string hvac_protocol_version_; - std::string hvac_software_version_; - std::string hvac_hardware_version_; - std::string hvac_device_name_; - bool hvac_functions_[5]; - bool &use_crc_; + esphome::optional hvac_hardware_info_; uint8_t active_alarms_[8]; + int extra_control_packet_bytes_; + HonControlMethod control_method_; esphome::sensor::Sensor *outdoor_sensor_; + std::queue control_messages_queue_; }; } // namespace haier diff --git a/esphome/components/haier/hon_packet.h b/esphome/components/haier/hon_packet.h index c6b32df200..7724b43854 100644 --- a/esphome/components/haier/hon_packet.h +++ b/esphome/components/haier/hon_packet.h @@ -35,6 +35,20 @@ enum class ConditioningMode : uint8_t { FAN = 0x06 }; +enum class DataParameters : uint8_t { + AC_POWER = 0x01, + SET_POINT = 0x02, + AC_MODE = 0x04, + FAN_MODE = 0x05, + USE_FAHRENHEIT = 0x07, + TEN_DEGREE = 0x0A, + HEALTH_MODE = 0x0B, + BEEPER_STATUS = 0x16, + LOCK_REMOTE = 0x17, + QUIET_MODE = 0x19, + FAST_MODE = 0x1A, +}; + enum class SpecialMode : uint8_t { NONE = 0x00, ELDERLY = 0x01, CHILDREN = 0x02, PREGNANT = 0x03 }; enum class FanMode : uint8_t { FAN_HIGH = 0x01, FAN_MID = 0x02, FAN_LOW = 0x03, FAN_AUTO = 0x05 }; @@ -124,11 +138,7 @@ struct HaierPacketSensors { uint16_t co2_value; // CO2 value (0 PPM - 10000 PPM, 1 PPM step) }; -struct HaierStatus { - uint16_t subcommand; - HaierPacketControl control; - HaierPacketSensors sensors; -}; +constexpr size_t HAIER_STATUS_FRAME_SIZE = 2 + sizeof(HaierPacketControl) + sizeof(HaierPacketSensors); struct DeviceVersionAnswer { char protocol_version[8]; @@ -140,76 +150,6 @@ struct DeviceVersionAnswer { uint8_t functions[2]; }; -// In this section comments: -// - module is the ESP32 control module (communication module in Haier protocol document) -// - device is the conditioner control board (network appliances in Haier protocol document) -enum class FrameType : uint8_t { - CONTROL = 0x01, // Requests or sets one or multiple parameters (module <-> device, required) - STATUS = 0x02, // Contains one or multiple parameters values, usually answer to control frame (module <-> device, - // required) - INVALID = 0x03, // Communication error indication (module <-> device, required) - ALARM_STATUS = 0x04, // Alarm status report (module <-> device, interactive, required) - CONFIRM = 0x05, // Acknowledgment, usually used to confirm reception of frame if there is no special answer (module - // <-> device, required) - REPORT = 0x06, // Report frame (module <-> device, interactive, required) - STOP_FAULT_ALARM = 0x09, // Stop fault alarm frame (module -> device, interactive, required) - SYSTEM_DOWNLINK = 0x11, // System downlink frame (module -> device, optional) - DEVICE_UPLINK = 0x12, // Device uplink frame (module <- device , interactive, optional) - SYSTEM_QUERY = 0x13, // System query frame (module -> device, optional) - SYSTEM_QUERY_RESPONSE = 0x14, // System query response frame (module <- device , optional) - DEVICE_QUERY = 0x15, // Device query frame (module <- device, optional) - DEVICE_QUERY_RESPONSE = 0x16, // Device query response frame (module -> device, optional) - GROUP_COMMAND = 0x60, // Group command frame (module -> device, interactive, optional) - GET_DEVICE_VERSION = 0x61, // Requests device version (module -> device, required) - GET_DEVICE_VERSION_RESPONSE = 0x62, // Device version answer (module <- device, required_ - GET_ALL_ADDRESSES = 0x67, // Requests all devices addresses (module -> device, interactive, optional) - GET_ALL_ADDRESSES_RESPONSE = - 0x68, // Answer to request of all devices addresses (module <- device , interactive, optional) - HANDSET_CHANGE_NOTIFICATION = 0x69, // Handset change notification frame (module <- device , interactive, optional) - GET_DEVICE_ID = 0x70, // Requests Device ID (module -> device, required) - GET_DEVICE_ID_RESPONSE = 0x71, // Response to device ID request (module <- device , required) - GET_ALARM_STATUS = 0x73, // Alarm status request (module -> device, required) - GET_ALARM_STATUS_RESPONSE = 0x74, // Response to alarm status request (module <- device, required) - GET_DEVICE_CONFIGURATION = 0x7C, // Requests device configuration (module -> device, interactive, required) - GET_DEVICE_CONFIGURATION_RESPONSE = - 0x7D, // Response to device configuration request (module <- device, interactive, required) - DOWNLINK_TRANSPARENT_TRANSMISSION = 0x8C, // Downlink transparent transmission (proxy data Haier cloud -> device) - // (module -> device, interactive, optional) - UPLINK_TRANSPARENT_TRANSMISSION = 0x8D, // Uplink transparent transmission (proxy data device -> Haier cloud) (module - // <- device, interactive, optional) - START_DEVICE_UPGRADE = 0xE1, // Initiate device OTA upgrade (module -> device, OTA required) - START_DEVICE_UPGRADE_RESPONSE = 0xE2, // Response to initiate device upgrade command (module <- device, OTA required) - GET_FIRMWARE_CONTENT = 0xE5, // Requests to send firmware (module <- device, OTA required) - GET_FIRMWARE_CONTENT_RESPONSE = - 0xE6, // Response to send firmware request (module -> device, OTA required) (multipacket?) - CHANGE_BAUD_RATE = 0xE7, // Requests to change port baud rate (module <- device, OTA required) - CHANGE_BAUD_RATE_RESPONSE = 0xE8, // Response to change port baud rate request (module -> device, OTA required) - GET_SUBBOARD_INFO = 0xE9, // Requests subboard information (module -> device, required) - GET_SUBBOARD_INFO_RESPONSE = 0xEA, // Response to subboard information request (module <- device, required) - GET_HARDWARE_INFO = 0xEB, // Requests information about device and subboard (module -> device, required) - GET_HARDWARE_INFO_RESPONSE = 0xEC, // Response to hardware information request (module <- device, required) - GET_UPGRADE_RESULT = 0xED, // Requests result of the firmware update (module <- device, OTA required) - GET_UPGRADE_RESULT_RESPONSE = 0xEF, // Response to firmware update results request (module -> device, OTA required) - GET_NETWORK_STATUS = 0xF0, // Requests network status (module <- device, interactive, optional) - GET_NETWORK_STATUS_RESPONSE = 0xF1, // Response to network status request (module -> device, interactive, optional) - START_WIFI_CONFIGURATION = 0xF2, // Starts WiFi configuration procedure (module <- device, interactive, required) - START_WIFI_CONFIGURATION_RESPONSE = - 0xF3, // Response to start WiFi configuration request (module -> device, interactive, required) - STOP_WIFI_CONFIGURATION = 0xF4, // Stop WiFi configuration procedure (module <- device, interactive, required) - STOP_WIFI_CONFIGURATION_RESPONSE = - 0xF5, // Response to stop WiFi configuration request (module -> device, interactive, required) - REPORT_NETWORK_STATUS = 0xF7, // Reports network status (module -> device, required) - CLEAR_CONFIGURATION = 0xF8, // Request to clear module configuration (module <- device, interactive, optional) - BIG_DATA_REPORT_CONFIGURATION = - 0xFA, // Configuration for autoreport device full status (module -> device, interactive, optional) - BIG_DATA_REPORT_CONFIGURATION_RESPONSE = - 0xFB, // Response to set big data configuration (module <- device, interactive, optional) - GET_MANAGEMENT_INFORMATION = 0xFC, // Request management information from device (module -> device, required) - GET_MANAGEMENT_INFORMATION_RESPONSE = - 0xFD, // Response to management information request (module <- device, required) - WAKE_UP = 0xFE, // Request to wake up (module <-> device, optional) -}; - enum class SubcommandsControl : uint16_t { GET_PARAMETERS = 0x4C01, // Request specific parameters (packet content: parameter ID1 + parameter ID2 + ...) GET_USER_DATA = 0x4D01, // Request all user data from device (packet content: None) diff --git a/esphome/components/haier/smartair2_climate.cpp b/esphome/components/haier/smartair2_climate.cpp index f29f840088..c2326883f7 100644 --- a/esphome/components/haier/smartair2_climate.cpp +++ b/esphome/components/haier/smartair2_climate.cpp @@ -12,21 +12,28 @@ namespace haier { static const char *const TAG = "haier.climate"; constexpr size_t SIGNAL_LEVEL_UPDATE_INTERVAL_MS = 10000; +constexpr uint8_t CONTROL_MESSAGE_RETRIES = 5; +constexpr std::chrono::milliseconds CONTROL_MESSAGE_RETRIES_INTERVAL = std::chrono::milliseconds(500); +constexpr uint8_t INIT_REQUESTS_RETRY = 2; +constexpr std::chrono::milliseconds INIT_REQUESTS_RETRY_INTERVAL = std::chrono::milliseconds(2000); -Smartair2Climate::Smartair2Climate() - : last_status_message_(new uint8_t[sizeof(smartair2_protocol::HaierPacketControl)]), timeouts_counter_(0) {} +Smartair2Climate::Smartair2Climate() { + last_status_message_ = std::unique_ptr(new uint8_t[sizeof(smartair2_protocol::HaierPacketControl)]); +} -haier_protocol::HandlerError Smartair2Climate::status_handler_(uint8_t request_type, uint8_t message_type, +haier_protocol::HandlerError Smartair2Climate::status_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size) { haier_protocol::HandlerError result = - this->answer_preprocess_(request_type, (uint8_t) smartair2_protocol::FrameType::CONTROL, message_type, - (uint8_t) smartair2_protocol::FrameType::STATUS, ProtocolPhases::UNKNOWN); + this->answer_preprocess_(request_type, haier_protocol::FrameType::CONTROL, message_type, + haier_protocol::FrameType::STATUS, ProtocolPhases::UNKNOWN); if (result == haier_protocol::HandlerError::HANDLER_OK) { result = this->process_status_message_(data, data_size); if (result != haier_protocol::HandlerError::HANDLER_OK) { ESP_LOGW(TAG, "Error %d while parsing Status packet", (int) result); - this->set_phase((this->protocol_phase_ >= ProtocolPhases::IDLE) ? ProtocolPhases::IDLE - : ProtocolPhases::SENDING_FIRST_STATUS_REQUEST); + this->reset_phase_(); + this->action_request_.reset(); + this->force_send_control_ = false; } else { if (data_size >= sizeof(smartair2_protocol::HaierPacketControl) + 2) { memcpy(this->last_status_message_.get(), data + 2, sizeof(smartair2_protocol::HaierPacketControl)); @@ -34,36 +41,45 @@ haier_protocol::HandlerError Smartair2Climate::status_handler_(uint8_t request_t ESP_LOGW(TAG, "Status packet too small: %d (should be >= %d)", data_size, sizeof(smartair2_protocol::HaierPacketControl)); } - if (this->protocol_phase_ == ProtocolPhases::WAITING_FIRST_STATUS_ANSWER) { - ESP_LOGI(TAG, "First HVAC status received"); - this->set_phase(ProtocolPhases::IDLE); - } else if (this->protocol_phase_ == ProtocolPhases::WAITING_STATUS_ANSWER) { - this->set_phase(ProtocolPhases::IDLE); - } else if (this->protocol_phase_ == ProtocolPhases::WAITING_CONTROL_ANSWER) { - this->set_phase(ProtocolPhases::IDLE); - this->set_force_send_control_(false); - if (this->hvac_settings_.valid) - this->hvac_settings_.reset(); + switch (this->protocol_phase_) { + case ProtocolPhases::SENDING_FIRST_STATUS_REQUEST: + ESP_LOGI(TAG, "First HVAC status received"); + this->set_phase(ProtocolPhases::IDLE); + break; + case ProtocolPhases::SENDING_ACTION_COMMAND: + // Do nothing, phase will be changed in process_phase + break; + case ProtocolPhases::SENDING_STATUS_REQUEST: + this->set_phase(ProtocolPhases::IDLE); + break; + case ProtocolPhases::SENDING_CONTROL: + this->set_phase(ProtocolPhases::IDLE); + this->force_send_control_ = false; + if (this->current_hvac_settings_.valid) + this->current_hvac_settings_.reset(); + break; + default: + break; } } return result; } else { - this->set_phase((this->protocol_phase_ >= ProtocolPhases::IDLE) ? ProtocolPhases::IDLE - : ProtocolPhases::SENDING_FIRST_STATUS_REQUEST); + this->action_request_.reset(); + this->force_send_control_ = false; + this->reset_phase_(); return result; } } -haier_protocol::HandlerError Smartair2Climate::get_device_version_answer_handler_(uint8_t request_type, - uint8_t message_type, - const uint8_t *data, - size_t data_size) { - if (request_type != (uint8_t) smartair2_protocol::FrameType::GET_DEVICE_VERSION) +haier_protocol::HandlerError Smartair2Climate::get_device_version_answer_handler_( + haier_protocol::FrameType request_type, haier_protocol::FrameType message_type, const uint8_t *data, + size_t data_size) { + if (request_type != haier_protocol::FrameType::GET_DEVICE_VERSION) return haier_protocol::HandlerError::UNSUPPORTED_MESSAGE; - if (ProtocolPhases::WAITING_INIT_1_ANSWER != this->protocol_phase_) + if (ProtocolPhases::SENDING_INIT_1 != this->protocol_phase_) return haier_protocol::HandlerError::UNEXPECTED_MESSAGE; // Invalid packet is expected answer - if ((message_type == (uint8_t) smartair2_protocol::FrameType::GET_DEVICE_VERSION_RESPONSE) && (data_size >= 39) && + if ((message_type == haier_protocol::FrameType::GET_DEVICE_VERSION_RESPONSE) && (data_size >= 39) && ((data[37] & 0x04) != 0)) { ESP_LOGW(TAG, "It looks like your ESPHome Haier climate configuration is wrong. You should use the hOn protocol " "instead of smartAir2"); @@ -72,58 +88,35 @@ haier_protocol::HandlerError Smartair2Climate::get_device_version_answer_handler return haier_protocol::HandlerError::HANDLER_OK; } -haier_protocol::HandlerError Smartair2Climate::report_network_status_answer_handler_(uint8_t request_type, - uint8_t message_type, - const uint8_t *data, - size_t data_size) { - haier_protocol::HandlerError result = this->answer_preprocess_( - request_type, (uint8_t) smartair2_protocol::FrameType::REPORT_NETWORK_STATUS, message_type, - (uint8_t) smartair2_protocol::FrameType::CONFIRM, ProtocolPhases::WAITING_SIGNAL_LEVEL_ANSWER); - this->set_phase(ProtocolPhases::IDLE); - return result; -} - -haier_protocol::HandlerError Smartair2Climate::initial_messages_timeout_handler_(uint8_t message_type) { +haier_protocol::HandlerError Smartair2Climate::messages_timeout_handler_with_cycle_for_init_( + haier_protocol::FrameType message_type) { if (this->protocol_phase_ >= ProtocolPhases::IDLE) return HaierClimateBase::timeout_default_handler_(message_type); - this->timeouts_counter_++; - ESP_LOGI(TAG, "Answer timeout for command %02X, phase %d, timeout counter %d", message_type, - (int) this->protocol_phase_, this->timeouts_counter_); - if (this->timeouts_counter_ >= 3) { - ProtocolPhases new_phase = (ProtocolPhases) ((int) this->protocol_phase_ + 1); - if (new_phase >= ProtocolPhases::SENDING_ALARM_STATUS_REQUEST) - new_phase = ProtocolPhases::SENDING_INIT_1; - this->set_phase(new_phase); - } else { - // Returning to the previous state to try again - this->set_phase((ProtocolPhases) ((int) this->protocol_phase_ - 1)); - } + ESP_LOGI(TAG, "Answer timeout for command %02X, phase %s", (uint8_t) message_type, + phase_to_string_(this->protocol_phase_)); + ProtocolPhases new_phase = (ProtocolPhases) ((int) this->protocol_phase_ + 1); + if (new_phase >= ProtocolPhases::SENDING_ALARM_STATUS_REQUEST) + new_phase = ProtocolPhases::SENDING_INIT_1; + this->set_phase(new_phase); return haier_protocol::HandlerError::HANDLER_OK; } void Smartair2Climate::set_handlers() { // Set handlers this->haier_protocol_.set_answer_handler( - (uint8_t) (smartair2_protocol::FrameType::GET_DEVICE_VERSION), + haier_protocol::FrameType::GET_DEVICE_VERSION, std::bind(&Smartair2Climate::get_device_version_answer_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); this->haier_protocol_.set_answer_handler( - (uint8_t) (smartair2_protocol::FrameType::CONTROL), + haier_protocol::FrameType::CONTROL, std::bind(&Smartair2Climate::status_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); this->haier_protocol_.set_answer_handler( - (uint8_t) (smartair2_protocol::FrameType::REPORT_NETWORK_STATUS), + haier_protocol::FrameType::REPORT_NETWORK_STATUS, std::bind(&Smartair2Climate::report_network_status_answer_handler_, this, std::placeholders::_1, std::placeholders::_2, std::placeholders::_3, std::placeholders::_4)); - this->haier_protocol_.set_timeout_handler( - (uint8_t) (smartair2_protocol::FrameType::GET_DEVICE_ID), - std::bind(&Smartair2Climate::initial_messages_timeout_handler_, this, std::placeholders::_1)); - this->haier_protocol_.set_timeout_handler( - (uint8_t) (smartair2_protocol::FrameType::GET_DEVICE_VERSION), - std::bind(&Smartair2Climate::initial_messages_timeout_handler_, this, std::placeholders::_1)); - this->haier_protocol_.set_timeout_handler( - (uint8_t) (smartair2_protocol::FrameType::CONTROL), - std::bind(&Smartair2Climate::initial_messages_timeout_handler_, this, std::placeholders::_1)); + this->haier_protocol_.set_default_timeout_handler( + std::bind(&Smartair2Climate::messages_timeout_handler_with_cycle_for_init_, this, std::placeholders::_1)); } void Smartair2Climate::dump_config() { @@ -134,9 +127,7 @@ void Smartair2Climate::dump_config() { void Smartair2Climate::process_phase(std::chrono::steady_clock::time_point now) { switch (this->protocol_phase_) { case ProtocolPhases::SENDING_INIT_1: - if (this->can_send_message() && - (((this->timeouts_counter_ == 0) && (this->is_protocol_initialisation_interval_exceeded_(now))) || - ((this->timeouts_counter_ > 0) && (this->is_message_interval_exceeded_(now))))) { + if (this->can_send_message() && this->is_protocol_initialisation_interval_exceeded_(now)) { // Indicate device capabilities: // bit 0 - if 1 module support interactive mode // bit 1 - if 1 module support controller-device mode @@ -145,92 +136,65 @@ void Smartair2Climate::process_phase(std::chrono::steady_clock::time_point now) // bit 4..bit 15 - not used uint8_t module_capabilities[2] = {0b00000000, 0b00000111}; static const haier_protocol::HaierMessage DEVICE_VERSION_REQUEST( - (uint8_t) smartair2_protocol::FrameType::GET_DEVICE_VERSION, module_capabilities, - sizeof(module_capabilities)); - this->send_message_(DEVICE_VERSION_REQUEST, false); - this->set_phase(ProtocolPhases::WAITING_INIT_1_ANSWER); + haier_protocol::FrameType::GET_DEVICE_VERSION, module_capabilities, sizeof(module_capabilities)); + this->send_message_(DEVICE_VERSION_REQUEST, this->use_crc_, INIT_REQUESTS_RETRY, INIT_REQUESTS_RETRY_INTERVAL); } break; case ProtocolPhases::SENDING_INIT_2: - case ProtocolPhases::WAITING_INIT_2_ANSWER: this->set_phase(ProtocolPhases::SENDING_FIRST_STATUS_REQUEST); break; case ProtocolPhases::SENDING_FIRST_STATUS_REQUEST: case ProtocolPhases::SENDING_STATUS_REQUEST: if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { - static const haier_protocol::HaierMessage STATUS_REQUEST((uint8_t) smartair2_protocol::FrameType::CONTROL, - 0x4D01); - this->send_message_(STATUS_REQUEST, false); + static const haier_protocol::HaierMessage STATUS_REQUEST(haier_protocol::FrameType::CONTROL, 0x4D01); + if (this->protocol_phase_ == ProtocolPhases::SENDING_FIRST_STATUS_REQUEST) { + this->send_message_(STATUS_REQUEST, this->use_crc_, INIT_REQUESTS_RETRY, INIT_REQUESTS_RETRY_INTERVAL); + } else { + this->send_message_(STATUS_REQUEST, this->use_crc_); + } this->last_status_request_ = now; - this->set_phase((ProtocolPhases) ((uint8_t) this->protocol_phase_ + 1)); } break; #ifdef USE_WIFI case ProtocolPhases::SENDING_SIGNAL_LEVEL: if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { - this->send_message_( - this->get_wifi_signal_message_((uint8_t) smartair2_protocol::FrameType::REPORT_NETWORK_STATUS), false); + this->send_message_(this->get_wifi_signal_message_(), this->use_crc_); this->last_signal_request_ = now; - this->set_phase(ProtocolPhases::WAITING_SIGNAL_LEVEL_ANSWER); } break; - case ProtocolPhases::WAITING_SIGNAL_LEVEL_ANSWER: - break; #else case ProtocolPhases::SENDING_SIGNAL_LEVEL: - case ProtocolPhases::WAITING_SIGNAL_LEVEL_ANSWER: this->set_phase(ProtocolPhases::IDLE); break; #endif case ProtocolPhases::SENDING_UPDATE_SIGNAL_REQUEST: - case ProtocolPhases::WAITING_UPDATE_SIGNAL_ANSWER: this->set_phase(ProtocolPhases::SENDING_SIGNAL_LEVEL); break; case ProtocolPhases::SENDING_ALARM_STATUS_REQUEST: - case ProtocolPhases::WAITING_ALARM_STATUS_ANSWER: this->set_phase(ProtocolPhases::SENDING_INIT_1); break; case ProtocolPhases::SENDING_CONTROL: - if (this->first_control_attempt_) { - this->control_request_timestamp_ = now; - this->first_control_attempt_ = false; + if (this->can_send_message() && this->is_control_message_interval_exceeded_(now)) { + ESP_LOGI(TAG, "Sending control packet"); + this->send_message_(get_control_message(), this->use_crc_, CONTROL_MESSAGE_RETRIES, + CONTROL_MESSAGE_RETRIES_INTERVAL); } - if (this->is_control_message_timeout_exceeded_(now)) { - ESP_LOGW(TAG, "Sending control packet timeout!"); - this->set_force_send_control_(false); - if (this->hvac_settings_.valid) - this->hvac_settings_.reset(); - this->forced_request_status_ = true; - this->forced_publish_ = true; + break; + case ProtocolPhases::SENDING_ACTION_COMMAND: + if (this->action_request_.has_value()) { + if (this->action_request_.value().message.has_value()) { + this->send_message_(this->action_request_.value().message.value(), this->use_crc_); + this->action_request_.value().message.reset(); + } else { + // Message already sent, reseting request and return to idle + this->action_request_.reset(); + this->set_phase(ProtocolPhases::IDLE); + } + } else { + ESP_LOGW(TAG, "SENDING_ACTION_COMMAND phase without action request!"); this->set_phase(ProtocolPhases::IDLE); - } else if (this->can_send_message() && this->is_control_message_interval_exceeded_( - now)) // Using CONTROL_MESSAGES_INTERVAL_MS to speedup requests - { - haier_protocol::HaierMessage control_message = get_control_message(); - this->send_message_(control_message, false); - ESP_LOGI(TAG, "Control packet sent"); - this->set_phase(ProtocolPhases::WAITING_CONTROL_ANSWER); } break; - case ProtocolPhases::SENDING_POWER_ON_COMMAND: - case ProtocolPhases::SENDING_POWER_OFF_COMMAND: - if (this->can_send_message() && this->is_message_interval_exceeded_(now)) { - haier_protocol::HaierMessage power_cmd( - (uint8_t) smartair2_protocol::FrameType::CONTROL, - this->protocol_phase_ == ProtocolPhases::SENDING_POWER_ON_COMMAND ? 0x4D02 : 0x4D03); - this->send_message_(power_cmd, false); - this->set_phase(this->protocol_phase_ == ProtocolPhases::SENDING_POWER_ON_COMMAND - ? ProtocolPhases::WAITING_POWER_ON_ANSWER - : ProtocolPhases::WAITING_POWER_OFF_ANSWER); - } - break; - case ProtocolPhases::WAITING_INIT_1_ANSWER: - case ProtocolPhases::WAITING_FIRST_STATUS_ANSWER: - case ProtocolPhases::WAITING_STATUS_ANSWER: - case ProtocolPhases::WAITING_CONTROL_ANSWER: - case ProtocolPhases::WAITING_POWER_ON_ANSWER: - case ProtocolPhases::WAITING_POWER_OFF_ANSWER: - break; case ProtocolPhases::IDLE: { if (this->forced_request_status_ || this->is_status_request_interval_exceeded_(now)) { this->set_phase(ProtocolPhases::SENDING_STATUS_REQUEST); @@ -245,55 +209,55 @@ void Smartair2Climate::process_phase(std::chrono::steady_clock::time_point now) } break; default: // Shouldn't get here -#if (HAIER_LOG_LEVEL > 4) ESP_LOGE(TAG, "Wrong protocol handler state: %s (%d), resetting communication", phase_to_string_(this->protocol_phase_), (int) this->protocol_phase_); -#else - ESP_LOGE(TAG, "Wrong protocol handler state: %d, resetting communication", (int) this->protocol_phase_); -#endif this->set_phase(ProtocolPhases::SENDING_INIT_1); break; } } +haier_protocol::HaierMessage Smartair2Climate::get_power_message(bool state) { + if (state) { + static haier_protocol::HaierMessage power_on_message(haier_protocol::FrameType::CONTROL, 0x4D02); + return power_on_message; + } else { + static haier_protocol::HaierMessage power_off_message(haier_protocol::FrameType::CONTROL, 0x4D03); + return power_off_message; + } +} + haier_protocol::HaierMessage Smartair2Climate::get_control_message() { uint8_t control_out_buffer[sizeof(smartair2_protocol::HaierPacketControl)]; memcpy(control_out_buffer, this->last_status_message_.get(), sizeof(smartair2_protocol::HaierPacketControl)); smartair2_protocol::HaierPacketControl *out_data = (smartair2_protocol::HaierPacketControl *) control_out_buffer; out_data->cntrl = 0; - if (this->hvac_settings_.valid) { - HvacSettings climate_control; - climate_control = this->hvac_settings_; + if (this->current_hvac_settings_.valid) { + HvacSettings &climate_control = this->current_hvac_settings_; if (climate_control.mode.has_value()) { switch (climate_control.mode.value()) { case CLIMATE_MODE_OFF: out_data->ac_power = 0; break; - case CLIMATE_MODE_HEAT_COOL: out_data->ac_power = 1; out_data->ac_mode = (uint8_t) smartair2_protocol::ConditioningMode::AUTO; out_data->fan_mode = this->other_modes_fan_speed_; break; - case CLIMATE_MODE_HEAT: out_data->ac_power = 1; out_data->ac_mode = (uint8_t) smartair2_protocol::ConditioningMode::HEAT; out_data->fan_mode = this->other_modes_fan_speed_; break; - case CLIMATE_MODE_DRY: out_data->ac_power = 1; out_data->ac_mode = (uint8_t) smartair2_protocol::ConditioningMode::DRY; out_data->fan_mode = this->other_modes_fan_speed_; break; - case CLIMATE_MODE_FAN_ONLY: out_data->ac_power = 1; out_data->ac_mode = (uint8_t) smartair2_protocol::ConditioningMode::FAN; out_data->fan_mode = this->fan_mode_speed_; // Auto doesn't work in fan only mode break; - case CLIMATE_MODE_COOL: out_data->ac_power = 1; out_data->ac_mode = (uint8_t) smartair2_protocol::ConditioningMode::COOL; @@ -327,32 +291,49 @@ haier_protocol::HaierMessage Smartair2Climate::get_control_message() { } // Set swing mode if (climate_control.swing_mode.has_value()) { - switch (climate_control.swing_mode.value()) { - case CLIMATE_SWING_OFF: - out_data->use_swing_bits = 0; - out_data->swing_both = 0; - break; - case CLIMATE_SWING_VERTICAL: - out_data->swing_both = 0; - out_data->vertical_swing = 1; - out_data->horizontal_swing = 0; - break; - case CLIMATE_SWING_HORIZONTAL: - out_data->swing_both = 0; - out_data->vertical_swing = 0; - out_data->horizontal_swing = 1; - break; - case CLIMATE_SWING_BOTH: - out_data->swing_both = 1; - out_data->use_swing_bits = 0; - out_data->vertical_swing = 0; - out_data->horizontal_swing = 0; - break; + if (this->use_alternative_swing_control_) { + switch (climate_control.swing_mode.value()) { + case CLIMATE_SWING_OFF: + out_data->swing_mode = 0; + break; + case CLIMATE_SWING_VERTICAL: + out_data->swing_mode = 1; + break; + case CLIMATE_SWING_HORIZONTAL: + out_data->swing_mode = 2; + break; + case CLIMATE_SWING_BOTH: + out_data->swing_mode = 3; + break; + } + } else { + switch (climate_control.swing_mode.value()) { + case CLIMATE_SWING_OFF: + out_data->use_swing_bits = 0; + out_data->swing_mode = 0; + break; + case CLIMATE_SWING_VERTICAL: + out_data->swing_mode = 0; + out_data->vertical_swing = 1; + out_data->horizontal_swing = 0; + break; + case CLIMATE_SWING_HORIZONTAL: + out_data->swing_mode = 0; + out_data->vertical_swing = 0; + out_data->horizontal_swing = 1; + break; + case CLIMATE_SWING_BOTH: + out_data->swing_mode = 1; + out_data->use_swing_bits = 0; + out_data->vertical_swing = 0; + out_data->horizontal_swing = 0; + break; + } } } if (climate_control.target_temperature.has_value()) { float target_temp = climate_control.target_temperature.value(); - out_data->set_point = target_temp - 16; // set the temperature with offset 16 + out_data->set_point = ((int) target_temp) - 16; // set the temperature with offset 16 out_data->half_degree = (target_temp - ((int) target_temp) >= 0.49) ? 1 : 0; } if (out_data->ac_power == 0) { @@ -383,7 +364,7 @@ haier_protocol::HaierMessage Smartair2Climate::get_control_message() { } out_data->display_status = this->display_status_ ? 0 : 1; out_data->health_mode = this->health_mode_ ? 1 : 0; - return haier_protocol::HaierMessage((uint8_t) smartair2_protocol::FrameType::CONTROL, 0x4D5F, control_out_buffer, + return haier_protocol::HaierMessage(haier_protocol::FrameType::CONTROL, 0x4D5F, control_out_buffer, sizeof(smartair2_protocol::HaierPacketControl)); } @@ -459,13 +440,19 @@ haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uin // Do something only if display status changed if (this->mode == CLIMATE_MODE_OFF) { // AC just turned on from remote need to turn off display - this->set_force_send_control_(true); + this->force_send_control_ = true; } else { this->display_status_ = disp_status; } } } } + { + // Health mode + bool old_health_mode = this->health_mode_; + this->health_mode_ = packet.control.health_mode == 1; + should_publish = should_publish || (old_health_mode != this->health_mode_); + } { // Climate mode ClimateMode old_mode = this->mode; @@ -493,70 +480,57 @@ haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uin } should_publish = should_publish || (old_mode != this->mode); } - { - // Health mode - bool old_health_mode = this->health_mode_; - this->health_mode_ = packet.control.health_mode == 1; - should_publish = should_publish || (old_health_mode != this->health_mode_); - } { // Swing mode ClimateSwingMode old_swing_mode = this->swing_mode; - if (packet.control.swing_both == 0) { - if (packet.control.vertical_swing != 0) { - this->swing_mode = CLIMATE_SWING_VERTICAL; - } else if (packet.control.horizontal_swing != 0) { - this->swing_mode = CLIMATE_SWING_HORIZONTAL; - } else { - this->swing_mode = CLIMATE_SWING_OFF; + if (this->use_alternative_swing_control_) { + switch (packet.control.swing_mode) { + case 1: + this->swing_mode = CLIMATE_SWING_VERTICAL; + break; + case 2: + this->swing_mode = CLIMATE_SWING_HORIZONTAL; + break; + case 3: + this->swing_mode = CLIMATE_SWING_BOTH; + break; + default: + this->swing_mode = CLIMATE_SWING_OFF; + break; } } else { - swing_mode = CLIMATE_SWING_BOTH; + if (packet.control.swing_mode == 0) { + if (packet.control.vertical_swing != 0) { + this->swing_mode = CLIMATE_SWING_VERTICAL; + } else if (packet.control.horizontal_swing != 0) { + this->swing_mode = CLIMATE_SWING_HORIZONTAL; + } else { + this->swing_mode = CLIMATE_SWING_OFF; + } + } else { + swing_mode = CLIMATE_SWING_BOTH; + } } should_publish = should_publish || (old_swing_mode != this->swing_mode); } this->last_valid_status_timestamp_ = std::chrono::steady_clock::now(); - if (this->forced_publish_ || should_publish) { -#if (HAIER_LOG_LEVEL > 4) - std::chrono::high_resolution_clock::time_point _publish_start = std::chrono::high_resolution_clock::now(); -#endif + if (should_publish) { this->publish_state(); -#if (HAIER_LOG_LEVEL > 4) - ESP_LOGV(TAG, "Publish delay: %lld ms", - std::chrono::duration_cast(std::chrono::high_resolution_clock::now() - - _publish_start) - .count()); -#endif - this->forced_publish_ = false; } if (should_publish) { ESP_LOGI(TAG, "HVAC values changed"); } - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "HVAC Mode = 0x%X", packet.control.ac_mode); - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "Fan speed Status = 0x%X", packet.control.fan_mode); - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "Horizontal Swing Status = 0x%X", packet.control.horizontal_swing); - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "Vertical Swing Status = 0x%X", packet.control.vertical_swing); - esp_log_printf_((should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG), TAG, __LINE__, - "Set Point Status = 0x%X", packet.control.set_point); + int log_level = should_publish ? ESPHOME_LOG_LEVEL_INFO : ESPHOME_LOG_LEVEL_DEBUG; + esp_log_printf_(log_level, TAG, __LINE__, "HVAC Mode = 0x%X", packet.control.ac_mode); + esp_log_printf_(log_level, TAG, __LINE__, "Fan speed Status = 0x%X", packet.control.fan_mode); + esp_log_printf_(log_level, TAG, __LINE__, "Horizontal Swing Status = 0x%X", packet.control.horizontal_swing); + esp_log_printf_(log_level, TAG, __LINE__, "Vertical Swing Status = 0x%X", packet.control.vertical_swing); + esp_log_printf_(log_level, TAG, __LINE__, "Set Point Status = 0x%X", packet.control.set_point); return haier_protocol::HandlerError::HANDLER_OK; } -bool Smartair2Climate::is_message_invalid(uint8_t message_type) { - return message_type == (uint8_t) smartair2_protocol::FrameType::INVALID; -} - -void Smartair2Climate::set_phase(HaierClimateBase::ProtocolPhases phase) { - int old_phase = (int) this->protocol_phase_; - int new_phase = (int) phase; - int min_p = std::min(old_phase, new_phase); - int max_p = std::max(old_phase, new_phase); - if ((min_p % 2 != 0) || (max_p - min_p > 1)) - this->timeouts_counter_ = 0; - HaierClimateBase::set_phase(phase); +void Smartair2Climate::set_alternative_swing_control(bool swing_control) { + this->use_alternative_swing_control_ = swing_control; } } // namespace haier diff --git a/esphome/components/haier/smartair2_climate.h b/esphome/components/haier/smartair2_climate.h index f173b10749..6914d8a1fb 100644 --- a/esphome/components/haier/smartair2_climate.h +++ b/esphome/components/haier/smartair2_climate.h @@ -13,27 +13,27 @@ class Smartair2Climate : public HaierClimateBase { Smartair2Climate &operator=(const Smartair2Climate &) = delete; ~Smartair2Climate(); void dump_config() override; + void set_alternative_swing_control(bool swing_control); protected: void set_handlers() override; void process_phase(std::chrono::steady_clock::time_point now) override; + haier_protocol::HaierMessage get_power_message(bool state) override; haier_protocol::HaierMessage get_control_message() override; - bool is_message_invalid(uint8_t message_type) override; - void set_phase(HaierClimateBase::ProtocolPhases phase) override; - // Answer and timeout handlers - haier_protocol::HandlerError status_handler_(uint8_t request_type, uint8_t message_type, const uint8_t *data, + // Answer handlers + haier_protocol::HandlerError status_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size); - haier_protocol::HandlerError get_device_version_answer_handler_(uint8_t request_type, uint8_t message_type, + haier_protocol::HandlerError get_device_version_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size); - haier_protocol::HandlerError get_device_id_answer_handler_(uint8_t request_type, uint8_t message_type, + haier_protocol::HandlerError get_device_id_answer_handler_(haier_protocol::FrameType request_type, + haier_protocol::FrameType message_type, const uint8_t *data, size_t data_size); - haier_protocol::HandlerError report_network_status_answer_handler_(uint8_t request_type, uint8_t message_type, - const uint8_t *data, size_t data_size); - haier_protocol::HandlerError initial_messages_timeout_handler_(uint8_t message_type); + haier_protocol::HandlerError messages_timeout_handler_with_cycle_for_init_(haier_protocol::FrameType message_type); // Helper functions haier_protocol::HandlerError process_status_message_(const uint8_t *packet, uint8_t size); - std::unique_ptr last_status_message_; - unsigned int timeouts_counter_; + bool use_alternative_swing_control_; }; } // namespace haier diff --git a/esphome/components/haier/smartair2_packet.h b/esphome/components/haier/smartair2_packet.h index f791c21af2..22570ff048 100644 --- a/esphome/components/haier/smartair2_packet.h +++ b/esphome/components/haier/smartair2_packet.h @@ -41,8 +41,9 @@ struct HaierPacketControl { // 24 uint8_t : 8; // 25 - uint8_t swing_both; // If 1 - swing both direction, if 0 - horizontal_swing and vertical_swing define - // vertical/horizontal/off + uint8_t swing_mode; // In normal mode: If 1 - swing both direction, if 0 - horizontal_swing and + // vertical_swing define vertical/horizontal/off + // In alternative mode: 0 - off, 01 - vertical, 02 - horizontal, 03 - both // 26 uint8_t : 3; uint8_t use_fahrenheit : 1; @@ -82,19 +83,6 @@ struct HaierStatus { HaierPacketControl control; }; -enum class FrameType : uint8_t { - CONTROL = 0x01, - STATUS = 0x02, - INVALID = 0x03, - CONFIRM = 0x05, - GET_DEVICE_VERSION = 0x61, - GET_DEVICE_VERSION_RESPONSE = 0x62, - GET_DEVICE_ID = 0x70, - GET_DEVICE_ID_RESPONSE = 0x71, - REPORT_NETWORK_STATUS = 0xF7, - NO_COMMAND = 0xFF, -}; - } // namespace smartair2_protocol } // namespace haier } // namespace esphome diff --git a/esphome/components/heatpumpir/ir_sender_esphome.h b/esphome/components/heatpumpir/ir_sender_esphome.h index 7546d990ea..944d0e859c 100644 --- a/esphome/components/heatpumpir/ir_sender_esphome.h +++ b/esphome/components/heatpumpir/ir_sender_esphome.h @@ -3,7 +3,6 @@ #ifdef USE_ARDUINO #include "esphome/components/remote_base/remote_base.h" -#include "esphome/components/remote_transmitter/remote_transmitter.h" #include // arduino-heatpump library namespace esphome { @@ -11,14 +10,13 @@ namespace heatpumpir { class IRSenderESPHome : public IRSender { public: - IRSenderESPHome(remote_transmitter::RemoteTransmitterComponent *transmitter) - : IRSender(0), transmit_(transmitter->transmit()){}; + IRSenderESPHome(remote_base::RemoteTransmitterBase *transmitter) : IRSender(0), transmit_(transmitter->transmit()){}; void setFrequency(int frequency) override; // NOLINT(readability-identifier-naming) void space(int space_length) override; void mark(int mark_length) override; protected: - remote_transmitter::RemoteTransmitterComponent::TransmitCall transmit_; + remote_base::RemoteTransmitterBase::TransmitCall transmit_; }; } // namespace heatpumpir diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 1b7c654b0b..c11021fc9c 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -1,15 +1,23 @@ +from __future__ import annotations + import logging +import hashlib import io from pathlib import Path import re import requests +from magic import Magic + +from PIL import Image from esphome import core from esphome.components import font +from esphome import external_files import esphome.config_validation as cv import esphome.codegen as cg from esphome.const import ( + __version__, CONF_DITHER, CONF_FILE, CONF_ICON, @@ -19,6 +27,7 @@ from esphome.const import ( CONF_RESIZE, CONF_SOURCE, CONF_TYPE, + CONF_URL, ) from esphome.core import CORE, HexInt @@ -43,34 +52,74 @@ IMAGE_TYPE = { CONF_USE_TRANSPARENCY = "use_transparency" # If the MDI file cannot be downloaded within this time, abort. -MDI_DOWNLOAD_TIMEOUT = 30 # seconds +IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds SOURCE_LOCAL = "local" SOURCE_MDI = "mdi" +SOURCE_WEB = "web" + Image_ = image_ns.class_("Image") -def _compute_local_icon_path(value) -> Path: - base_dir = Path(CORE.data_dir) / DOMAIN / "mdi" +def _compute_local_icon_path(value: dict) -> Path: + base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi" return base_dir / f"{value[CONF_ICON]}.svg" -def download_mdi(value): - mdi_id = value[CONF_ICON] - path = _compute_local_icon_path(value) - if path.is_file(): - return value - url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg" - _LOGGER.debug("Downloading %s MDI image from %s", mdi_id, url) +def _compute_local_image_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) + return base_dir / key + + +def download_content(url: str, path: Path) -> None: + if not external_files.has_remote_file_changed(url, path): + _LOGGER.debug("Remote file has not changed %s", url) + return + + _LOGGER.debug( + "Remote file has changed, downloading from %s to %s", + url, + path, + ) + try: - req = requests.get(url, timeout=MDI_DOWNLOAD_TIMEOUT) + req = requests.get( + url, + timeout=IMAGE_DOWNLOAD_TIMEOUT, + headers={"User-agent": f"ESPHome/{__version__} (https://esphome.io)"}, + ) req.raise_for_status() except requests.exceptions.RequestException as e: - raise cv.Invalid(f"Could not download MDI image {mdi_id} from {url}: {e}") + raise cv.Invalid(f"Could not download from {url}: {e}") path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(req.content) + + +def download_mdi(value): + validate_cairosvg_installed(value) + + mdi_id = value[CONF_ICON] + path = _compute_local_icon_path(value) + + url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg" + + download_content(url, path) + + return value + + +def download_image(value): + url = value[CONF_URL] + path = _compute_local_image_path(value) + + download_content(url, path) + return value @@ -139,6 +188,13 @@ def validate_file_shorthand(value): CONF_ICON: icon, } ) + if value.startswith("http://") or value.startswith("https://"): + return FILE_SCHEMA( + { + CONF_SOURCE: SOURCE_WEB, + CONF_URL: value, + } + ) return FILE_SCHEMA( { CONF_SOURCE: SOURCE_LOCAL, @@ -160,10 +216,18 @@ MDI_SCHEMA = cv.All( download_mdi, ) +WEB_SCHEMA = cv.All( + { + cv.Required(CONF_URL): cv.string, + }, + download_image, +) + TYPED_FILE_SCHEMA = cv.typed_schema( { SOURCE_LOCAL: LOCAL_SCHEMA, SOURCE_MDI: MDI_SCHEMA, + SOURCE_WEB: WEB_SCHEMA, }, key=CONF_SOURCE, ) @@ -201,9 +265,7 @@ IMAGE_SCHEMA = cv.Schema( CONFIG_SCHEMA = cv.All(font.validate_pillow_installed, IMAGE_SCHEMA) -def load_svg_image(file: str, resize: tuple[int, int]): - from PIL import Image - +def load_svg_image(file: bytes, resize: tuple[int, int]): # This import is only needed in case of SVG images; adding it # to the top would force configurations not using SVG to also have it # installed for no reason. @@ -212,19 +274,17 @@ def load_svg_image(file: str, resize: tuple[int, int]): if resize: req_width, req_height = resize svg_image = svg2png( - url=file, + file, output_width=req_width, output_height=req_height, ) else: - svg_image = svg2png(url=file) + svg_image = svg2png(file) return Image.open(io.BytesIO(svg_image)) async def to_code(config): - from PIL import Image - conf_file = config[CONF_FILE] if conf_file[CONF_SOURCE] == SOURCE_LOCAL: @@ -233,17 +293,26 @@ async def to_code(config): elif conf_file[CONF_SOURCE] == SOURCE_MDI: path = _compute_local_icon_path(conf_file).as_posix() + elif conf_file[CONF_SOURCE] == SOURCE_WEB: + path = _compute_local_image_path(conf_file).as_posix() + try: - resize = config.get(CONF_RESIZE) - if path.lower().endswith(".svg"): - image = load_svg_image(path, resize) - else: - image = Image.open(path) - if resize: - image.thumbnail(resize) + with open(path, "rb") as f: + file_contents = f.read() except Exception as e: raise core.EsphomeError(f"Could not load image file {path}: {e}") + mime = Magic(mime=True) + file_type = mime.from_buffer(file_contents) + + resize = config.get(CONF_RESIZE) + if "svg" in file_type: + image = load_svg_image(file_contents, resize) + else: + image = Image.open(io.BytesIO(file_contents)) + if resize: + image.thumbnail(resize) + width, height = image.size if CONF_RESIZE not in config and (width > 500 or height > 500): diff --git a/esphome/components/interval/__init__.py b/esphome/components/interval/__init__.py index 4514f80ba3..db3232c4b0 100644 --- a/esphome/components/interval/__init__.py +++ b/esphome/components/interval/__init__.py @@ -1,7 +1,7 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome import automation -from esphome.const import CONF_ID, CONF_INTERVAL +from esphome.const import CONF_ID, CONF_INTERVAL, CONF_STARTUP_DELAY CODEOWNERS = ["@esphome/core"] interval_ns = cg.esphome_ns.namespace("interval") @@ -13,6 +13,9 @@ CONFIG_SCHEMA = automation.validate_automation( cv.Schema( { cv.GenerateID(): cv.declare_id(IntervalTrigger), + cv.Optional( + CONF_STARTUP_DELAY, default="0s" + ): cv.positive_time_period_milliseconds, cv.Required(CONF_INTERVAL): cv.positive_time_period_milliseconds, } ).extend(cv.COMPONENT_SCHEMA) @@ -26,3 +29,4 @@ async def to_code(config): await automation.build_automation(var, [], conf) cg.add(var.set_update_interval(conf[CONF_INTERVAL])) + cg.add(var.set_startup_delay(conf[CONF_STARTUP_DELAY])) diff --git a/esphome/components/interval/interval.h b/esphome/components/interval/interval.h index 605ac868f3..5b8bc3081f 100644 --- a/esphome/components/interval/interval.h +++ b/esphome/components/interval/interval.h @@ -8,8 +8,26 @@ namespace interval { class IntervalTrigger : public Trigger<>, public PollingComponent { public: - void update() override { this->trigger(); } + void update() override { + if (this->started_) + this->trigger(); + } + + void setup() override { + if (this->startup_delay_ == 0) { + this->started_ = true; + } else { + this->set_timeout(this->startup_delay_, [this] { this->started_ = true; }); + } + } + + void set_startup_delay(const uint32_t startup_delay) { this->startup_delay_ = startup_delay; } + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + uint32_t startup_delay_{0}; + bool started_{false}; }; } // namespace interval diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 6f63935e6e..923762aea4 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -147,7 +147,7 @@ void MQTTClientComponent::dump_config() { ESP_LOGCONFIG(TAG, " Availability: '%s'", this->availability_.topic.c_str()); } } -bool MQTTClientComponent::can_proceed() { return this->is_connected(); } +bool MQTTClientComponent::can_proceed() { return network::is_disabled() || this->is_connected(); } void MQTTClientComponent::start_dnslookup_() { for (auto &subscription : this->subscriptions_) { diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 197d0c32d4..f4a5126d0c 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -40,6 +40,8 @@ const EntityBase *MQTTLockComponent::get_entity() const { return this->lock_; } void MQTTLockComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfig &config) { if (this->lock_->traits.get_assumed_state()) root[MQTT_OPTIMISTIC] = true; + if (this->lock_->traits.get_supports_open()) + root[MQTT_PAYLOAD_OPEN] = "OPEN"; } bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } diff --git a/esphome/components/my9231/my9231.cpp b/esphome/components/my9231/my9231.cpp index a97587b7be..c511591856 100644 --- a/esphome/components/my9231/my9231.cpp +++ b/esphome/components/my9231/my9231.cpp @@ -1,5 +1,6 @@ #include "my9231.h" #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome { namespace my9231 { @@ -51,7 +52,11 @@ void MY9231OutputComponent::setup() { MY9231_CMD_SCATTER_APDM | MY9231_CMD_FREQUENCY_DIVIDE_1 | MY9231_CMD_REACTION_FAST | MY9231_CMD_ONE_SHOT_DISABLE; ESP_LOGV(TAG, " Command: 0x%02X", command); - this->init_chips_(command); + { + InterruptLock lock; + this->send_dcki_pulses_(32 * this->num_chips_); + this->init_chips_(command); + } ESP_LOGV(TAG, " Chips initialized."); } void MY9231OutputComponent::dump_config() { @@ -66,11 +71,14 @@ void MY9231OutputComponent::loop() { if (!this->update_) return; - for (auto pwm_amount : this->pwm_amounts_) { - this->write_word_(pwm_amount, this->bit_depth_); + { + InterruptLock lock; + for (auto pwm_amount : this->pwm_amounts_) { + this->write_word_(pwm_amount, this->bit_depth_); + } + // Send 8 DI pulses. After 8 falling edges, the duty data are store. + this->send_di_pulses_(8); } - // Send 8 DI pulses. After 8 falling edges, the duty data are store. - this->send_di_pulses_(8); this->update_ = false; } void MY9231OutputComponent::set_channel_value_(uint8_t channel, uint16_t value) { @@ -92,6 +100,7 @@ void MY9231OutputComponent::init_chips_(uint8_t command) { // Send 16 DI pulse. After 14 falling edges, the command data are // stored and after 16 falling edges the duty mode is activated. this->send_di_pulses_(16); + delayMicroseconds(12); } void MY9231OutputComponent::write_word_(uint16_t value, uint8_t bits) { for (uint8_t i = bits; i > 0; i--) { @@ -106,6 +115,13 @@ void MY9231OutputComponent::send_di_pulses_(uint8_t count) { this->pin_di_->digital_write(false); } } +void MY9231OutputComponent::send_dcki_pulses_(uint8_t count) { + delayMicroseconds(12); + for (uint8_t i = 0; i < count; i++) { + this->pin_dcki_->digital_write(true); + this->pin_dcki_->digital_write(false); + } +} } // namespace my9231 } // namespace esphome diff --git a/esphome/components/my9231/my9231.h b/esphome/components/my9231/my9231.h index a777dcc960..77c1259853 100644 --- a/esphome/components/my9231/my9231.h +++ b/esphome/components/my9231/my9231.h @@ -49,6 +49,7 @@ class MY9231OutputComponent : public Component { void init_chips_(uint8_t command); void write_word_(uint16_t value, uint8_t bits); void send_di_pulses_(uint8_t count); + void send_dcki_pulses_(uint8_t count); GPIOPin *pin_di_; GPIOPin *pin_dcki_; diff --git a/esphome/components/network/util.cpp b/esphome/components/network/util.cpp index 941102d6c1..109c2947ce 100644 --- a/esphome/components/network/util.cpp +++ b/esphome/components/network/util.cpp @@ -29,6 +29,14 @@ bool is_connected() { return false; } +bool is_disabled() { +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) + return wifi::global_wifi_component->is_disabled(); +#endif + return false; +} + network::IPAddress get_ip_address() { #ifdef USE_ETHERNET if (ethernet::global_eth_component != nullptr) diff --git a/esphome/components/network/util.h b/esphome/components/network/util.h index f248d5cbf4..0322f19215 100644 --- a/esphome/components/network/util.h +++ b/esphome/components/network/util.h @@ -8,6 +8,8 @@ namespace network { /// Return whether the node is connected to the network (through wifi, eth, ...) bool is_connected(); +/// Return whether the network is disabled (only wifi for now) +bool is_disabled(); /// Get the active network hostname std::string get_use_address(); IPAddress get_ip_address(); diff --git a/esphome/components/nextion/nextion.cpp b/esphome/components/nextion/nextion.cpp index 0134595050..92afdfe3dc 100644 --- a/esphome/components/nextion/nextion.cpp +++ b/esphome/components/nextion/nextion.cpp @@ -93,7 +93,8 @@ bool Nextion::check_connect_() { connect_info.push_back(response.substr(start, end - start)); } - if (connect_info.size() == 7) { + this->is_detected_ = (connect_info.size() == 7); + if (this->is_detected_) { ESP_LOGN(TAG, "Received connect_info %zu", connect_info.size()); this->device_model_ = connect_info[2]; diff --git a/esphome/components/nextion/nextion_base.h b/esphome/components/nextion/nextion_base.h index a24fd74060..ffc431ed13 100644 --- a/esphome/components/nextion/nextion_base.h +++ b/esphome/components/nextion/nextion_base.h @@ -48,10 +48,12 @@ class NextionBase { bool is_sleeping() { return this->is_sleeping_; } bool is_setup() { return this->is_setup_; } + bool is_detected() { return this->is_detected_; } protected: bool is_setup_ = false; bool is_sleeping_ = false; + bool is_detected_ = false; }; } // namespace nextion diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index 25dedd71d8..a2411b1b12 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -52,8 +52,9 @@ RemoteReceiverTrigger = ns.class_( "RemoteReceiverTrigger", automation.Trigger, RemoteReceiverListener ) RemoteTransmitterDumper = ns.class_("RemoteTransmitterDumper") +RemoteTransmittable = ns.class_("RemoteTransmittable") RemoteTransmitterActionBase = ns.class_( - "RemoteTransmitterActionBase", automation.Action + "RemoteTransmitterActionBase", RemoteTransmittable, automation.Action ) RemoteReceiverBase = ns.class_("RemoteReceiverBase") RemoteTransmitterBase = ns.class_("RemoteTransmitterBase") @@ -68,11 +69,30 @@ def templatize(value): return cv.Schema(ret) +REMOTE_LISTENER_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_RECEIVER_ID): cv.use_id(RemoteReceiverBase), + } +) + + +REMOTE_TRANSMITTABLE_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_TRANSMITTER_ID): cv.use_id(RemoteTransmitterBase), + } +) + + async def register_listener(var, config): receiver = await cg.get_variable(config[CONF_RECEIVER_ID]) cg.add(receiver.register_listener(var)) +async def register_transmittable(var, config): + transmitter_ = await cg.get_variable(config[CONF_TRANSMITTER_ID]) + cg.add(var.set_transmitter(transmitter_)) + + def register_binary_sensor(name, type, schema): return BINARY_SENSOR_REGISTRY.register(name, type, schema) @@ -129,10 +149,9 @@ def validate_repeat(value): BASE_REMOTE_TRANSMITTER_SCHEMA = cv.Schema( { - cv.GenerateID(CONF_TRANSMITTER_ID): cv.use_id(RemoteTransmitterBase), cv.Optional(CONF_REPEAT): validate_repeat, } -) +).extend(REMOTE_TRANSMITTABLE_SCHEMA) def register_action(name, type_, schema): @@ -143,9 +162,8 @@ def register_action(name, type_, schema): def decorator(func): async def new_func(config, action_id, template_arg, args): - transmitter = await cg.get_variable(config[CONF_TRANSMITTER_ID]) var = cg.new_Pvariable(action_id, template_arg) - cg.add(var.set_parent(transmitter)) + await register_transmittable(var, config) if CONF_REPEAT in config: conf = config[CONF_REPEAT] template_ = await cg.templatable(conf[CONF_TIMES], args, cg.uint32) @@ -1539,7 +1557,7 @@ MIDEA_SCHEMA = cv.Schema( @register_binary_sensor("midea", MideaBinarySensor, MIDEA_SCHEMA) def midea_binary_sensor(var, config): - cg.add(var.set_code(config[CONF_CODE])) + cg.add(var.set_data(config[CONF_CODE])) @register_trigger("midea", MideaTrigger, MideaData) diff --git a/esphome/components/remote_base/midea_protocol.h b/esphome/components/remote_base/midea_protocol.h index 6925686b34..94fb6f3d94 100644 --- a/esphome/components/remote_base/midea_protocol.h +++ b/esphome/components/remote_base/midea_protocol.h @@ -67,20 +67,7 @@ class MideaProtocol : public RemoteProtocol { void dump(const MideaData &data) override; }; -class MideaBinarySensor : public RemoteReceiverBinarySensorBase { - public: - bool matches(RemoteReceiveData src) override { - auto data = MideaProtocol().decode(src); - return data.has_value() && data.value() == this->data_; - } - void set_code(const std::vector &code) { this->data_ = code; } - - protected: - MideaData data_; -}; - -using MideaTrigger = RemoteReceiverTrigger; -using MideaDumper = RemoteReceiverDumper; +DECLARE_REMOTE_PROTOCOL(Midea) template class MideaAction : public RemoteTransmitterActionBase { TEMPLATABLE_VALUE(std::vector, code) diff --git a/esphome/components/remote_base/rc_switch_protocol.h b/esphome/components/remote_base/rc_switch_protocol.h index fc465dbd5d..96cbbd1467 100644 --- a/esphome/components/remote_base/rc_switch_protocol.h +++ b/esphome/components/remote_base/rc_switch_protocol.h @@ -15,6 +15,8 @@ struct RCSwitchData { class RCSwitchBase { public: + using ProtocolData = RCSwitchData; + RCSwitchBase() = default; RCSwitchBase(uint32_t sync_high, uint32_t sync_low, uint32_t zero_high, uint32_t zero_low, uint32_t one_high, uint32_t one_low, bool inverted); @@ -213,7 +215,7 @@ class RCSwitchDumper : public RemoteReceiverDumperBase { bool dump(RemoteReceiveData src) override; }; -using RCSwitchTrigger = RemoteReceiverTrigger; +using RCSwitchTrigger = RemoteReceiverTrigger; } // namespace remote_base } // namespace esphome diff --git a/esphome/components/remote_base/remote_base.h b/esphome/components/remote_base/remote_base.h index a456007655..ebbb528a23 100644 --- a/esphome/components/remote_base/remote_base.h +++ b/esphome/components/remote_base/remote_base.h @@ -127,6 +127,14 @@ class RemoteTransmitterBase : public RemoteComponentBase { this->temp_.reset(); return TransmitCall(this); } + template + void transmit(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + auto call = this->transmit(); + Protocol().encode(call.get_data(), data); + call.set_send_times(send_times); + call.set_send_wait(send_wait); + call.perform(); + } protected: void send_(uint32_t send_times, uint32_t send_wait); @@ -184,12 +192,13 @@ class RemoteReceiverBinarySensorBase : public binary_sensor::BinarySensorInitial template class RemoteProtocol { public: - virtual void encode(RemoteTransmitData *dst, const T &data) = 0; - virtual optional decode(RemoteReceiveData src) = 0; - virtual void dump(const T &data) = 0; + using ProtocolData = T; + virtual void encode(RemoteTransmitData *dst, const ProtocolData &data) = 0; + virtual optional decode(RemoteReceiveData src) = 0; + virtual void dump(const ProtocolData &data) = 0; }; -template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { +template class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase { public: RemoteReceiverBinarySensor() : RemoteReceiverBinarySensorBase() {} @@ -201,13 +210,14 @@ template class RemoteReceiverBinarySensor : public Remot } public: - void set_data(D data) { data_ = data; } + void set_data(typename T::ProtocolData data) { data_ = data; } protected: - D data_; + typename T::ProtocolData data_; }; -template class RemoteReceiverTrigger : public Trigger, public RemoteReceiverListener { +template +class RemoteReceiverTrigger : public Trigger, public RemoteReceiverListener { protected: bool on_receive(RemoteReceiveData src) override { auto proto = T(); @@ -220,28 +230,36 @@ template class RemoteReceiverTrigger : public Trigger } }; -template class RemoteTransmitterActionBase : public Action { +class RemoteTransmittable { public: - void set_parent(RemoteTransmitterBase *parent) { this->parent_ = parent; } + RemoteTransmittable() {} + RemoteTransmittable(RemoteTransmitterBase *transmitter) : transmitter_(transmitter) {} + void set_transmitter(RemoteTransmitterBase *transmitter) { this->transmitter_ = transmitter; } - TEMPLATABLE_VALUE(uint32_t, send_times); - TEMPLATABLE_VALUE(uint32_t, send_wait); + protected: + template + void transmit_(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) { + this->transmitter_->transmit(data, send_times, send_wait); + } + RemoteTransmitterBase *transmitter_; +}; +template class RemoteTransmitterActionBase : public RemoteTransmittable, public Action { + TEMPLATABLE_VALUE(uint32_t, send_times) + TEMPLATABLE_VALUE(uint32_t, send_wait) + + protected: void play(Ts... x) override { - auto call = this->parent_->transmit(); + auto call = this->transmitter_->transmit(); this->encode(call.get_data(), x...); call.set_send_times(this->send_times_.value_or(x..., 1)); call.set_send_wait(this->send_wait_.value_or(x..., 0)); call.perform(); } - - protected: virtual void encode(RemoteTransmitData *dst, Ts... x) = 0; - - RemoteTransmitterBase *parent_{}; }; -template class RemoteReceiverDumper : public RemoteReceiverDumperBase { +template class RemoteReceiverDumper : public RemoteReceiverDumperBase { public: bool dump(RemoteReceiveData src) override { auto proto = T(); @@ -254,9 +272,9 @@ template class RemoteReceiverDumper : public RemoteRecei }; #define DECLARE_REMOTE_PROTOCOL_(prefix) \ - using prefix##BinarySensor = RemoteReceiverBinarySensor; \ - using prefix##Trigger = RemoteReceiverTrigger; \ - using prefix##Dumper = RemoteReceiverDumper; + using prefix##BinarySensor = RemoteReceiverBinarySensor; \ + using prefix##Trigger = RemoteReceiverTrigger; \ + using prefix##Dumper = RemoteReceiverDumper; #define DECLARE_REMOTE_PROTOCOL(prefix) DECLARE_REMOTE_PROTOCOL_(prefix) } // namespace remote_base diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 62f199b040..d027f48244 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -74,12 +74,12 @@ def _format_framework_arduino_version(ver: cv.Version) -> str: # The default/recommended arduino framework version # - https://github.com/earlephilhower/arduino-pico/releases # - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico -RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 4, 0) +RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(3, 6, 0) # The platformio/raspberrypi version to use for arduino frameworks # - https://github.com/platformio/platform-raspberrypi/releases # - https://api.registry.platformio.org/v3/packages/platformio/platform/raspberrypi -ARDUINO_PLATFORM_VERSION = cv.Version(1, 9, 0) +ARDUINO_PLATFORM_VERSION = cv.Version(1, 10, 0) def _arduino_check_versions(value): diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 42951d6089..c90880bc9f 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -201,13 +201,19 @@ void SEN5XComponent::setup() { ESP_LOGE(TAG, "Failed to read RHT Acceleration mode"); } } - if (this->voc_tuning_params_.has_value()) + if (this->voc_tuning_params_.has_value()) { this->write_tuning_parameters_(SEN5X_CMD_VOC_ALGORITHM_TUNING, this->voc_tuning_params_.value()); - if (this->nox_tuning_params_.has_value()) + delay(20); + } + if (this->nox_tuning_params_.has_value()) { this->write_tuning_parameters_(SEN5X_CMD_NOX_ALGORITHM_TUNING, this->nox_tuning_params_.value()); + delay(20); + } - if (this->temperature_compensation_.has_value()) + if (this->temperature_compensation_.has_value()) { this->write_temperature_compensation_(this->temperature_compensation_.value()); + delay(20); + } // Finally start sensor measurements auto cmd = SEN5X_CMD_START_MEASUREMENTS_RHT_ONLY; diff --git a/esphome/components/sen5x/sen5x.h b/esphome/components/sen5x/sen5x.h index f306003a82..6d90636a89 100644 --- a/esphome/components/sen5x/sen5x.h +++ b/esphome/components/sen5x/sen5x.h @@ -41,8 +41,8 @@ struct GasTuning { }; struct TemperatureCompensation { - uint16_t offset; - uint16_t normalized_offset_slope; + int16_t offset; + int16_t normalized_offset_slope; uint16_t time_constant; }; @@ -70,27 +70,33 @@ class SEN5XComponent : public PollingComponent, public sensirion_common::Sensiri void set_voc_algorithm_tuning(uint16_t index_offset, uint16_t learning_time_offset_hours, uint16_t learning_time_gain_hours, uint16_t gating_max_duration_minutes, uint16_t std_initial, uint16_t gain_factor) { - voc_tuning_params_.value().index_offset = index_offset; - voc_tuning_params_.value().learning_time_offset_hours = learning_time_offset_hours; - voc_tuning_params_.value().learning_time_gain_hours = learning_time_gain_hours; - voc_tuning_params_.value().gating_max_duration_minutes = gating_max_duration_minutes; - voc_tuning_params_.value().std_initial = std_initial; - voc_tuning_params_.value().gain_factor = gain_factor; + GasTuning tuning_params; + tuning_params.index_offset = index_offset; + tuning_params.learning_time_offset_hours = learning_time_offset_hours; + tuning_params.learning_time_gain_hours = learning_time_gain_hours; + tuning_params.gating_max_duration_minutes = gating_max_duration_minutes; + tuning_params.std_initial = std_initial; + tuning_params.gain_factor = gain_factor; + voc_tuning_params_ = tuning_params; } void set_nox_algorithm_tuning(uint16_t index_offset, uint16_t learning_time_offset_hours, uint16_t learning_time_gain_hours, uint16_t gating_max_duration_minutes, uint16_t gain_factor) { - nox_tuning_params_.value().index_offset = index_offset; - nox_tuning_params_.value().learning_time_offset_hours = learning_time_offset_hours; - nox_tuning_params_.value().learning_time_gain_hours = learning_time_gain_hours; - nox_tuning_params_.value().gating_max_duration_minutes = gating_max_duration_minutes; - nox_tuning_params_.value().std_initial = 50; - nox_tuning_params_.value().gain_factor = gain_factor; + GasTuning tuning_params; + tuning_params.index_offset = index_offset; + tuning_params.learning_time_offset_hours = learning_time_offset_hours; + tuning_params.learning_time_gain_hours = learning_time_gain_hours; + tuning_params.gating_max_duration_minutes = gating_max_duration_minutes; + tuning_params.std_initial = 50; + tuning_params.gain_factor = gain_factor; + nox_tuning_params_ = tuning_params; } void set_temperature_compensation(float offset, float normalized_offset_slope, uint16_t time_constant) { - temperature_compensation_.value().offset = offset * 200; - temperature_compensation_.value().normalized_offset_slope = normalized_offset_slope * 100; - temperature_compensation_.value().time_constant = time_constant; + TemperatureCompensation temp_comp; + temp_comp.offset = offset * 200; + temp_comp.normalized_offset_slope = normalized_offset_slope * 10000; + temp_comp.time_constant = time_constant; + temperature_compensation_ = temp_comp; } bool start_fan_cleaning(); diff --git a/esphome/components/sen5x/sensor.py b/esphome/components/sen5x/sensor.py index 392510e417..4bc4a138a3 100644 --- a/esphome/components/sen5x/sensor.py +++ b/esphome/components/sen5x/sensor.py @@ -88,6 +88,15 @@ GAS_SENSOR = cv.Schema( } ) + +def float_previously_pct(value): + if isinstance(value, str) and "%" in value: + raise cv.Invalid( + f"The value '{value}' is a percentage. Suggested value: {float(value.strip('%')) / 100}" + ) + return value + + CONFIG_SCHEMA = ( cv.Schema( { @@ -151,7 +160,9 @@ CONFIG_SCHEMA = ( cv.Optional(CONF_TEMPERATURE_COMPENSATION): cv.Schema( { cv.Optional(CONF_OFFSET, default=0): cv.float_, - cv.Optional(CONF_NORMALIZED_OFFSET_SLOPE, default=0): cv.percentage, + cv.Optional(CONF_NORMALIZED_OFFSET_SLOPE, default=0): cv.All( + float_previously_pct, cv.float_ + ), cv.Optional(CONF_TIME_CONSTANT, default=0): cv.int_, } ), diff --git a/esphome/components/ssd1306_base/__init__.py b/esphome/components/ssd1306_base/__init__.py index f4abd845c8..55239dfcb8 100644 --- a/esphome/components/ssd1306_base/__init__.py +++ b/esphome/components/ssd1306_base/__init__.py @@ -33,6 +33,7 @@ MODELS = { "SH1106_96X16": SSD1306Model.SH1106_MODEL_96_16, "SH1106_64X48": SSD1306Model.SH1106_MODEL_64_48, "SH1107_128X64": SSD1306Model.SH1107_MODEL_128_64, + "SH1107_128X128": SSD1306Model.SH1107_MODEL_128_128, "SSD1305_128X32": SSD1306Model.SSD1305_MODEL_128_32, "SSD1305_128X64": SSD1306Model.SSD1305_MODEL_128_64, } @@ -63,8 +64,10 @@ SSD1306_SCHEMA = display.FULL_DISPLAY_SCHEMA.extend( cv.Optional(CONF_EXTERNAL_VCC): cv.boolean, cv.Optional(CONF_FLIP_X, default=True): cv.boolean, cv.Optional(CONF_FLIP_Y, default=True): cv.boolean, - cv.Optional(CONF_OFFSET_X, default=0): cv.int_range(min=-32, max=32), - cv.Optional(CONF_OFFSET_Y, default=0): cv.int_range(min=-32, max=32), + # Offsets determine shifts of memory location to LCD rows/columns, + # and this family of controllers supports up to 128x128 screens + cv.Optional(CONF_OFFSET_X, default=0): cv.int_range(min=0, max=128), + cv.Optional(CONF_OFFSET_Y, default=0): cv.int_range(min=0, max=128), cv.Optional(CONF_INVERT, default=False): cv.boolean, } ).extend(cv.polling_component_schema("1s")) diff --git a/esphome/components/ssd1306_base/ssd1306_base.cpp b/esphome/components/ssd1306_base/ssd1306_base.cpp index 3cacd473d1..749c3511c1 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.cpp +++ b/esphome/components/ssd1306_base/ssd1306_base.cpp @@ -35,16 +35,31 @@ static const uint8_t SSD1306_COMMAND_INVERSE_DISPLAY = 0xA7; static const uint8_t SSD1305_COMMAND_SET_BRIGHTNESS = 0x82; static const uint8_t SSD1305_COMMAND_SET_AREA_COLOR = 0xD8; +static const uint8_t SH1107_COMMAND_SET_START_LINE = 0xDC; +static const uint8_t SH1107_COMMAND_CHARGE_PUMP = 0xAD; + void SSD1306::setup() { this->init_internal_(this->get_buffer_length_()); + // SH1107 resources + // + // Datasheet v2.3: + // www.displayfuture.com/Display/datasheet/controller/SH1107.pdf + // Adafruit C++ driver: + // github.com/adafruit/Adafruit_SH110x + // Adafruit CircuitPython driver: + // github.com/adafruit/Adafruit_CircuitPython_DisplayIO_SH1107 + // Turn off display during initialization (0xAE) this->command(SSD1306_COMMAND_DISPLAY_OFF); - // Set oscillator frequency to 4'b1000 with no clock division (0xD5) - this->command(SSD1306_COMMAND_SET_DISPLAY_CLOCK_DIV); - // Oscillator frequency <= 4'b1000, no clock division - this->command(0x80); + // If SH1107, use POR defaults (0x50) = divider 1, frequency +0% + if (!this->is_sh1107_()) { + // Set oscillator frequency to 4'b1000 with no clock division (0xD5) + this->command(SSD1306_COMMAND_SET_DISPLAY_CLOCK_DIV); + // Oscillator frequency <= 4'b1000, no clock division + this->command(0x80); + } // Enable low power display mode for SSD1305 (0xD8) if (this->is_ssd1305_()) { @@ -60,11 +75,26 @@ void SSD1306::setup() { this->command(SSD1306_COMMAND_SET_DISPLAY_OFFSET_Y); this->command(0x00 + this->offset_y_); - // Set start line at line 0 (0x40) - this->command(SSD1306_COMMAND_SET_START_LINE | 0x00); + if (this->is_sh1107_()) { + // Set start line at line 0 (0xDC) + this->command(SH1107_COMMAND_SET_START_LINE); + this->command(0x00); + } else { + // Set start line at line 0 (0x40) + this->command(SSD1306_COMMAND_SET_START_LINE | 0x00); + } - // SSD1305 does not have charge pump - if (!this->is_ssd1305_()) { + if (this->is_ssd1305_()) { + // SSD1305 does not have charge pump + } else if (this->is_sh1107_()) { + // Enable charge pump (0xAD) + this->command(SH1107_COMMAND_CHARGE_PUMP); + if (this->external_vcc_) { + this->command(0x8A); + } else { + this->command(0x8B); + } + } else { // Enable charge pump (0x8D) this->command(SSD1306_COMMAND_CHARGE_PUMP); if (this->external_vcc_) { @@ -76,34 +106,41 @@ void SSD1306::setup() { // Set addressing mode to horizontal (0x20) this->command(SSD1306_COMMAND_MEMORY_MODE); - this->command(0x00); - + if (!this->is_sh1107_()) { + // SH1107 memory mode is a 1 byte command + this->command(0x00); + } // X flip mode (0xA0, 0xA1) this->command(SSD1306_COMMAND_SEGRE_MAP | this->flip_x_); // Y flip mode (0xC0, 0xC8) this->command(SSD1306_COMMAND_COM_SCAN_INC | (this->flip_y_ << 3)); - // Set pin configuration (0xDA) - this->command(SSD1306_COMMAND_SET_COM_PINS); - switch (this->model_) { - case SSD1306_MODEL_128_32: - case SH1106_MODEL_128_32: - case SSD1306_MODEL_96_16: - case SH1106_MODEL_96_16: - this->command(0x02); - break; - case SSD1306_MODEL_128_64: - case SH1106_MODEL_128_64: - case SSD1306_MODEL_64_48: - case SSD1306_MODEL_64_32: - case SH1106_MODEL_64_48: - case SH1107_MODEL_128_64: - case SSD1305_MODEL_128_32: - case SSD1305_MODEL_128_64: - case SSD1306_MODEL_72_40: - this->command(0x12); - break; + if (!this->is_sh1107_()) { + // Set pin configuration (0xDA) + this->command(SSD1306_COMMAND_SET_COM_PINS); + switch (this->model_) { + case SSD1306_MODEL_128_32: + case SH1106_MODEL_128_32: + case SSD1306_MODEL_96_16: + case SH1106_MODEL_96_16: + this->command(0x02); + break; + case SSD1306_MODEL_128_64: + case SH1106_MODEL_128_64: + case SSD1306_MODEL_64_48: + case SSD1306_MODEL_64_32: + case SH1106_MODEL_64_48: + case SSD1305_MODEL_128_32: + case SSD1305_MODEL_128_64: + case SSD1306_MODEL_72_40: + this->command(0x12); + break; + case SH1107_MODEL_128_64: + case SH1107_MODEL_128_128: + // Not used, but prevents build warning + break; + } } // Pre-charge period (0xD9) @@ -117,7 +154,9 @@ void SSD1306::setup() { // Set V_COM (0xDB) this->command(SSD1306_COMMAND_SET_VCOM_DETECT); switch (this->model_) { + case SH1106_MODEL_128_64: case SH1107_MODEL_128_64: + case SH1107_MODEL_128_128: this->command(0x35); break; case SSD1306_MODEL_72_40: @@ -149,7 +188,7 @@ void SSD1306::setup() { this->turn_on(); } void SSD1306::display() { - if (this->is_sh1106_()) { + if (this->is_sh1106_() || this->is_sh1107_()) { this->write_display_data(); return; } @@ -183,6 +222,7 @@ bool SSD1306::is_sh1106_() const { return this->model_ == SH1106_MODEL_96_16 || this->model_ == SH1106_MODEL_128_32 || this->model_ == SH1106_MODEL_128_64; } +bool SSD1306::is_sh1107_() const { return this->model_ == SH1107_MODEL_128_64 || this->model_ == SH1107_MODEL_128_128; } bool SSD1306::is_ssd1305_() const { return this->model_ == SSD1305_MODEL_128_64 || this->model_ == SSD1305_MODEL_128_64; } @@ -224,6 +264,7 @@ void SSD1306::turn_off() { int SSD1306::get_height_internal() { switch (this->model_) { case SH1107_MODEL_128_64: + case SH1107_MODEL_128_128: return 128; case SSD1306_MODEL_128_32: case SSD1306_MODEL_64_32: @@ -254,6 +295,7 @@ int SSD1306::get_width_internal() { case SH1106_MODEL_128_64: case SSD1305_MODEL_128_32: case SSD1305_MODEL_128_64: + case SH1107_MODEL_128_128: return 128; case SSD1306_MODEL_96_16: case SH1106_MODEL_96_16: diff --git a/esphome/components/ssd1306_base/ssd1306_base.h b/esphome/components/ssd1306_base/ssd1306_base.h index 4b0e9bb80e..34b76d284d 100644 --- a/esphome/components/ssd1306_base/ssd1306_base.h +++ b/esphome/components/ssd1306_base/ssd1306_base.h @@ -19,6 +19,7 @@ enum SSD1306Model { SH1106_MODEL_96_16, SH1106_MODEL_64_48, SH1107_MODEL_128_64, + SH1107_MODEL_128_128, SSD1305_MODEL_128_32, SSD1305_MODEL_128_64, }; @@ -58,6 +59,7 @@ class SSD1306 : public PollingComponent, public display::DisplayBuffer { void init_reset_(); bool is_sh1106_() const; + bool is_sh1107_() const; bool is_ssd1305_() const; void draw_absolute_pixel_internal(int x, int y, Color color) override; diff --git a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp index 96734eb618..ed7cf102ee 100644 --- a/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp +++ b/esphome/components/ssd1306_i2c/ssd1306_i2c.cpp @@ -38,13 +38,19 @@ void I2CSSD1306::dump_config() { } void I2CSSD1306::command(uint8_t value) { this->write_byte(0x00, value); } void HOT I2CSSD1306::write_display_data() { - if (this->is_sh1106_()) { + if (this->is_sh1106_() || this->is_sh1107_()) { uint32_t i = 0; for (uint8_t page = 0; page < (uint8_t) this->get_height_internal() / 8; page++) { this->command(0xB0 + page); // row - this->command(0x02); // lower column - this->command(0x10); // higher column - + if (this->is_sh1106_()) { + this->command(0x02); // lower column - 0x02 is historical SH1106 value + } else { + // Other SH1107 drivers use 0x00 + // Column values dont change and it seems they can be set only once, + // but we follow SH1106 implementation and resend them + this->command(0x00); + } + this->command(0x10); // higher column for (uint8_t x = 0; x < (uint8_t) this->get_width_internal() / 16; x++) { uint8_t data[16]; for (uint8_t &j : data) diff --git a/esphome/components/ssd1306_spi/ssd1306_spi.cpp b/esphome/components/ssd1306_spi/ssd1306_spi.cpp index 7f025d77cd..0a0debfd65 100644 --- a/esphome/components/ssd1306_spi/ssd1306_spi.cpp +++ b/esphome/components/ssd1306_spi/ssd1306_spi.cpp @@ -36,10 +36,14 @@ void SPISSD1306::command(uint8_t value) { this->disable(); } void HOT SPISSD1306::write_display_data() { - if (this->is_sh1106_()) { + if (this->is_sh1106_() || this->is_sh1107_()) { for (uint8_t y = 0; y < (uint8_t) this->get_height_internal() / 8; y++) { this->command(0xB0 + y); - this->command(0x02); + if (this->is_sh1106_()) { + this->command(0x02); + } else { + this->command(0x00); + } this->command(0x10); this->dc_pin_->digital_write(true); for (uint8_t x = 0; x < (uint8_t) this->get_width_internal(); x++) { diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index 36f2bb5851..9005422ce6 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -75,12 +75,13 @@ def validate_rx_pin(value): def validate_invert_esp32(config): if ( CORE.is_esp32 + and CORE.using_arduino and CONF_TX_PIN in config and CONF_RX_PIN in config and config[CONF_TX_PIN][CONF_INVERTED] != config[CONF_RX_PIN][CONF_INVERTED] ): raise cv.Invalid( - "Different invert values for TX and RX pin are not (yet) supported for ESP32." + "Different invert values for TX and RX pin are not supported for ESP32 when using Arduino." ) return config diff --git a/esphome/components/voice_assistant/__init__.py b/esphome/components/voice_assistant/__init__.py index 3270b9f370..d05f39072c 100644 --- a/esphome/components/voice_assistant/__init__.py +++ b/esphome/components/voice_assistant/__init__.py @@ -18,20 +18,27 @@ DEPENDENCIES = ["api", "microphone"] CODEOWNERS = ["@jesserockz"] -CONF_SILENCE_DETECTION = "silence_detection" -CONF_ON_LISTENING = "on_listening" -CONF_ON_START = "on_start" -CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" -CONF_ON_STT_END = "on_stt_end" -CONF_ON_TTS_START = "on_tts_start" -CONF_ON_TTS_END = "on_tts_end" CONF_ON_END = "on_end" CONF_ON_ERROR = "on_error" +CONF_ON_INTENT_END = "on_intent_end" +CONF_ON_INTENT_START = "on_intent_start" +CONF_ON_LISTENING = "on_listening" +CONF_ON_START = "on_start" +CONF_ON_STT_END = "on_stt_end" +CONF_ON_STT_VAD_END = "on_stt_vad_end" +CONF_ON_STT_VAD_START = "on_stt_vad_start" +CONF_ON_TTS_END = "on_tts_end" +CONF_ON_TTS_START = "on_tts_start" +CONF_ON_TTS_STREAM_START = "on_tts_stream_start" +CONF_ON_TTS_STREAM_END = "on_tts_stream_end" +CONF_ON_WAKE_WORD_DETECTED = "on_wake_word_detected" + +CONF_SILENCE_DETECTION = "silence_detection" CONF_USE_WAKE_WORD = "use_wake_word" CONF_VAD_THRESHOLD = "vad_threshold" -CONF_NOISE_SUPPRESSION_LEVEL = "noise_suppression_level" CONF_AUTO_GAIN = "auto_gain" +CONF_NOISE_SUPPRESSION_LEVEL = "noise_suppression_level" CONF_VOLUME_MULTIPLIER = "volume_multiplier" @@ -51,6 +58,17 @@ IsRunningCondition = voice_assistant_ns.class_( "IsRunningCondition", automation.Condition, cg.Parented.template(VoiceAssistant) ) + +def tts_stream_validate(config): + if CONF_SPEAKER not in config and ( + CONF_ON_TTS_STREAM_START in config or CONF_ON_TTS_STREAM_END in config + ): + raise cv.Invalid( + f"{CONF_SPEAKER} is required when using {CONF_ON_TTS_STREAM_START} and/or {CONF_ON_TTS_STREAM_END}" + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -88,8 +106,27 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation( single=True ), + cv.Optional(CONF_ON_INTENT_START): automation.validate_automation( + single=True + ), + cv.Optional(CONF_ON_INTENT_END): automation.validate_automation( + single=True + ), + cv.Optional(CONF_ON_STT_VAD_START): automation.validate_automation( + single=True + ), + cv.Optional(CONF_ON_STT_VAD_END): automation.validate_automation( + single=True + ), + cv.Optional(CONF_ON_TTS_STREAM_START): automation.validate_automation( + single=True + ), + cv.Optional(CONF_ON_TTS_STREAM_END): automation.validate_automation( + single=True + ), } ).extend(cv.COMPONENT_SCHEMA), + tts_stream_validate, ) @@ -177,6 +214,48 @@ async def to_code(config): config[CONF_ON_CLIENT_DISCONNECTED], ) + if CONF_ON_INTENT_START in config: + await automation.build_automation( + var.get_intent_start_trigger(), + [], + config[CONF_ON_INTENT_START], + ) + + if CONF_ON_INTENT_END in config: + await automation.build_automation( + var.get_intent_end_trigger(), + [], + config[CONF_ON_INTENT_END], + ) + + if CONF_ON_STT_VAD_START in config: + await automation.build_automation( + var.get_stt_vad_start_trigger(), + [], + config[CONF_ON_STT_VAD_START], + ) + + if CONF_ON_STT_VAD_END in config: + await automation.build_automation( + var.get_stt_vad_end_trigger(), + [], + config[CONF_ON_STT_VAD_END], + ) + + if CONF_ON_TTS_STREAM_START in config: + await automation.build_automation( + var.get_tts_stream_start_trigger(), + [], + config[CONF_ON_TTS_STREAM_START], + ) + + if CONF_ON_TTS_STREAM_END in config: + await automation.build_automation( + var.get_tts_stream_end_trigger(), + [], + config[CONF_ON_TTS_STREAM_END], + ) + cg.add_define("USE_VOICE_ASSISTANT") diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index d15d702d4b..9b13a71039 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -31,7 +31,7 @@ void VoiceAssistant::setup() { this->socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (socket_ == nullptr) { - ESP_LOGW(TAG, "Could not create socket."); + ESP_LOGW(TAG, "Could not create socket"); this->mark_failed(); return; } @@ -69,7 +69,7 @@ void VoiceAssistant::setup() { ExternalRAMAllocator speaker_allocator(ExternalRAMAllocator::ALLOW_FAILURE); this->speaker_buffer_ = speaker_allocator.allocate(SPEAKER_BUFFER_SIZE); if (this->speaker_buffer_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate speaker buffer."); + ESP_LOGW(TAG, "Could not allocate speaker buffer"); this->mark_failed(); return; } @@ -79,7 +79,7 @@ void VoiceAssistant::setup() { ExternalRAMAllocator allocator(ExternalRAMAllocator::ALLOW_FAILURE); this->input_buffer_ = allocator.allocate(INPUT_BUFFER_SIZE); if (this->input_buffer_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate input buffer."); + ESP_LOGW(TAG, "Could not allocate input buffer"); this->mark_failed(); return; } @@ -89,7 +89,7 @@ void VoiceAssistant::setup() { this->ring_buffer_ = rb_create(BUFFER_SIZE, sizeof(int16_t)); if (this->ring_buffer_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate ring buffer."); + ESP_LOGW(TAG, "Could not allocate ring buffer"); this->mark_failed(); return; } @@ -98,7 +98,7 @@ void VoiceAssistant::setup() { ExternalRAMAllocator send_allocator(ExternalRAMAllocator::ALLOW_FAILURE); this->send_buffer_ = send_allocator.allocate(SEND_BUFFER_SIZE); if (send_buffer_ == nullptr) { - ESP_LOGW(TAG, "Could not allocate send buffer."); + ESP_LOGW(TAG, "Could not allocate send buffer"); this->mark_failed(); return; } @@ -221,8 +221,8 @@ void VoiceAssistant::loop() { msg.audio_settings = audio_settings; if (this->api_client_ == nullptr || !this->api_client_->send_voice_assistant_request(msg)) { - ESP_LOGW(TAG, "Could not request start."); - this->error_trigger_->trigger("not-connected", "Could not request start."); + ESP_LOGW(TAG, "Could not request start"); + this->error_trigger_->trigger("not-connected", "Could not request start"); this->continuous_ = false; this->set_state_(State::IDLE, State::IDLE); break; @@ -280,7 +280,7 @@ void VoiceAssistant::loop() { this->speaker_buffer_size_ += len; } } else { - ESP_LOGW(TAG, "Receive buffer full."); + ESP_LOGW(TAG, "Receive buffer full"); } if (this->speaker_buffer_size_ > 0) { size_t written = this->speaker_->play(this->speaker_buffer_, this->speaker_buffer_size_); @@ -290,7 +290,7 @@ void VoiceAssistant::loop() { this->speaker_buffer_index_ -= written; this->set_timeout("speaker-timeout", 2000, [this]() { this->speaker_->stop(); }); } else { - ESP_LOGW(TAG, "Speaker buffer full."); + ESP_LOGW(TAG, "Speaker buffer full"); } } if (this->wait_for_stream_end_) { @@ -513,7 +513,7 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { break; } case api::enums::VOICE_ASSISTANT_STT_START: - ESP_LOGD(TAG, "STT Started"); + ESP_LOGD(TAG, "STT started"); this->listening_trigger_->trigger(); break; case api::enums::VOICE_ASSISTANT_STT_END: { @@ -525,19 +525,24 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { } } if (text.empty()) { - ESP_LOGW(TAG, "No text in STT_END event."); + ESP_LOGW(TAG, "No text in STT_END event"); return; } ESP_LOGD(TAG, "Speech recognised as: \"%s\"", text.c_str()); this->stt_end_trigger_->trigger(text); break; } + case api::enums::VOICE_ASSISTANT_INTENT_START: + ESP_LOGD(TAG, "Intent started"); + this->intent_start_trigger_->trigger(); + break; case api::enums::VOICE_ASSISTANT_INTENT_END: { for (auto arg : msg.data) { if (arg.name == "conversation_id") { this->conversation_id_ = std::move(arg.value); } } + this->intent_end_trigger_->trigger(); break; } case api::enums::VOICE_ASSISTANT_TTS_START: { @@ -548,7 +553,7 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { } } if (text.empty()) { - ESP_LOGW(TAG, "No text in TTS_START event."); + ESP_LOGW(TAG, "No text in TTS_START event"); return; } ESP_LOGD(TAG, "Response: \"%s\"", text.c_str()); @@ -566,7 +571,7 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { } } if (url.empty()) { - ESP_LOGW(TAG, "No url in TTS_END event."); + ESP_LOGW(TAG, "No url in TTS_END event"); return; } ESP_LOGD(TAG, "Response URL: \"%s\"", url.c_str()); @@ -610,6 +615,11 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { if (code == "wake-word-timeout" || code == "wake_word_detection_aborted") { // Don't change state here since either the "tts-end" or "run-end" events will do it. return; + } else if (code == "wake-provider-missing" || code == "wake-engine-missing") { + // Wake word is not set up or not ready on Home Assistant so stop and do not retry until user starts again. + this->request_stop(); + this->error_trigger_->trigger(code, message); + return; } ESP_LOGE(TAG, "Error: %s - %s", code.c_str(), message.c_str()); if (this->state_ != State::IDLE) { @@ -622,13 +632,27 @@ void VoiceAssistant::on_event(const api::VoiceAssistantEventResponse &msg) { case api::enums::VOICE_ASSISTANT_TTS_STREAM_START: { #ifdef USE_SPEAKER this->wait_for_stream_end_ = true; + ESP_LOGD(TAG, "TTS stream start"); + this->tts_stream_start_trigger_->trigger(); #endif break; } case api::enums::VOICE_ASSISTANT_TTS_STREAM_END: { this->set_state_(State::RESPONSE_FINISHED, State::IDLE); +#ifdef USE_SPEAKER + ESP_LOGD(TAG, "TTS stream end"); + this->tts_stream_end_trigger_->trigger(); +#endif break; } + case api::enums::VOICE_ASSISTANT_STT_VAD_START: + ESP_LOGD(TAG, "Starting STT by VAD"); + this->stt_vad_start_trigger_->trigger(); + break; + case api::enums::VOICE_ASSISTANT_STT_VAD_END: + ESP_LOGD(TAG, "STT by VAD end"); + this->stt_vad_end_trigger_->trigger(); + break; default: ESP_LOGD(TAG, "Unhandled event type: %d", msg.event_type); break; diff --git a/esphome/components/voice_assistant/voice_assistant.h b/esphome/components/voice_assistant/voice_assistant.h index a265522bca..f6dcd1c563 100644 --- a/esphome/components/voice_assistant/voice_assistant.h +++ b/esphome/components/voice_assistant/voice_assistant.h @@ -100,13 +100,21 @@ class VoiceAssistant : public Component { void set_auto_gain(uint8_t auto_gain) { this->auto_gain_ = auto_gain; } void set_volume_multiplier(float volume_multiplier) { this->volume_multiplier_ = volume_multiplier; } + Trigger<> *get_intent_end_trigger() const { return this->intent_end_trigger_; } + Trigger<> *get_intent_start_trigger() const { return this->intent_start_trigger_; } Trigger<> *get_listening_trigger() const { return this->listening_trigger_; } + Trigger<> *get_end_trigger() const { return this->end_trigger_; } Trigger<> *get_start_trigger() const { return this->start_trigger_; } + Trigger<> *get_stt_vad_end_trigger() const { return this->stt_vad_end_trigger_; } + Trigger<> *get_stt_vad_start_trigger() const { return this->stt_vad_start_trigger_; } +#ifdef USE_SPEAKER + Trigger<> *get_tts_stream_start_trigger() const { return this->tts_stream_start_trigger_; } + Trigger<> *get_tts_stream_end_trigger() const { return this->tts_stream_end_trigger_; } +#endif Trigger<> *get_wake_word_detected_trigger() const { return this->wake_word_detected_trigger_; } Trigger *get_stt_end_trigger() const { return this->stt_end_trigger_; } - Trigger *get_tts_start_trigger() const { return this->tts_start_trigger_; } Trigger *get_tts_end_trigger() const { return this->tts_end_trigger_; } - Trigger<> *get_end_trigger() const { return this->end_trigger_; } + Trigger *get_tts_start_trigger() const { return this->tts_start_trigger_; } Trigger *get_error_trigger() const { return this->error_trigger_; } Trigger<> *get_client_connected_trigger() const { return this->client_connected_trigger_; } @@ -124,13 +132,21 @@ class VoiceAssistant : public Component { std::unique_ptr socket_ = nullptr; struct sockaddr_storage dest_addr_; + Trigger<> *intent_end_trigger_ = new Trigger<>(); + Trigger<> *intent_start_trigger_ = new Trigger<>(); Trigger<> *listening_trigger_ = new Trigger<>(); + Trigger<> *end_trigger_ = new Trigger<>(); Trigger<> *start_trigger_ = new Trigger<>(); + Trigger<> *stt_vad_start_trigger_ = new Trigger<>(); + Trigger<> *stt_vad_end_trigger_ = new Trigger<>(); +#ifdef USE_SPEAKER + Trigger<> *tts_stream_start_trigger_ = new Trigger<>(); + Trigger<> *tts_stream_end_trigger_ = new Trigger<>(); +#endif Trigger<> *wake_word_detected_trigger_ = new Trigger<>(); Trigger *stt_end_trigger_ = new Trigger(); - Trigger *tts_start_trigger_ = new Trigger(); Trigger *tts_end_trigger_ = new Trigger(); - Trigger<> *end_trigger_ = new Trigger<>(); + Trigger *tts_start_trigger_ = new Trigger(); Trigger *error_trigger_ = new Trigger(); Trigger<> *client_connected_trigger_ = new Trigger<>(); diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index c42835f169..32c9d07046 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -403,6 +403,10 @@ async def to_code(config): lambda ap: cg.add(var.set_ap(wifi_network(conf, ap, ip_config))), ) cg.add(var.set_ap_timeout(conf[CONF_AP_TIMEOUT])) + cg.add_define("USE_WIFI_AP") + elif CORE.is_esp32 and CORE.using_esp_idf: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_SOFTAP_SUPPORT", False) + add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index cd87bb48a7..d023405728 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -82,6 +82,7 @@ void WiFiComponent::start() { } else { this->start_scanning(); } +#ifdef USE_WIFI_AP } else if (this->has_ap()) { this->setup_ap_config_(); if (this->output_power_.has_value() && !this->wifi_apply_output_power_(*this->output_power_)) { @@ -94,6 +95,7 @@ void WiFiComponent::start() { captive_portal::global_captive_portal->start(); } #endif +#endif // USE_WIFI_AP } #ifdef USE_IMPROV if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) { @@ -160,6 +162,7 @@ void WiFiComponent::loop() { return; } +#ifdef USE_WIFI_AP if (this->has_ap() && !this->ap_setup_) { if (now - this->last_connected_ > this->ap_timeout_) { ESP_LOGI(TAG, "Starting fallback AP!"); @@ -170,6 +173,7 @@ void WiFiComponent::loop() { #endif } } +#endif // USE_WIFI_AP #ifdef USE_IMPROV if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active()) { @@ -199,11 +203,16 @@ void WiFiComponent::set_fast_connect(bool fast_connect) { this->fast_connect_ = void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; } #endif + network::IPAddress WiFiComponent::get_ip_address() { if (this->has_sta()) return this->wifi_sta_ip(); + +#ifdef USE_WIFI_AP if (this->has_ap()) return this->wifi_soft_ap_ip(); +#endif // USE_WIFI_AP + return {}; } network::IPAddress WiFiComponent::get_dns_address(int num) { @@ -218,6 +227,8 @@ std::string WiFiComponent::get_use_address() const { return this->use_address_; } void WiFiComponent::set_use_address(const std::string &use_address) { this->use_address_ = use_address; } + +#ifdef USE_WIFI_AP void WiFiComponent::setup_ap_config_() { this->wifi_mode_({}, true); @@ -255,13 +266,16 @@ void WiFiComponent::setup_ap_config_() { } } -float WiFiComponent::get_loop_priority() const { - return 10.0f; // before other loop components -} void WiFiComponent::set_ap(const WiFiAP &ap) { this->ap_ = ap; this->has_ap_ = true; } +#endif // USE_WIFI_AP + +float WiFiComponent::get_loop_priority() const { + return 10.0f; // before other loop components +} + void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); } void WiFiComponent::set_sta(const WiFiAP &ap) { this->clear_sta(); @@ -389,6 +403,10 @@ void WiFiComponent::print_connect_params_() { bssid_t bssid = wifi_bssid(); ESP_LOGCONFIG(TAG, " Local MAC: %s", get_mac_address_pretty().c_str()); + if (this->is_disabled()) { + ESP_LOGCONFIG(TAG, " WiFi is disabled!"); + return; + } ESP_LOGCONFIG(TAG, " SSID: " LOG_SECRET("'%s'"), wifi_ssid().c_str()); ESP_LOGCONFIG(TAG, " IP Address: %s", wifi_sta_ip().str().c_str()); ESP_LOGCONFIG(TAG, " BSSID: " LOG_SECRET("%02X:%02X:%02X:%02X:%02X:%02X"), bssid[0], bssid[1], bssid[2], bssid[3], diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 3ee69bb5de..6cbdc51caf 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -194,6 +194,7 @@ class WiFiComponent : public Component { void add_sta(const WiFiAP &ap); void clear_sta(); +#ifdef USE_WIFI_AP /** Setup an Access Point that should be created if no connection to a station can be made. * * This can also be used without set_sta(). Then the AP will always be active. @@ -203,6 +204,7 @@ class WiFiComponent : public Component { */ void set_ap(const WiFiAP &ap); WiFiAP get_ap() { return this->ap_; } +#endif // USE_WIFI_AP void enable(); void disable(); @@ -299,7 +301,11 @@ class WiFiComponent : public Component { protected: static std::string format_mac_addr(const uint8_t mac[6]); + +#ifdef USE_WIFI_AP void setup_ap_config_(); +#endif // USE_WIFI_AP + void print_connect_params_(); void wifi_loop_(); @@ -313,8 +319,12 @@ class WiFiComponent : public Component { void wifi_pre_setup_(); WiFiSTAConnectStatus wifi_sta_connect_status_(); bool wifi_scan_start_(bool passive); + +#ifdef USE_WIFI_AP bool wifi_ap_ip_config_(optional manual_ip); bool wifi_start_ap_(const WiFiAP &ap); +#endif // USE_WIFI_AP + bool wifi_disconnect_(); int32_t wifi_channel_(); network::IPAddress wifi_subnet_mask_(); diff --git a/esphome/components/wifi/wifi_component_esp32_arduino.cpp b/esphome/components/wifi/wifi_component_esp32_arduino.cpp index 17b15757ef..5d8aa7f749 100644 --- a/esphome/components/wifi/wifi_component_esp32_arduino.cpp +++ b/esphome/components/wifi/wifi_component_esp32_arduino.cpp @@ -597,6 +597,8 @@ void WiFiComponent::wifi_scan_done_callback_() { WiFi.scanDelete(); this->scan_done_ = true; } + +#ifdef USE_WIFI_AP bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { esp_err_t err; @@ -654,6 +656,7 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { return true; } + bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { // enable AP if (!this->wifi_mode_({}, true)) @@ -692,11 +695,14 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return true; } + network::IPAddress WiFiComponent::wifi_soft_ap_ip() { tcpip_adapter_ip_info_t ip; tcpip_adapter_get_ip_info(TCPIP_ADAPTER_IF_AP, &ip); return network::IPAddress(&ip.ip); } +#endif // USE_WIFI_AP + bool WiFiComponent::wifi_disconnect_() { return esp_wifi_disconnect(); } bssid_t WiFiComponent::wifi_bssid() { diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index a48c6c711d..15b0c65641 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -688,6 +688,8 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { } this->scan_done_ = true; } + +#ifdef USE_WIFI_AP bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { // enable AP if (!this->wifi_mode_({}, true)) @@ -753,6 +755,7 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { return true; } + bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { // enable AP if (!this->wifi_mode_({}, true)) @@ -790,11 +793,14 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return true; } + network::IPAddress WiFiComponent::wifi_soft_ap_ip() { struct ip_info ip {}; wifi_get_ip_info(SOFTAP_IF, &ip); return network::IPAddress(&ip.ip); } +#endif // USE_WIFI_AP + bssid_t WiFiComponent::wifi_bssid() { bssid_t bssid{}; uint8_t *raw_bssid = WiFi.BSSID(); diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 34ecaf887d..0035733553 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -17,7 +17,11 @@ #ifdef USE_WIFI_WPA2_EAP #include #endif + +#ifdef USE_WIFI_AP #include "dhcpserver/dhcpserver.h" +#endif // USE_WIFI_AP + #include "lwip/err.h" #include "lwip/dns.h" @@ -35,15 +39,19 @@ static const char *const TAG = "wifi_esp32"; static EventGroupHandle_t s_wifi_event_group; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static QueueHandle_t s_event_queue; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static esp_netif_t *s_sta_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_got_ip = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_ap_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connect_not_found = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_wifi_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +#ifdef USE_WIFI_AP +static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +#endif // USE_WIFI_AP + +static bool s_sta_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_sta_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_sta_got_ip = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_ap_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_sta_connect_not_found = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_wifi_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) struct IDFWiFiEvent { esp_event_base_t event_base; @@ -159,7 +167,11 @@ void WiFiComponent::wifi_pre_setup_() { } s_sta_netif = esp_netif_create_default_wifi_sta(); + +#ifdef USE_WIFI_AP s_ap_netif = esp_netif_create_default_wifi_ap(); +#endif // USE_WIFI_AP + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); // cfg.nvs_enable = false; err = esp_wifi_init(&cfg); @@ -674,6 +686,11 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { return; } + if (it.number == 0) { + // no results + return; + } + uint16_t number = it.number; std::vector records(number); err = esp_wifi_scan_get_ap_records(&number, records.data()); @@ -761,6 +778,8 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { scan_done_ = false; return true; } + +#ifdef USE_WIFI_AP bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { esp_err_t err; @@ -816,6 +835,7 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { return true; } + bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { // enable AP if (!this->wifi_mode_({}, true)) @@ -853,6 +873,8 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return true; } +#endif // USE_WIFI_AP + network::IPAddress WiFiComponent::wifi_soft_ap_ip() { esp_netif_ip_info_t ip; esp_netif_get_ip_info(s_sta_netif, &ip); diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index d7f4406540..29c6ce64d0 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -412,6 +412,8 @@ void WiFiComponent::wifi_scan_done_callback_() { WiFi.scanDelete(); this->scan_done_ = true; } + +#ifdef USE_WIFI_AP bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { // enable AP if (!this->wifi_mode_({}, true)) @@ -423,6 +425,7 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { return WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), IPAddress(255, 255, 255, 0)); } } + bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { // enable AP if (!this->wifi_mode_({}, true)) @@ -438,7 +441,10 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return WiFi.softAP(ap.get_ssid().c_str(), ap.get_password().empty() ? NULL : ap.get_password().c_str(), ap.get_channel().value_or(1), ap.get_hidden()); } + network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } +#endif // USE_WIFI_AP + bool WiFiComponent::wifi_disconnect_() { return WiFi.disconnect(); } bssid_t WiFiComponent::wifi_bssid() { diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index d67b466d6c..c71203a877 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -138,6 +138,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { return true; } +#ifdef USE_WIFI_AP bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { // TODO: return false; @@ -151,7 +152,9 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return true; } + network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {(const ip_addr_t *) WiFi.localIP()}; } +#endif // USE_WIFI_AP bool WiFiComponent::wifi_disconnect_() { int err = cyw43_wifi_leave(&cyw43_state, CYW43_ITF_STA); diff --git a/esphome/core/defines.h b/esphome/core/defines.h index d4187d4c08..b93b8c9270 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -50,6 +50,7 @@ #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER #define USE_WIFI +#define USE_WIFI_AP // Arduino-specific feature flags #ifdef USE_ARDUINO diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 714a1642f8..c95c0470de 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -278,10 +278,13 @@ std::string str_snake_case(const std::string &str) { return result; } std::string str_sanitize(const std::string &str) { - std::string out; - std::copy_if(str.begin(), str.end(), std::back_inserter(out), [](const char &c) { - return c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); - }); + std::string out = str; + std::replace_if( + out.begin(), out.end(), + [](const char &c) { + return !(c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')); + }, + '_'); return out; } std::string str_snprintf(const char *fmt, size_t len, ...) { diff --git a/esphome/dashboard/const.py b/esphome/dashboard/const.py new file mode 100644 index 0000000000..ed2b81d3e8 --- /dev/null +++ b/esphome/dashboard/const.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +EVENT_ENTRY_ADDED = "entry_added" +EVENT_ENTRY_REMOVED = "entry_removed" +EVENT_ENTRY_UPDATED = "entry_updated" +EVENT_ENTRY_STATE_CHANGED = "entry_state_changed" + +SENTINEL = object() diff --git a/esphome/dashboard/core.py b/esphome/dashboard/core.py new file mode 100644 index 0000000000..ffec9784e8 --- /dev/null +++ b/esphome/dashboard/core.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import asyncio +import logging +import threading +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING, Any, Callable + +from ..zeroconf import DiscoveredImport +from .entries import DashboardEntries +from .settings import DashboardSettings + +if TYPE_CHECKING: + from .status.mdns import MDNSStatus + + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class Event: + """Dashboard Event.""" + + event_type: str + data: dict[str, Any] + + +class EventBus: + """Dashboard event bus.""" + + def __init__(self) -> None: + """Initialize the Dashboard event bus.""" + self._listeners: dict[str, set[Callable[[Event], None]]] = {} + + def async_add_listener( + self, event_type: str, listener: Callable[[Event], None] + ) -> Callable[[], None]: + """Add a listener to the event bus.""" + self._listeners.setdefault(event_type, set()).add(listener) + return partial(self._async_remove_listener, event_type, listener) + + def _async_remove_listener( + self, event_type: str, listener: Callable[[Event], None] + ) -> None: + """Remove a listener from the event bus.""" + self._listeners[event_type].discard(listener) + + def async_fire(self, event_type: str, event_data: dict[str, Any]) -> None: + """Fire an event.""" + event = Event(event_type, event_data) + + _LOGGER.debug("Firing event: %s", event) + + for listener in self._listeners.get(event_type, set()): + listener(event) + + +class ESPHomeDashboard: + """Class that represents the dashboard.""" + + __slots__ = ( + "bus", + "entries", + "loop", + "import_result", + "stop_event", + "ping_request", + "mqtt_ping_request", + "mdns_status", + "settings", + ) + + def __init__(self) -> None: + """Initialize the ESPHomeDashboard.""" + self.bus = EventBus() + self.entries: DashboardEntries | None = None + self.loop: asyncio.AbstractEventLoop | None = None + self.import_result: dict[str, DiscoveredImport] = {} + self.stop_event = threading.Event() + self.ping_request: asyncio.Event | None = None + self.mqtt_ping_request = threading.Event() + self.mdns_status: MDNSStatus | None = None + self.settings: DashboardSettings = DashboardSettings() + + async def async_setup(self) -> None: + """Setup the dashboard.""" + self.loop = asyncio.get_running_loop() + self.ping_request = asyncio.Event() + self.entries = DashboardEntries(self) + + async def async_run(self) -> None: + """Run the dashboard.""" + settings = self.settings + mdns_task: asyncio.Task | None = None + ping_status_task: asyncio.Task | None = None + await self.entries.async_update_entries() + + if settings.status_use_ping: + from .status.ping import PingStatus + + ping_status = PingStatus() + ping_status_task = asyncio.create_task(ping_status.async_run()) + else: + from .status.mdns import MDNSStatus + + mdns_status = MDNSStatus() + await mdns_status.async_refresh_hosts() + self.mdns_status = mdns_status + mdns_task = asyncio.create_task(mdns_status.async_run()) + + if settings.status_use_mqtt: + from .status.mqtt import MqttStatusThread + + status_thread_mqtt = MqttStatusThread() + status_thread_mqtt.start() + + shutdown_event = asyncio.Event() + try: + await shutdown_event.wait() + finally: + _LOGGER.info("Shutting down...") + self.stop_event.set() + self.ping_request.set() + if ping_status_task: + ping_status_task.cancel() + if mdns_task: + mdns_task.cancel() + if settings.status_use_mqtt: + status_thread_mqtt.join() + self.mqtt_ping_request.set() + await asyncio.sleep(0) + + +DASHBOARD = ESPHomeDashboard() diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index 5967c95aba..789b14653c 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -1,1473 +1,21 @@ from __future__ import annotations -import base64 -import binascii -import codecs -import collections -import datetime -import functools -import gzip -import hashlib -import hmac -import json -import logging -import multiprocessing +import asyncio import os -import secrets -import shutil -import subprocess -import threading -from pathlib import Path +import socket -import tornado -import tornado.concurrent -import tornado.gen -import tornado.httpserver -import tornado.ioloop -import tornado.iostream -import tornado.netutil -import tornado.process -import tornado.queues -import tornado.web -import tornado.websocket -import yaml -from tornado.log import access_log +from esphome.storage_json import EsphomeStorageJSON, esphome_storage_path -from esphome import const, platformio_api, util, yaml_util -from esphome.core import CORE -from esphome.helpers import get_bool_env, mkdir_p, run_system_command -from esphome.storage_json import ( - EsphomeStorageJSON, - StorageJSON, - esphome_storage_path, - ext_storage_path, - trash_storage_path, -) -from esphome.util import get_serial_ports, shlex_quote -from esphome.zeroconf import ( - ESPHOME_SERVICE_TYPE, - DashboardBrowser, - DashboardImportDiscovery, - DashboardStatus, - EsphomeZeroconf, -) - -from .util import friendly_name_slugify, password_hash - -_LOGGER = logging.getLogger(__name__) +from .core import DASHBOARD +from .web_server import make_app, start_web_server ENV_DEV = "ESPHOME_DASHBOARD_DEV" +settings = DASHBOARD.settings -class DashboardSettings: - def __init__(self): - self.config_dir = "" - self.password_hash = "" - self.username = "" - self.using_password = False - self.on_ha_addon = False - self.cookie_secret = None - self.absolute_config_dir = None - self._entry_cache: dict[ - str, tuple[tuple[int, int, float, int], DashboardEntry] - ] = {} - def parse_args(self, args): - self.on_ha_addon = args.ha_addon - password = args.password or os.getenv("PASSWORD", "") - if not self.on_ha_addon: - self.username = args.username or os.getenv("USERNAME", "") - self.using_password = bool(password) - if self.using_password: - self.password_hash = password_hash(password) - self.config_dir = args.configuration - self.absolute_config_dir = Path(self.config_dir).resolve() - CORE.config_path = os.path.join(self.config_dir, ".") - - @property - def relative_url(self): - return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL", "/") - - @property - def status_use_ping(self): - return get_bool_env("ESPHOME_DASHBOARD_USE_PING") - - @property - def status_use_mqtt(self): - return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT") - - @property - def using_ha_addon_auth(self): - if not self.on_ha_addon: - return False - return not get_bool_env("DISABLE_HA_AUTHENTICATION") - - @property - def using_auth(self): - return self.using_password or self.using_ha_addon_auth - - @property - def streamer_mode(self): - return get_bool_env("ESPHOME_STREAMER_MODE") - - def check_password(self, username, password): - if not self.using_auth: - return True - if username != self.username: - return False - - # Compare password in constant running time (to prevent timing attacks) - return hmac.compare_digest(self.password_hash, password_hash(password)) - - def rel_path(self, *args): - joined_path = os.path.join(self.config_dir, *args) - # Raises ValueError if not relative to ESPHome config folder - Path(joined_path).resolve().relative_to(self.absolute_config_dir) - return joined_path - - def list_yaml_files(self) -> list[str]: - return util.list_yaml_files([self.config_dir]) - - def entries(self) -> list[DashboardEntry]: - """Fetch all dashboard entries, thread-safe.""" - path_to_cache_key: dict[str, tuple[int, int, float, int]] = {} - # - # The cache key is (inode, device, mtime, size) - # which allows us to avoid locking since it ensures - # every iteration of this call will always return the newest - # items from disk at the cost of a stat() call on each - # file which is much faster than reading the file - # for the cache hit case which is the common case. - # - # Because there is no lock the cache may - # get built more than once but that's fine as its still - # thread-safe and results in orders of magnitude less - # reads from disk than if we did not cache at all and - # does not have a lock contention issue. - # - for file in self.list_yaml_files(): - try: - # Prefer the json storage path if it exists - stat = os.stat(ext_storage_path(os.path.basename(file))) - except OSError: - try: - # Fallback to the yaml file if the storage - # file does not exist or could not be generated - stat = os.stat(file) - except OSError: - # File was deleted, ignore - continue - path_to_cache_key[file] = ( - stat.st_ino, - stat.st_dev, - stat.st_mtime, - stat.st_size, - ) - - entry_cache = self._entry_cache - - # Remove entries that no longer exist - removed: list[str] = [] - for file in entry_cache: - if file not in path_to_cache_key: - removed.append(file) - - for file in removed: - entry_cache.pop(file) - - dashboard_entries: list[DashboardEntry] = [] - for file, cache_key in path_to_cache_key.items(): - if cached_entry := entry_cache.get(file): - entry_key, dashboard_entry = cached_entry - if entry_key == cache_key: - dashboard_entries.append(dashboard_entry) - continue - - dashboard_entry = DashboardEntry(file) - dashboard_entries.append(dashboard_entry) - entry_cache[file] = (cache_key, dashboard_entry) - - return dashboard_entries - - -settings = DashboardSettings() - -cookie_authenticated_yes = b"yes" - - -def template_args(): - version = const.__version__ - if "b" in version: - docs_link = "https://beta.esphome.io/" - elif "dev" in version: - docs_link = "https://next.esphome.io/" - else: - docs_link = "https://www.esphome.io/" - - return { - "version": version, - "docs_link": docs_link, - "get_static_file_url": get_static_file_url, - "relative_url": settings.relative_url, - "streamer_mode": settings.streamer_mode, - "config_dir": settings.config_dir, - } - - -def authenticated(func): - @functools.wraps(func) - def decorator(self, *args, **kwargs): - if not is_authenticated(self): - self.redirect("./login") - return None - return func(self, *args, **kwargs) - - return decorator - - -def is_authenticated(request_handler): - if settings.on_ha_addon: - # Handle ingress - disable auth on ingress port - # X-HA-Ingress is automatically stripped on the non-ingress server in nginx - header = request_handler.request.headers.get("X-HA-Ingress", "NO") - if str(header) == "YES": - return True - if settings.using_auth: - return ( - request_handler.get_secure_cookie("authenticated") - == cookie_authenticated_yes - ) - return True - - -def bind_config(func): - def decorator(self, *args, **kwargs): - configuration = self.get_argument("configuration") - kwargs = kwargs.copy() - kwargs["configuration"] = configuration - return func(self, *args, **kwargs) - - return decorator - - -# pylint: disable=abstract-method -class BaseHandler(tornado.web.RequestHandler): - pass - - -def websocket_class(cls): - # pylint: disable=protected-access - if not hasattr(cls, "_message_handlers"): - cls._message_handlers = {} - - for _, method in cls.__dict__.items(): - if hasattr(method, "_message_handler"): - cls._message_handlers[method._message_handler] = method - - return cls - - -def websocket_method(name): - def wrap(fn): - # pylint: disable=protected-access - fn._message_handler = name - return fn - - return wrap - - -@websocket_class -class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): - def __init__(self, application, request, **kwargs): - super().__init__(application, request, **kwargs) - self._proc = None - self._queue = None - self._is_closed = False - # Windows doesn't support non-blocking pipes, - # use Popen() with a reading thread instead - self._use_popen = os.name == "nt" - - @authenticated - def on_message(self, message): - # Messages are always JSON, 500 when not - json_message = json.loads(message) - type_ = json_message["type"] - # pylint: disable=no-member - handlers = type(self)._message_handlers - if type_ not in handlers: - _LOGGER.warning("Requested unknown message type %s", type_) - return - - handlers[type_](self, json_message) - - @websocket_method("spawn") - def handle_spawn(self, json_message): - if self._proc is not None: - # spawn can only be called once - return - command = self.build_command(json_message) - _LOGGER.info("Running command '%s'", " ".join(shlex_quote(x) for x in command)) - - if self._use_popen: - self._queue = tornado.queues.Queue() - # pylint: disable=consider-using-with - self._proc = subprocess.Popen( - command, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - stdout_thread = threading.Thread(target=self._stdout_thread) - stdout_thread.daemon = True - stdout_thread.start() - else: - self._proc = tornado.process.Subprocess( - command, - stdout=tornado.process.Subprocess.STREAM, - stderr=subprocess.STDOUT, - stdin=tornado.process.Subprocess.STREAM, - ) - self._proc.set_exit_callback(self._proc_on_exit) - - tornado.ioloop.IOLoop.current().spawn_callback(self._redirect_stdout) - - @property - def is_process_active(self): - return self._proc is not None and self._proc.returncode is None - - @websocket_method("stdin") - def handle_stdin(self, json_message): - if not self.is_process_active: - return - data = json_message["data"] - data = codecs.encode(data, "utf8", "replace") - _LOGGER.debug("< stdin: %s", data) - self._proc.stdin.write(data) - - @tornado.gen.coroutine - def _redirect_stdout(self): - reg = b"[\n\r]" - - while True: - try: - if self._use_popen: - data = yield self._queue.get() - if data is None: - self._proc_on_exit(self._proc.poll()) - break - else: - data = yield self._proc.stdout.read_until_regex(reg) - except tornado.iostream.StreamClosedError: - break - data = codecs.decode(data, "utf8", "replace") - - _LOGGER.debug("> stdout: %s", data) - self.write_message({"event": "line", "data": data}) - - def _stdout_thread(self): - if not self._use_popen: - return - while True: - data = self._proc.stdout.readline() - if data: - data = data.replace(b"\r", b"") - self._queue.put_nowait(data) - if self._proc.poll() is not None: - break - self._proc.wait(1.0) - self._queue.put_nowait(None) - - def _proc_on_exit(self, returncode): - if not self._is_closed: - # Check if the proc was not forcibly closed - _LOGGER.info("Process exited with return code %s", returncode) - self.write_message({"event": "exit", "code": returncode}) - - def on_close(self): - # Check if proc exists (if 'start' has been run) - if self.is_process_active: - _LOGGER.debug("Terminating process") - if self._use_popen: - self._proc.terminate() - else: - self._proc.proc.terminate() - # Shutdown proc on WS close - self._is_closed = True - - def build_command(self, json_message): - raise NotImplementedError - - -class EsphomeLogsHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - return [ - "esphome", - "--dashboard", - "logs", - config_file, - "--device", - json_message["port"], - ] - - -class EsphomeRenameHandler(EsphomeCommandWebSocket): - old_name: str - - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - self.old_name = json_message["configuration"] - return [ - "esphome", - "--dashboard", - "rename", - config_file, - json_message["newName"], - ] - - def _proc_on_exit(self, returncode): - super()._proc_on_exit(returncode) - - if returncode != 0: - return - - # Remove the old ping result from the cache - PING_RESULT.pop(self.old_name, None) - - -class EsphomeUploadHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - return [ - "esphome", - "--dashboard", - "upload", - config_file, - "--device", - json_message["port"], - ] - - -class EsphomeRunHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - return [ - "esphome", - "--dashboard", - "run", - config_file, - "--device", - json_message["port"], - ] - - -class EsphomeCompileHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - command = ["esphome", "--dashboard", "compile"] - if json_message.get("only_generate", False): - command.append("--only-generate") - command.append(config_file) - return command - - -class EsphomeValidateHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - command = ["esphome", "--dashboard", "config", config_file] - if not settings.streamer_mode: - command.append("--show-secrets") - return command - - -class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - return ["esphome", "--dashboard", "clean-mqtt", config_file] - - -class EsphomeCleanHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - config_file = settings.rel_path(json_message["configuration"]) - return ["esphome", "--dashboard", "clean", config_file] - - -class EsphomeVscodeHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - return ["esphome", "--dashboard", "-q", "vscode", "dummy"] - - -class EsphomeAceEditorHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - return ["esphome", "--dashboard", "-q", "vscode", "--ace", settings.config_dir] - - -class EsphomeUpdateAllHandler(EsphomeCommandWebSocket): - def build_command(self, json_message): - return ["esphome", "--dashboard", "update-all", settings.config_dir] - - -class SerialPortRequestHandler(BaseHandler): - @authenticated - def get(self): - ports = get_serial_ports() - data = [] - for port in ports: - desc = port.description - if port.path == "/dev/ttyAMA0": - desc = "UART pins on GPIO header" - split_desc = desc.split(" - ") - if len(split_desc) == 2 and split_desc[0] == split_desc[1]: - # Some serial ports repeat their values - desc = split_desc[0] - data.append({"port": port.path, "desc": desc}) - data.append({"port": "OTA", "desc": "Over-The-Air"}) - data.sort(key=lambda x: x["port"], reverse=True) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - - -class WizardRequestHandler(BaseHandler): - @authenticated - def post(self): - from esphome import wizard - - kwargs = { - k: v - for k, v in json.loads(self.request.body.decode()).items() - if k in ("name", "platform", "board", "ssid", "psk", "password") - } - if not kwargs["name"]: - self.set_status(422) - self.set_header("content-type", "application/json") - self.write(json.dumps({"error": "Name is required"})) - return - - kwargs["friendly_name"] = kwargs["name"] - kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"]) - - kwargs["ota_password"] = secrets.token_hex(16) - noise_psk = secrets.token_bytes(32) - kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() - filename = f"{kwargs['name']}.yaml" - destination = settings.rel_path(filename) - wizard.wizard_write(path=destination, **kwargs) - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": filename})) - self.finish() - - -class ImportRequestHandler(BaseHandler): - @authenticated - def post(self): - from esphome.components.dashboard_import import import_config - - args = json.loads(self.request.body.decode()) - try: - name = args["name"] - friendly_name = args.get("friendly_name") - encryption = args.get("encryption", False) - - imported_device = next( - (res for res in IMPORT_RESULT.values() if res.device_name == name), None - ) - - if imported_device is not None: - network = imported_device.network - if friendly_name is None: - friendly_name = imported_device.friendly_name - else: - network = const.CONF_WIFI - - import_config( - settings.rel_path(f"{name}.yaml"), - name, - friendly_name, - args["project_name"], - args["package_import_url"], - network, - encryption, - ) - # Make sure the device gets marked online right away - PING_REQUEST.set() - except FileExistsError: - self.set_status(500) - self.write("File already exists") - return - except ValueError: - self.set_status(422) - self.write("Invalid package url") - return - - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps({"configuration": f"{name}.yaml"})) - self.finish() - - -class DownloadListRequestHandler(BaseHandler): - @authenticated - @bind_config - def get(self, configuration=None): - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - self.send_error(404) - return - - from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS - from esphome.components.esp32 import get_download_types as esp32_types - from esphome.components.esp8266 import get_download_types as esp8266_types - from esphome.components.libretiny import get_download_types as libretiny_types - from esphome.components.rp2040 import get_download_types as rp2040_types - - downloads = [] - platform = storage_json.target_platform.lower() - if platform == const.PLATFORM_RP2040: - downloads = rp2040_types(storage_json) - elif platform == const.PLATFORM_ESP8266: - downloads = esp8266_types(storage_json) - elif platform.upper() in ESP32_VARIANTS: - downloads = esp32_types(storage_json) - elif platform == const.PLATFORM_BK72XX: - downloads = libretiny_types(storage_json) - elif platform == const.PLATFORM_RTL87XX: - downloads = libretiny_types(storage_json) - else: - self.send_error(418) - return - - self.set_status(200) - self.set_header("content-type", "application/json") - self.write(json.dumps(downloads)) - self.finish() - return - - -class DownloadBinaryRequestHandler(BaseHandler): - @authenticated - @bind_config - def get(self, configuration=None): - compressed = self.get_argument("compressed", "0") == "1" - - storage_path = ext_storage_path(configuration) - storage_json = StorageJSON.load(storage_path) - if storage_json is None: - self.send_error(404) - return - - # fallback to type=, but prioritize file= - file_name = self.get_argument("type", None) - file_name = self.get_argument("file", file_name) - if file_name is None: - self.send_error(400) - return - file_name = file_name.replace("..", "").lstrip("/") - # get requested download name, or build it based on filename - download_name = self.get_argument( - "download", - f"{storage_json.name}-{file_name}", - ) - path = os.path.dirname(storage_json.firmware_bin_path) - path = os.path.join(path, file_name) - - if not Path(path).is_file(): - args = ["esphome", "idedata", settings.rel_path(configuration)] - rc, stdout, _ = run_system_command(*args) - - if rc != 0: - self.send_error(404 if rc == 2 else 500) - return - - idedata = platformio_api.IDEData(json.loads(stdout)) - - found = False - for image in idedata.extra_flash_images: - if image.path.endswith(file_name): - path = image.path - download_name = file_name - found = True - break - - if not found: - self.send_error(404) - return - - download_name = download_name + ".gz" if compressed else download_name - - self.set_header("Content-Type", "application/octet-stream") - self.set_header( - "Content-Disposition", f'attachment; filename="{download_name}"' - ) - self.set_header("Cache-Control", "no-cache") - if not Path(path).is_file(): - self.send_error(404) - return - - with open(path, "rb") as f: - data = f.read() - if compressed: - data = gzip.compress(data, 9) - self.write(data) - - self.finish() - - -class EsphomeVersionHandler(BaseHandler): - @authenticated - def get(self): - self.set_header("Content-Type", "application/json") - self.write(json.dumps({"version": const.__version__})) - self.finish() - - -def _list_dashboard_entries() -> list[DashboardEntry]: - return settings.entries() - - -class DashboardEntry: - """Represents a single dashboard entry. - - This class is thread-safe and read-only. - """ - - __slots__ = ("path", "_storage", "_loaded_storage") - - def __init__(self, path: str) -> None: - """Initialize the DashboardEntry.""" - self.path = path - self._storage = None - self._loaded_storage = False - - def __repr__(self): - """Return the representation of this entry.""" - return ( - f"DashboardEntry({self.path} " - f"address={self.address} " - f"web_port={self.web_port} " - f"name={self.name} " - f"no_mdns={self.no_mdns})" - ) - - @property - def filename(self): - """Return the filename of this entry.""" - return os.path.basename(self.path) - - @property - def storage(self) -> StorageJSON | None: - """Return the StorageJSON object for this entry.""" - if not self._loaded_storage: - self._storage = StorageJSON.load(ext_storage_path(self.filename)) - self._loaded_storage = True - return self._storage - - @property - def address(self): - """Return the address of this entry.""" - if self.storage is None: - return None - return self.storage.address - - @property - def no_mdns(self): - """Return the no_mdns of this entry.""" - if self.storage is None: - return None - return self.storage.no_mdns - - @property - def web_port(self): - """Return the web port of this entry.""" - if self.storage is None: - return None - return self.storage.web_port - - @property - def name(self): - """Return the name of this entry.""" - if self.storage is None: - return self.filename.replace(".yml", "").replace(".yaml", "") - return self.storage.name - - @property - def friendly_name(self): - """Return the friendly name of this entry.""" - if self.storage is None: - return self.name - return self.storage.friendly_name - - @property - def comment(self): - """Return the comment of this entry.""" - if self.storage is None: - return None - return self.storage.comment - - @property - def target_platform(self): - """Return the target platform of this entry.""" - if self.storage is None: - return None - return self.storage.target_platform - - @property - def update_available(self): - """Return if an update is available for this entry.""" - if self.storage is None: - return True - return self.update_old != self.update_new - - @property - def update_old(self): - if self.storage is None: - return "" - return self.storage.esphome_version or "" - - @property - def update_new(self): - return const.__version__ - - @property - def loaded_integrations(self): - if self.storage is None: - return [] - return self.storage.loaded_integrations - - -class ListDevicesHandler(BaseHandler): - @authenticated - def get(self): - entries = _list_dashboard_entries() - self.set_header("content-type", "application/json") - configured = {entry.name for entry in entries} - self.write( - json.dumps( - { - "configured": [ - { - "name": entry.name, - "friendly_name": entry.friendly_name, - "configuration": entry.filename, - "loaded_integrations": entry.loaded_integrations, - "deployed_version": entry.update_old, - "current_version": entry.update_new, - "path": entry.path, - "comment": entry.comment, - "address": entry.address, - "web_port": entry.web_port, - "target_platform": entry.target_platform, - } - for entry in entries - ], - "importable": [ - { - "name": res.device_name, - "friendly_name": res.friendly_name, - "package_import_url": res.package_import_url, - "project_name": res.project_name, - "project_version": res.project_version, - "network": res.network, - } - for res in IMPORT_RESULT.values() - if res.device_name not in configured - ], - } - ) - ) - - -class MainRequestHandler(BaseHandler): - @authenticated - def get(self): - begin = bool(self.get_argument("begin", False)) - - self.render( - "index.template.html", - begin=begin, - **template_args(), - login_enabled=settings.using_password, - ) - - -def _ping_func(filename, address): - if os.name == "nt": - command = ["ping", "-n", "1", address] - else: - command = ["ping", "-c", "1", address] - rc, _, _ = run_system_command(*command) - return filename, rc == 0 - - -class PrometheusServiceDiscoveryHandler(BaseHandler): - @authenticated - def get(self): - entries = _list_dashboard_entries() - self.set_header("content-type", "application/json") - sd = [] - for entry in entries: - if entry.web_port is None: - continue - labels = { - "__meta_name": entry.name, - "__meta_esp_platform": entry.target_platform, - "__meta_esphome_version": entry.storage.esphome_version, - } - for integration in entry.storage.loaded_integrations: - labels[f"__meta_integration_{integration}"] = "true" - sd.append( - { - "targets": [ - f"{entry.address}:{entry.web_port}", - ], - "labels": labels, - } - ) - self.write(json.dumps(sd)) - - -class BoardsRequestHandler(BaseHandler): - @authenticated - def get(self, platform: str): - from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS - from esphome.components.esp32.boards import BOARDS as ESP32_BOARDS - from esphome.components.esp8266.boards import BOARDS as ESP8266_BOARDS - from esphome.components.rp2040.boards import BOARDS as RP2040_BOARDS - from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS - - platform_to_boards = { - const.PLATFORM_ESP32: ESP32_BOARDS, - const.PLATFORM_ESP8266: ESP8266_BOARDS, - const.PLATFORM_RP2040: RP2040_BOARDS, - const.PLATFORM_BK72XX: BK72XX_BOARDS, - const.PLATFORM_RTL87XX: RTL87XX_BOARDS, - } - # filter all ESP32 variants by requested platform - if platform.startswith("esp32"): - boards = { - k: v - for k, v in platform_to_boards[const.PLATFORM_ESP32].items() - if v[const.KEY_VARIANT] == platform.upper() - } - else: - boards = platform_to_boards[platform] - - # map to a {board_name: board_title} dict - platform_boards = {key: val[const.KEY_NAME] for key, val in boards.items()} - # sort by board title - boards_items = sorted(platform_boards.items(), key=lambda item: item[1]) - output = [{"items": dict(boards_items)}] - - self.set_header("content-type", "application/json") - self.write(json.dumps(output)) - - -class MDNSStatusThread(threading.Thread): - def __init__(self): - """Initialize the MDNSStatusThread.""" - super().__init__() - # This is the current mdns state for each host (True, False, None) - self.host_mdns_state: dict[str, bool | None] = {} - # This is the hostnames to filenames mapping - self.host_name_to_filename: dict[str, str] = {} - # This is a set of host names to track (i.e no_mdns = false) - self.host_name_with_mdns_enabled: set[set] = set() - self._refresh_hosts() - - def _refresh_hosts(self): - """Refresh the hosts to track.""" - entries = _list_dashboard_entries() - host_name_with_mdns_enabled = self.host_name_with_mdns_enabled - host_mdns_state = self.host_mdns_state - host_name_to_filename = self.host_name_to_filename - - for entry in entries: - name = entry.name - # If no_mdns is set, remove it from the set - if entry.no_mdns: - host_name_with_mdns_enabled.discard(name) - continue - - # We are tracking this host - host_name_with_mdns_enabled.add(name) - filename = entry.filename - - # If we just adopted/imported this host, we likely - # already have a state for it, so we should make sure - # to set it so the dashboard shows it as online - if name in host_mdns_state: - PING_RESULT[filename] = host_mdns_state[name] - - # Make sure the mapping is up to date - # so when we get an mdns update we can map it back - # to the filename - host_name_to_filename[name] = filename - - def run(self): - global IMPORT_RESULT - - zc = EsphomeZeroconf() - host_mdns_state = self.host_mdns_state - host_name_to_filename = self.host_name_to_filename - host_name_with_mdns_enabled = self.host_name_with_mdns_enabled - - def on_update(dat: dict[str, bool | None]) -> None: - """Update the global PING_RESULT dict.""" - for name, result in dat.items(): - host_mdns_state[name] = result - if name in host_name_with_mdns_enabled: - filename = host_name_to_filename[name] - PING_RESULT[filename] = result - - self._refresh_hosts() - stat = DashboardStatus(on_update) - imports = DashboardImportDiscovery() - browser = DashboardBrowser( - zc, ESPHOME_SERVICE_TYPE, [stat.browser_callback, imports.browser_callback] - ) - - while not STOP_EVENT.is_set(): - self._refresh_hosts() - IMPORT_RESULT = imports.import_state - PING_REQUEST.wait() - PING_REQUEST.clear() - - browser.cancel() - zc.close() - - -class PingStatusThread(threading.Thread): - def run(self): - with multiprocessing.Pool(processes=8) as pool: - while not STOP_EVENT.wait(2): - # Only do pings if somebody has the dashboard open - - def callback(ret): - PING_RESULT[ret[0]] = ret[1] - - entries = _list_dashboard_entries() - queue = collections.deque() - for entry in entries: - if entry.address is None: - PING_RESULT[entry.filename] = None - continue - - result = pool.apply_async( - _ping_func, (entry.filename, entry.address), callback=callback - ) - queue.append(result) - - while queue: - item = queue[0] - if item.ready(): - queue.popleft() - continue - - try: - item.get(0.1) - except OSError: - # ping not installed - pass - except multiprocessing.TimeoutError: - pass - - if STOP_EVENT.is_set(): - pool.terminate() - return - - PING_REQUEST.wait() - PING_REQUEST.clear() - - -class MqttStatusThread(threading.Thread): - def run(self): - from esphome import mqtt - - entries = _list_dashboard_entries() - - config = mqtt.config_from_env() - topic = "esphome/discover/#" - - def on_message(client, userdata, msg): - nonlocal entries - - payload = msg.payload.decode(errors="backslashreplace") - if len(payload) > 0: - data = json.loads(payload) - if "name" not in data: - return - for entry in entries: - if entry.name == data["name"]: - PING_RESULT[entry.filename] = True - return - - def on_connect(client, userdata, flags, return_code): - client.publish("esphome/discover", None, retain=False) - - mqttid = str(binascii.hexlify(os.urandom(6)).decode()) - - client = mqtt.prepare( - config, - [topic], - on_message, - on_connect, - None, - None, - f"esphome-dashboard-{mqttid}", - ) - client.loop_start() - - while not STOP_EVENT.wait(2): - # update entries - entries = _list_dashboard_entries() - - # will be set to true on on_message - for entry in entries: - if entry.no_mdns: - PING_RESULT[entry.filename] = False - - client.publish("esphome/discover", None, retain=False) - MQTT_PING_REQUEST.wait() - MQTT_PING_REQUEST.clear() - - client.disconnect() - client.loop_stop() - - -class PingRequestHandler(BaseHandler): - @authenticated - def get(self): - PING_REQUEST.set() - if settings.status_use_mqtt: - MQTT_PING_REQUEST.set() - self.set_header("content-type", "application/json") - self.write(json.dumps(PING_RESULT)) - - -class InfoRequestHandler(BaseHandler): - @authenticated - @bind_config - def get(self, configuration=None): - yaml_path = settings.rel_path(configuration) - all_yaml_files = settings.list_yaml_files() - - if yaml_path not in all_yaml_files: - self.set_status(404) - return - - self.set_header("content-type", "application/json") - self.write(DashboardEntry(yaml_path).storage.to_json()) - - -class EditRequestHandler(BaseHandler): - @authenticated - @bind_config - def get(self, configuration=None): - filename = settings.rel_path(configuration) - content = "" - if os.path.isfile(filename): - with open(file=filename, encoding="utf-8") as f: - content = f.read() - self.write(content) - - @authenticated - @bind_config - def post(self, configuration=None): - with open(file=settings.rel_path(configuration), mode="wb") as f: - f.write(self.request.body) - self.set_status(200) - - -class DeleteRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration=None): - config_file = settings.rel_path(configuration) - storage_path = ext_storage_path(configuration) - - trash_path = trash_storage_path() - mkdir_p(trash_path) - shutil.move(config_file, os.path.join(trash_path, configuration)) - - storage_json = StorageJSON.load(storage_path) - if storage_json is not None: - # Delete build folder (if exists) - name = storage_json.name - build_folder = os.path.join(settings.config_dir, name) - if build_folder is not None: - shutil.rmtree(build_folder, os.path.join(trash_path, name)) - - # Remove the old ping result from the cache - PING_RESULT.pop(configuration, None) - - -class UndoDeleteRequestHandler(BaseHandler): - @authenticated - @bind_config - def post(self, configuration=None): - config_file = settings.rel_path(configuration) - trash_path = trash_storage_path() - shutil.move(os.path.join(trash_path, configuration), config_file) - - -PING_RESULT: dict = {} -IMPORT_RESULT = {} -STOP_EVENT = threading.Event() -PING_REQUEST = threading.Event() -MQTT_PING_REQUEST = threading.Event() - - -class LoginHandler(BaseHandler): - def get(self): - if is_authenticated(self): - self.redirect("./") - else: - self.render_login_page() - - def render_login_page(self, error=None): - self.render( - "login.template.html", - error=error, - ha_addon=settings.using_ha_addon_auth, - has_username=bool(settings.username), - **template_args(), - ) - - def post_ha_addon_login(self): - import requests - - headers = { - "X-Supervisor-Token": os.getenv("SUPERVISOR_TOKEN"), - } - - data = { - "username": self.get_argument("username", ""), - "password": self.get_argument("password", ""), - } - try: - req = requests.post( - "http://supervisor/auth", headers=headers, json=data, timeout=30 - ) - if req.status_code == 200: - self.set_secure_cookie("authenticated", cookie_authenticated_yes) - self.redirect("/") - return - except Exception as err: # pylint: disable=broad-except - _LOGGER.warning("Error during Hass.io auth request: %s", err) - self.set_status(500) - self.render_login_page(error="Internal server error") - return - self.set_status(401) - self.render_login_page(error="Invalid username or password") - - def post_native_login(self): - username = self.get_argument("username", "") - password = self.get_argument("password", "") - if settings.check_password(username, password): - self.set_secure_cookie("authenticated", cookie_authenticated_yes) - self.redirect("./") - return - error_str = ( - "Invalid username or password" if settings.username else "Invalid password" - ) - self.set_status(401) - self.render_login_page(error=error_str) - - def post(self): - if settings.using_ha_addon_auth: - self.post_ha_addon_login() - else: - self.post_native_login() - - -class LogoutHandler(BaseHandler): - @authenticated - def get(self): - self.clear_cookie("authenticated") - self.redirect("./login") - - -class SecretKeysRequestHandler(BaseHandler): - @authenticated - def get(self): - filename = None - - for secret_filename in const.SECRETS_FILES: - relative_filename = settings.rel_path(secret_filename) - if os.path.isfile(relative_filename): - filename = relative_filename - break - - if filename is None: - self.send_error(404) - return - - secret_keys = list(yaml_util.load_yaml(filename, clear_secrets=False)) - - self.set_header("content-type", "application/json") - self.write(json.dumps(secret_keys)) - - -class SafeLoaderIgnoreUnknown(yaml.SafeLoader): - def ignore_unknown(self, node): - return f"{node.tag} {node.value}" - - def construct_yaml_binary(self, node) -> str: - return super().construct_yaml_binary(node).decode("ascii") - - -SafeLoaderIgnoreUnknown.add_constructor(None, SafeLoaderIgnoreUnknown.ignore_unknown) -SafeLoaderIgnoreUnknown.add_constructor( - "tag:yaml.org,2002:binary", SafeLoaderIgnoreUnknown.construct_yaml_binary -) - - -class JsonConfigRequestHandler(BaseHandler): - @authenticated - @bind_config - def get(self, configuration=None): - filename = settings.rel_path(configuration) - if not os.path.isfile(filename): - self.send_error(404) - return - - args = ["esphome", "config", filename, "--show-secrets"] - - rc, stdout, _ = run_system_command(*args) - - if rc != 0: - self.send_error(422) - return - - data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown) - self.set_header("content-type", "application/json") - self.write(json.dumps(data)) - self.finish() - - -def get_base_frontend_path(): - if ENV_DEV not in os.environ: - import esphome_dashboard - - return esphome_dashboard.where() - - static_path = os.environ[ENV_DEV] - if not static_path.endswith("/"): - static_path += "/" - - # This path can be relative, so resolve against the root or else templates don't work - return os.path.abspath(os.path.join(os.getcwd(), static_path, "esphome_dashboard")) - - -def get_static_path(*args): - return os.path.join(get_base_frontend_path(), "static", *args) - - -@functools.cache -def get_static_file_url(name): - base = f"./static/{name}" - - if ENV_DEV in os.environ: - return base - - # Module imports can't deduplicate if stuff added to url - if name == "js/esphome/index.js": - import esphome_dashboard - - return base.replace("index.js", esphome_dashboard.entrypoint()) - - path = get_static_path(name) - with open(path, "rb") as f_handle: - hash_ = hashlib.md5(f_handle.read()).hexdigest()[:8] - return f"{base}?hash={hash_}" - - -def make_app(debug=get_bool_env(ENV_DEV)): - def log_function(handler): - if handler.get_status() < 400: - log_method = access_log.info - - if isinstance(handler, SerialPortRequestHandler) and not debug: - return - if isinstance(handler, PingRequestHandler) and not debug: - return - elif handler.get_status() < 500: - log_method = access_log.warning - else: - log_method = access_log.error - - request_time = 1000.0 * handler.request.request_time() - # pylint: disable=protected-access - log_method( - "%d %s %.2fms", - handler.get_status(), - handler._request_summary(), - request_time, - ) - - class StaticFileHandler(tornado.web.StaticFileHandler): - def get_cache_time( - self, path: str, modified: datetime.datetime | None, mime_type: str - ) -> int: - """Override to customize cache control behavior.""" - if debug: - return 0 - # Assets that are hashed have ?hash= in the URL, all javascript - # filenames hashed so we can cache them for a long time - if "hash" in self.request.arguments or "/javascript" in mime_type: - return self.CACHE_MAX_AGE - return super().get_cache_time(path, modified, mime_type) - - app_settings = { - "debug": debug, - "cookie_secret": settings.cookie_secret, - "log_function": log_function, - "websocket_ping_interval": 30.0, - "template_path": get_base_frontend_path(), - } - rel = settings.relative_url - app = tornado.web.Application( - [ - (f"{rel}", MainRequestHandler), - (f"{rel}login", LoginHandler), - (f"{rel}logout", LogoutHandler), - (f"{rel}logs", EsphomeLogsHandler), - (f"{rel}upload", EsphomeUploadHandler), - (f"{rel}run", EsphomeRunHandler), - (f"{rel}compile", EsphomeCompileHandler), - (f"{rel}validate", EsphomeValidateHandler), - (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), - (f"{rel}clean", EsphomeCleanHandler), - (f"{rel}vscode", EsphomeVscodeHandler), - (f"{rel}ace", EsphomeAceEditorHandler), - (f"{rel}update-all", EsphomeUpdateAllHandler), - (f"{rel}info", InfoRequestHandler), - (f"{rel}edit", EditRequestHandler), - (f"{rel}downloads", DownloadListRequestHandler), - (f"{rel}download.bin", DownloadBinaryRequestHandler), - (f"{rel}serial-ports", SerialPortRequestHandler), - (f"{rel}ping", PingRequestHandler), - (f"{rel}delete", DeleteRequestHandler), - (f"{rel}undo-delete", UndoDeleteRequestHandler), - (f"{rel}wizard", WizardRequestHandler), - (f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}), - (f"{rel}devices", ListDevicesHandler), - (f"{rel}import", ImportRequestHandler), - (f"{rel}secret_keys", SecretKeysRequestHandler), - (f"{rel}json-config", JsonConfigRequestHandler), - (f"{rel}rename", EsphomeRenameHandler), - (f"{rel}prometheus-sd", PrometheusServiceDiscoveryHandler), - (f"{rel}boards/([a-z0-9]+)", BoardsRequestHandler), - (f"{rel}version", EsphomeVersionHandler), - ], - **app_settings, - ) - - return app - - -def start_web_server(args): +def start_dashboard(args) -> None: + """Start the dashboard.""" settings.parse_args(args) if settings.using_auth: @@ -1478,49 +26,29 @@ def start_web_server(args): storage.save(path) settings.cookie_secret = storage.cookie_secret - app = make_app(args.verbose) - if args.socket is not None: - _LOGGER.info( - "Starting dashboard web server on unix socket %s and configuration dir %s...", - args.socket, - settings.config_dir, - ) - server = tornado.httpserver.HTTPServer(app) - socket = tornado.netutil.bind_unix_socket(args.socket, mode=0o666) - server.add_socket(socket) - else: - _LOGGER.info( - "Starting dashboard web server on http://%s:%s and configuration dir %s...", - args.address, - args.port, - settings.config_dir, - ) - app.listen(args.port, args.address) + try: + asyncio.run(async_start(args)) + except KeyboardInterrupt: + pass - if args.open_ui: - import webbrowser - webbrowser.open(f"http://{args.address}:{args.port}") +async def async_start(args) -> None: + """Start the dashboard.""" + dashboard = DASHBOARD + await dashboard.async_setup() + sock: socket.socket | None = args.socket + address: str | None = args.address + port: int | None = args.port - if settings.status_use_ping: - status_thread = PingStatusThread() - else: - status_thread = MDNSStatusThread() - status_thread.start() + start_web_server(make_app(args.verbose), sock, address, port, settings.config_dir) - if settings.status_use_mqtt: - status_thread_mqtt = MqttStatusThread() - status_thread_mqtt.start() + if args.open_ui: + import webbrowser + + webbrowser.open(f"http://{args.address}:{args.port}") try: - tornado.ioloop.IOLoop.current().start() - except KeyboardInterrupt: - _LOGGER.info("Shutting down...") - STOP_EVENT.set() - PING_REQUEST.set() - status_thread.join() - if settings.status_use_mqtt: - status_thread_mqtt.join() - MQTT_PING_REQUEST.set() - if args.socket is not None: - os.remove(args.socket) + await dashboard.async_run() + finally: + if sock: + os.remove(sock) diff --git a/esphome/dashboard/entries.py b/esphome/dashboard/entries.py new file mode 100644 index 0000000000..8ccfa795d5 --- /dev/null +++ b/esphome/dashboard/entries.py @@ -0,0 +1,387 @@ +from __future__ import annotations + +import asyncio +import logging +import os +from collections import defaultdict +from typing import TYPE_CHECKING, Any + +from esphome import const, util +from esphome.storage_json import StorageJSON, ext_storage_path + +from .const import ( + EVENT_ENTRY_ADDED, + EVENT_ENTRY_REMOVED, + EVENT_ENTRY_STATE_CHANGED, + EVENT_ENTRY_UPDATED, +) +from .enum import StrEnum + +if TYPE_CHECKING: + from .core import ESPHomeDashboard + +_LOGGER = logging.getLogger(__name__) + + +DashboardCacheKeyType = tuple[int, int, float, int] + +# Currently EntryState is a simple +# online/offline/unknown enum, but in the future +# it may be expanded to include more states + + +class EntryState(StrEnum): + ONLINE = "online" + OFFLINE = "offline" + UNKNOWN = "unknown" + + +_BOOL_TO_ENTRY_STATE = { + True: EntryState.ONLINE, + False: EntryState.OFFLINE, + None: EntryState.UNKNOWN, +} +_ENTRY_STATE_TO_BOOL = { + EntryState.ONLINE: True, + EntryState.OFFLINE: False, + EntryState.UNKNOWN: None, +} + + +def bool_to_entry_state(value: bool) -> EntryState: + """Convert a bool to an entry state.""" + return _BOOL_TO_ENTRY_STATE[value] + + +def entry_state_to_bool(value: EntryState) -> bool | None: + """Convert an entry state to a bool.""" + return _ENTRY_STATE_TO_BOOL[value] + + +class DashboardEntries: + """Represents all dashboard entries.""" + + __slots__ = ( + "_dashboard", + "_loop", + "_config_dir", + "_entries", + "_entry_states", + "_loaded_entries", + "_update_lock", + "_name_to_entry", + ) + + def __init__(self, dashboard: ESPHomeDashboard) -> None: + """Initialize the DashboardEntries.""" + self._dashboard = dashboard + self._loop = asyncio.get_running_loop() + self._config_dir = dashboard.settings.config_dir + # Entries are stored as + # { + # "path/to/file.yaml": DashboardEntry, + # ... + # } + self._entries: dict[str, DashboardEntry] = {} + self._loaded_entries = False + self._update_lock = asyncio.Lock() + self._name_to_entry: dict[str, set[DashboardEntry]] = defaultdict(set) + + def get(self, path: str) -> DashboardEntry | None: + """Get an entry by path.""" + return self._entries.get(path) + + def get_by_name(self, name: str) -> set[DashboardEntry] | None: + """Get an entry by name.""" + return self._name_to_entry.get(name) + + async def _async_all(self) -> list[DashboardEntry]: + """Return all entries.""" + return list(self._entries.values()) + + def all(self) -> list[DashboardEntry]: + """Return all entries.""" + return asyncio.run_coroutine_threadsafe(self._async_all, self._loop).result() + + def async_all(self) -> list[DashboardEntry]: + """Return all entries.""" + return list(self._entries.values()) + + def set_state(self, entry: DashboardEntry, state: EntryState) -> None: + """Set the state for an entry.""" + asyncio.run_coroutine_threadsafe( + self._async_set_state(entry, state), self._loop + ).result() + + async def _async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: + """Set the state for an entry.""" + self.async_set_state(entry, state) + + def async_set_state(self, entry: DashboardEntry, state: EntryState) -> None: + """Set the state for an entry.""" + if entry.state == state: + return + entry.state = state + self._dashboard.bus.async_fire( + EVENT_ENTRY_STATE_CHANGED, {"entry": entry, "state": state} + ) + + async def async_request_update_entries(self) -> None: + """Request an update of the dashboard entries from disk. + + If an update is already in progress, this will do nothing. + """ + if self._update_lock.locked(): + _LOGGER.debug("Dashboard entries are already being updated") + return + await self.async_update_entries() + + async def async_update_entries(self) -> None: + """Update the dashboard entries from disk.""" + async with self._update_lock: + await self._async_update_entries() + + def _load_entries( + self, entries: dict[DashboardEntry, DashboardCacheKeyType] + ) -> None: + """Load all entries from disk.""" + for entry, cache_key in entries.items(): + _LOGGER.debug( + "Loading dashboard entry %s because cache key changed: %s", + entry.path, + cache_key, + ) + entry.load_from_disk(cache_key) + + async def _async_update_entries(self) -> list[DashboardEntry]: + """Sync the dashboard entries from disk.""" + _LOGGER.debug("Updating dashboard entries") + # At some point it would be nice to use watchdog to avoid polling + + path_to_cache_key = await self._loop.run_in_executor( + None, self._get_path_to_cache_key + ) + entries = self._entries + name_to_entry = self._name_to_entry + added: dict[DashboardEntry, DashboardCacheKeyType] = {} + updated: dict[DashboardEntry, DashboardCacheKeyType] = {} + removed: set[DashboardEntry] = { + entry + for filename, entry in entries.items() + if filename not in path_to_cache_key + } + original_names: dict[DashboardEntry, str] = {} + + for path, cache_key in path_to_cache_key.items(): + if not (entry := entries.get(path)): + entry = DashboardEntry(path, cache_key) + added[entry] = cache_key + continue + + if entry.cache_key != cache_key: + updated[entry] = cache_key + original_names[entry] = entry.name + + if added or updated: + await self._loop.run_in_executor( + None, self._load_entries, {**added, **updated} + ) + + bus = self._dashboard.bus + for entry in added: + entries[entry.path] = entry + name_to_entry[entry.name].add(entry) + bus.async_fire(EVENT_ENTRY_ADDED, {"entry": entry}) + + for entry in removed: + del entries[entry.path] + name_to_entry[entry.name].discard(entry) + bus.async_fire(EVENT_ENTRY_REMOVED, {"entry": entry}) + + for entry in updated: + if (original_name := original_names[entry]) != (current_name := entry.name): + name_to_entry[original_name].discard(entry) + name_to_entry[current_name].add(entry) + bus.async_fire(EVENT_ENTRY_UPDATED, {"entry": entry}) + + def _get_path_to_cache_key(self) -> dict[str, DashboardCacheKeyType]: + """Return a dict of path to cache key.""" + path_to_cache_key: dict[str, DashboardCacheKeyType] = {} + # + # The cache key is (inode, device, mtime, size) + # which allows us to avoid locking since it ensures + # every iteration of this call will always return the newest + # items from disk at the cost of a stat() call on each + # file which is much faster than reading the file + # for the cache hit case which is the common case. + # + for file in util.list_yaml_files([self._config_dir]): + try: + # Prefer the json storage path if it exists + stat = os.stat(ext_storage_path(os.path.basename(file))) + except OSError: + try: + # Fallback to the yaml file if the storage + # file does not exist or could not be generated + stat = os.stat(file) + except OSError: + # File was deleted, ignore + continue + path_to_cache_key[file] = ( + stat.st_ino, + stat.st_dev, + stat.st_mtime, + stat.st_size, + ) + return path_to_cache_key + + +class DashboardEntry: + """Represents a single dashboard entry. + + This class is thread-safe and read-only. + """ + + __slots__ = ( + "path", + "filename", + "_storage_path", + "cache_key", + "storage", + "state", + "_to_dict", + ) + + def __init__(self, path: str, cache_key: DashboardCacheKeyType) -> None: + """Initialize the DashboardEntry.""" + self.path = path + self.filename: str = os.path.basename(path) + self._storage_path = ext_storage_path(self.filename) + self.cache_key = cache_key + self.storage: StorageJSON | None = None + self.state = EntryState.UNKNOWN + self._to_dict: dict[str, Any] | None = None + + def __repr__(self): + """Return the representation of this entry.""" + return ( + f"DashboardEntry(path={self.path} " + f"address={self.address} " + f"web_port={self.web_port} " + f"name={self.name} " + f"no_mdns={self.no_mdns} " + f"state={self.state} " + ")" + ) + + def to_dict(self) -> dict[str, Any]: + """Return a dict representation of this entry. + + The dict includes the loaded configuration but not + the current state of the entry. + """ + if self._to_dict is None: + self._to_dict = { + "name": self.name, + "friendly_name": self.friendly_name, + "configuration": self.filename, + "loaded_integrations": sorted(self.loaded_integrations), + "deployed_version": self.update_old, + "current_version": self.update_new, + "path": self.path, + "comment": self.comment, + "address": self.address, + "web_port": self.web_port, + "target_platform": self.target_platform, + } + return self._to_dict + + def load_from_disk(self, cache_key: DashboardCacheKeyType | None = None) -> None: + """Load this entry from disk.""" + self.storage = StorageJSON.load(self._storage_path) + self._to_dict = None + # + # Currently StorageJSON.load() will return None if the file does not exist + # + # StorageJSON currently does not provide an updated cache key so we use the + # one that is passed in. + # + # The cache key was read from the disk moments ago and may be stale but + # it does not matter since we are polling anyways, and the next call to + # async_update_entries() will load it again in the extremely rare case that + # it changed between the two calls. + # + if cache_key: + self.cache_key = cache_key + + @property + def address(self) -> str | None: + """Return the address of this entry.""" + if self.storage is None: + return None + return self.storage.address + + @property + def no_mdns(self) -> bool | None: + """Return the no_mdns of this entry.""" + if self.storage is None: + return None + return self.storage.no_mdns + + @property + def web_port(self) -> int | None: + """Return the web port of this entry.""" + if self.storage is None: + return None + return self.storage.web_port + + @property + def name(self) -> str: + """Return the name of this entry.""" + if self.storage is None: + return self.filename.replace(".yml", "").replace(".yaml", "") + return self.storage.name + + @property + def friendly_name(self) -> str: + """Return the friendly name of this entry.""" + if self.storage is None: + return self.name + return self.storage.friendly_name + + @property + def comment(self) -> str | None: + """Return the comment of this entry.""" + if self.storage is None: + return None + return self.storage.comment + + @property + def target_platform(self) -> str | None: + """Return the target platform of this entry.""" + if self.storage is None: + return None + return self.storage.target_platform + + @property + def update_available(self) -> bool: + """Return if an update is available for this entry.""" + if self.storage is None: + return True + return self.update_old != self.update_new + + @property + def update_old(self) -> str: + if self.storage is None: + return "" + return self.storage.esphome_version or "" + + @property + def update_new(self) -> str: + return const.__version__ + + @property + def loaded_integrations(self) -> set[str]: + if self.storage is None: + return [] + return self.storage.loaded_integrations diff --git a/esphome/dashboard/enum.py b/esphome/dashboard/enum.py new file mode 100644 index 0000000000..6aff21620e --- /dev/null +++ b/esphome/dashboard/enum.py @@ -0,0 +1,19 @@ +"""Enum backports from standard lib.""" +from __future__ import annotations + +from enum import Enum +from typing import Any + + +class StrEnum(str, Enum): + """Partial backport of Python 3.11's StrEnum for our basic use cases.""" + + def __new__(cls, value: str, *args: Any, **kwargs: Any) -> StrEnum: + """Create a new StrEnum instance.""" + if not isinstance(value, str): + raise TypeError(f"{value!r} is not a string") + return super().__new__(cls, value, *args, **kwargs) + + def __str__(self) -> str: + """Return self.value.""" + return str(self.value) diff --git a/esphome/dashboard/settings.py b/esphome/dashboard/settings.py new file mode 100644 index 0000000000..61718298d2 --- /dev/null +++ b/esphome/dashboard/settings.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import hmac +import os +from pathlib import Path +from typing import Any + +from esphome.core import CORE +from esphome.helpers import get_bool_env + +from .util.password import password_hash + + +class DashboardSettings: + """Settings for the dashboard.""" + + def __init__(self) -> None: + self.config_dir: str = "" + self.password_hash: str = "" + self.username: str = "" + self.using_password: bool = False + self.on_ha_addon: bool = False + self.cookie_secret: str | None = None + self.absolute_config_dir: Path | None = None + + def parse_args(self, args): + self.on_ha_addon: bool = args.ha_addon + password: str = args.password or os.getenv("PASSWORD", "") + if not self.on_ha_addon: + self.username: str = args.username or os.getenv("USERNAME", "") + self.using_password = bool(password) + if self.using_password: + self.password_hash = password_hash(password) + self.config_dir: str = args.configuration + self.absolute_config_dir: Path = Path(self.config_dir).resolve() + CORE.config_path = os.path.join(self.config_dir, ".") + + @property + def relative_url(self): + return os.getenv("ESPHOME_DASHBOARD_RELATIVE_URL", "/") + + @property + def status_use_ping(self): + return get_bool_env("ESPHOME_DASHBOARD_USE_PING") + + @property + def status_use_mqtt(self): + return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT") + + @property + def using_ha_addon_auth(self): + if not self.on_ha_addon: + return False + return not get_bool_env("DISABLE_HA_AUTHENTICATION") + + @property + def using_auth(self): + return self.using_password or self.using_ha_addon_auth + + @property + def streamer_mode(self): + return get_bool_env("ESPHOME_STREAMER_MODE") + + def check_password(self, username, password): + if not self.using_auth: + return True + if username != self.username: + return False + + # Compare password in constant running time (to prevent timing attacks) + return hmac.compare_digest(self.password_hash, password_hash(password)) + + def rel_path(self, *args: Any) -> str: + """Return a path relative to the ESPHome config folder.""" + joined_path = os.path.join(self.config_dir, *args) + # Raises ValueError if not relative to ESPHome config folder + Path(joined_path).resolve().relative_to(self.absolute_config_dir) + return joined_path diff --git a/esphome/dashboard/status/__init__.py b/esphome/dashboard/status/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/dashboard/status/mdns.py b/esphome/dashboard/status/mdns.py new file mode 100644 index 0000000000..bd212bc563 --- /dev/null +++ b/esphome/dashboard/status/mdns.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import asyncio + +from esphome.zeroconf import ( + ESPHOME_SERVICE_TYPE, + AsyncEsphomeZeroconf, + DashboardBrowser, + DashboardImportDiscovery, + DashboardStatus, +) + +from ..const import SENTINEL +from ..core import DASHBOARD +from ..entries import DashboardEntry, bool_to_entry_state + + +class MDNSStatus: + """Class that updates the mdns status.""" + + def __init__(self) -> None: + """Initialize the MDNSStatus class.""" + super().__init__() + self.aiozc: AsyncEsphomeZeroconf | None = None + # This is the current mdns state for each host (True, False, None) + self.host_mdns_state: dict[str, bool | None] = {} + self._loop = asyncio.get_running_loop() + + async def async_resolve_host(self, host_name: str) -> str | None: + """Resolve a host name to an address in a thread-safe manner.""" + if aiozc := self.aiozc: + return await aiozc.async_resolve_host(host_name) + return None + + async def async_refresh_hosts(self): + """Refresh the hosts to track.""" + dashboard = DASHBOARD + host_mdns_state = self.host_mdns_state + entries = dashboard.entries + poll_names: dict[str, set[DashboardEntry]] = {} + for entry in entries.async_all(): + if entry.no_mdns: + continue + # If we just adopted/imported this host, we likely + # already have a state for it, so we should make sure + # to set it so the dashboard shows it as online + if entry.loaded_integrations and "api" not in entry.loaded_integrations: + # No api available so we have to poll since + # the device won't respond to a request to ._esphomelib._tcp.local. + poll_names.setdefault(entry.name, set()).add(entry) + elif (online := host_mdns_state.get(entry.name, SENTINEL)) != SENTINEL: + entries.async_set_state(entry, bool_to_entry_state(online)) + + if poll_names and self.aiozc: + results = await asyncio.gather( + *(self.aiozc.async_resolve_host(name) for name in poll_names) + ) + for name, address in zip(poll_names, results): + result = bool(address) + host_mdns_state[name] = result + for entry in poll_names[name]: + entries.async_set_state(entry, bool_to_entry_state(result)) + + async def async_run(self) -> None: + dashboard = DASHBOARD + entries = dashboard.entries + aiozc = AsyncEsphomeZeroconf() + self.aiozc = aiozc + host_mdns_state = self.host_mdns_state + + def on_update(dat: dict[str, bool | None]) -> None: + """Update the entry state.""" + for name, result in dat.items(): + host_mdns_state[name] = result + if matching_entries := entries.get_by_name(name): + for entry in matching_entries: + if not entry.no_mdns: + entries.async_set_state(entry, bool_to_entry_state(result)) + + stat = DashboardStatus(on_update) + imports = DashboardImportDiscovery() + dashboard.import_result = imports.import_state + + browser = DashboardBrowser( + aiozc.zeroconf, + ESPHOME_SERVICE_TYPE, + [stat.browser_callback, imports.browser_callback], + ) + + ping_request = dashboard.ping_request + while not dashboard.stop_event.is_set(): + await self.async_refresh_hosts() + await ping_request.wait() + ping_request.clear() + + await browser.async_cancel() + await aiozc.async_close() + self.aiozc = None diff --git a/esphome/dashboard/status/mqtt.py b/esphome/dashboard/status/mqtt.py new file mode 100644 index 0000000000..8c35dd2535 --- /dev/null +++ b/esphome/dashboard/status/mqtt.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import binascii +import json +import os +import threading + +from esphome import mqtt + +from ..core import DASHBOARD +from ..entries import EntryState + + +class MqttStatusThread(threading.Thread): + """Status thread to get the status of the devices via MQTT.""" + + def run(self) -> None: + """Run the status thread.""" + dashboard = DASHBOARD + entries = dashboard.entries + current_entries = entries.all() + + config = mqtt.config_from_env() + topic = "esphome/discover/#" + + def on_message(client, userdata, msg): + nonlocal current_entries + + payload = msg.payload.decode(errors="backslashreplace") + if len(payload) > 0: + data = json.loads(payload) + if "name" not in data: + return + for entry in current_entries: + if entry.name == data["name"]: + entries.set_state(entry, EntryState.ONLINE) + return + + def on_connect(client, userdata, flags, return_code): + client.publish("esphome/discover", None, retain=False) + + mqttid = str(binascii.hexlify(os.urandom(6)).decode()) + + client = mqtt.prepare( + config, + [topic], + on_message, + on_connect, + None, + None, + f"esphome-dashboard-{mqttid}", + ) + client.loop_start() + + while not dashboard.stop_event.wait(2): + current_entries = entries.all() + # will be set to true on on_message + for entry in current_entries: + if entry.no_mdns: + entries.set_state(entry, EntryState.OFFLINE) + + client.publish("esphome/discover", None, retain=False) + dashboard.mqtt_ping_request.wait() + dashboard.mqtt_ping_request.clear() + + client.disconnect() + client.loop_stop() diff --git a/esphome/dashboard/status/ping.py b/esphome/dashboard/status/ping.py new file mode 100644 index 0000000000..d8281d9de1 --- /dev/null +++ b/esphome/dashboard/status/ping.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import asyncio +import os +from typing import cast + +from ..core import DASHBOARD +from ..entries import DashboardEntry, bool_to_entry_state +from ..util.itertools import chunked +from ..util.subprocess import async_system_command_status + + +async def _async_ping_host(host: str) -> bool: + """Ping a host.""" + return await async_system_command_status( + ["ping", "-n" if os.name == "nt" else "-c", "1", host] + ) + + +class PingStatus: + def __init__(self) -> None: + """Initialize the PingStatus class.""" + super().__init__() + self._loop = asyncio.get_running_loop() + + async def async_run(self) -> None: + """Run the ping status.""" + dashboard = DASHBOARD + entries = dashboard.entries + + while not dashboard.stop_event.is_set(): + # Only ping if the dashboard is open + await dashboard.ping_request.wait() + current_entries = dashboard.entries.async_all() + to_ping: list[DashboardEntry] = [ + entry for entry in current_entries if entry.address is not None + ] + for ping_group in chunked(to_ping, 16): + ping_group = cast(list[DashboardEntry], ping_group) + results = await asyncio.gather( + *(_async_ping_host(entry.address) for entry in ping_group), + return_exceptions=True, + ) + for entry, result in zip(ping_group, results): + if isinstance(result, Exception): + result = False + elif isinstance(result, BaseException): + raise result + entries.async_set_state(entry, bool_to_entry_state(result)) diff --git a/esphome/dashboard/util/__init__.py b/esphome/dashboard/util/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/dashboard/util/file.py b/esphome/dashboard/util/file.py new file mode 100644 index 0000000000..5f3c5f5f1b --- /dev/null +++ b/esphome/dashboard/util/file.py @@ -0,0 +1,55 @@ +import logging +import os +import tempfile +from pathlib import Path + +_LOGGER = logging.getLogger(__name__) + + +def write_utf8_file( + filename: Path, + utf8_str: str, + private: bool = False, +) -> None: + """Write a file and rename it into place. + + Writes all or nothing. + """ + write_file(filename, utf8_str.encode("utf-8"), private) + + +# from https://github.com/home-assistant/core/blob/dev/homeassistant/util/file.py +def write_file( + filename: Path, + utf8_data: bytes, + private: bool = False, +) -> None: + """Write a file and rename it into place. + + Writes all or nothing. + """ + + tmp_filename = "" + try: + # Modern versions of Python tempfile create this file with mode 0o600 + with tempfile.NamedTemporaryFile( + mode="wb", dir=os.path.dirname(filename), delete=False + ) as fdesc: + fdesc.write(utf8_data) + tmp_filename = fdesc.name + if not private: + os.fchmod(fdesc.fileno(), 0o644) + os.replace(tmp_filename, filename) + finally: + if os.path.exists(tmp_filename): + try: + os.remove(tmp_filename) + except OSError as err: + # If we are cleaning up then something else went wrong, so + # we should suppress likely follow-on errors in the cleanup + _LOGGER.error( + "File replacement cleanup failed for %s while saving %s: %s", + tmp_filename, + filename, + err, + ) diff --git a/esphome/dashboard/util/itertools.py b/esphome/dashboard/util/itertools.py new file mode 100644 index 0000000000..54e95ef802 --- /dev/null +++ b/esphome/dashboard/util/itertools.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from collections.abc import Iterable +from functools import partial +from itertools import islice +from typing import Any + + +def take(take_num: int, iterable: Iterable) -> list[Any]: + """Return first n items of the iterable as a list. + + From itertools recipes + """ + return list(islice(iterable, take_num)) + + +def chunked(iterable: Iterable, chunked_num: int) -> Iterable[Any]: + """Break *iterable* into lists of length *n*. + + From more-itertools + """ + return iter(partial(take, chunked_num, iter(iterable)), []) diff --git a/esphome/dashboard/util/password.py b/esphome/dashboard/util/password.py new file mode 100644 index 0000000000..e7ea28c25d --- /dev/null +++ b/esphome/dashboard/util/password.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import hashlib + + +def password_hash(password: str) -> bytes: + """Create a hash of a password to transform it to a fixed-length digest. + + Note this is not meant for secure storage, but for securely comparing passwords. + """ + return hashlib.sha256(password.encode()).digest() diff --git a/esphome/dashboard/util/subprocess.py b/esphome/dashboard/util/subprocess.py new file mode 100644 index 0000000000..583dd116e3 --- /dev/null +++ b/esphome/dashboard/util/subprocess.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Iterable + + +async def async_system_command_status(command: Iterable[str]) -> bool: + """Run a system command checking only the status.""" + process = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + close_fds=False, + ) + await process.wait() + return process.returncode == 0 + + +async def async_run_system_command(command: Iterable[str]) -> tuple[bool, bytes, bytes]: + """Run a system command and return a tuple of returncode, stdout, stderr.""" + process = await asyncio.create_subprocess_exec( + *command, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + close_fds=False, + ) + stdout, stderr = await process.communicate() + await process.wait() + return process.returncode, stdout, stderr diff --git a/esphome/dashboard/util.py b/esphome/dashboard/util/text.py similarity index 63% rename from esphome/dashboard/util.py rename to esphome/dashboard/util/text.py index a2ad530b74..08d2df6abf 100644 --- a/esphome/dashboard/util.py +++ b/esphome/dashboard/util/text.py @@ -1,17 +1,10 @@ -import hashlib +from __future__ import annotations + import unicodedata from esphome.const import ALLOWED_NAME_CHARS -def password_hash(password: str) -> bytes: - """Create a hash of a password to transform it to a fixed-length digest. - - Note this is not meant for secure storage, but for securely comparing passwords. - """ - return hashlib.sha256(password.encode()).digest() - - def strip_accents(value): return "".join( c diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py new file mode 100644 index 0000000000..9a9ccb462b --- /dev/null +++ b/esphome/dashboard/web_server.py @@ -0,0 +1,1106 @@ +from __future__ import annotations + +import asyncio +import base64 +import datetime +import functools +import gzip +import hashlib +import json +import logging +import os +import secrets +import shutil +import subprocess +import threading +from pathlib import Path +from typing import Any + +import tornado +import tornado.concurrent +import tornado.gen +import tornado.httpserver +import tornado.ioloop +import tornado.iostream +import tornado.netutil +import tornado.process +import tornado.queues +import tornado.web +import tornado.websocket +import tornado.httputil +import yaml +from tornado.log import access_log + +from esphome import const, platformio_api, yaml_util +from esphome.helpers import get_bool_env, mkdir_p +from esphome.storage_json import StorageJSON, ext_storage_path, trash_storage_path +from esphome.util import get_serial_ports, shlex_quote +from esphome.yaml_util import FastestAvailableSafeLoader + +from .core import DASHBOARD +from .entries import EntryState, entry_state_to_bool +from .util.file import write_file +from .util.subprocess import async_run_system_command +from .util.text import friendly_name_slugify + +_LOGGER = logging.getLogger(__name__) + +ENV_DEV = "ESPHOME_DASHBOARD_DEV" + + +cookie_authenticated_yes = b"yes" + + +settings = DASHBOARD.settings + + +def template_args(): + version = const.__version__ + if "b" in version: + docs_link = "https://beta.esphome.io/" + elif "dev" in version: + docs_link = "https://next.esphome.io/" + else: + docs_link = "https://www.esphome.io/" + + return { + "version": version, + "docs_link": docs_link, + "get_static_file_url": get_static_file_url, + "relative_url": settings.relative_url, + "streamer_mode": settings.streamer_mode, + "config_dir": settings.config_dir, + } + + +def authenticated(func): + @functools.wraps(func) + def decorator(self, *args, **kwargs): + if not is_authenticated(self): + self.redirect("./login") + return None + return func(self, *args, **kwargs) + + return decorator + + +def is_authenticated(request_handler): + if settings.on_ha_addon: + # Handle ingress - disable auth on ingress port + # X-HA-Ingress is automatically stripped on the non-ingress server in nginx + header = request_handler.request.headers.get("X-HA-Ingress", "NO") + if str(header) == "YES": + return True + if settings.using_auth: + return ( + request_handler.get_secure_cookie("authenticated") + == cookie_authenticated_yes + ) + return True + + +def bind_config(func): + def decorator(self, *args, **kwargs): + configuration = self.get_argument("configuration") + kwargs = kwargs.copy() + kwargs["configuration"] = configuration + return func(self, *args, **kwargs) + + return decorator + + +# pylint: disable=abstract-method +class BaseHandler(tornado.web.RequestHandler): + pass + + +def websocket_class(cls): + # pylint: disable=protected-access + if not hasattr(cls, "_message_handlers"): + cls._message_handlers = {} + + for _, method in cls.__dict__.items(): + if hasattr(method, "_message_handler"): + cls._message_handlers[method._message_handler] = method + + return cls + + +def websocket_method(name): + def wrap(fn): + # pylint: disable=protected-access + fn._message_handler = name + return fn + + return wrap + + +@websocket_class +class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): + """Base class for ESPHome websocket commands.""" + + def __init__( + self, + application: tornado.web.Application, + request: tornado.httputil.HTTPServerRequest, + **kwargs: Any, + ) -> None: + """Initialize the websocket.""" + super().__init__(application, request, **kwargs) + self._proc = None + self._queue = None + self._is_closed = False + # Windows doesn't support non-blocking pipes, + # use Popen() with a reading thread instead + self._use_popen = os.name == "nt" + + def open(self, *args: str, **kwargs: str) -> None: + """Handle new WebSocket connection.""" + # Ensure messages from the subprocess are sent immediately + # to avoid a 200-500ms delay when nodelay is not set. + self.set_nodelay(True) + + @authenticated + async def on_message( # pylint: disable=invalid-overridden-method + self, message: str + ) -> None: + # Since tornado 4.5, on_message is allowed to be a coroutine + # Messages are always JSON, 500 when not + json_message = json.loads(message) + type_ = json_message["type"] + # pylint: disable=no-member + handlers = type(self)._message_handlers + if type_ not in handlers: + _LOGGER.warning("Requested unknown message type %s", type_) + return + + await handlers[type_](self, json_message) + + @websocket_method("spawn") + async def handle_spawn(self, json_message: dict[str, Any]) -> None: + if self._proc is not None: + # spawn can only be called once + return + command = await self.build_command(json_message) + _LOGGER.info("Running command '%s'", " ".join(shlex_quote(x) for x in command)) + + if self._use_popen: + self._queue = tornado.queues.Queue() + # pylint: disable=consider-using-with + self._proc = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + stdout_thread = threading.Thread(target=self._stdout_thread) + stdout_thread.daemon = True + stdout_thread.start() + else: + self._proc = tornado.process.Subprocess( + command, + stdout=tornado.process.Subprocess.STREAM, + stderr=subprocess.STDOUT, + stdin=tornado.process.Subprocess.STREAM, + close_fds=False, + ) + self._proc.set_exit_callback(self._proc_on_exit) + + tornado.ioloop.IOLoop.current().spawn_callback(self._redirect_stdout) + + @property + def is_process_active(self): + return self._proc is not None and self._proc.returncode is None + + @websocket_method("stdin") + async def handle_stdin(self, json_message: dict[str, Any]) -> None: + if not self.is_process_active: + return + text: str = json_message["data"] + data = text.encode("utf-8", "replace") + _LOGGER.debug("< stdin: %s", data) + self._proc.stdin.write(data) + + @tornado.gen.coroutine + def _redirect_stdout(self) -> None: + reg = b"[\n\r]" + + while True: + try: + if self._use_popen: + data: bytes = yield self._queue.get() + if data is None: + self._proc_on_exit(self._proc.poll()) + break + else: + data: bytes = yield self._proc.stdout.read_until_regex(reg) + except tornado.iostream.StreamClosedError: + break + + text = data.decode("utf-8", "replace") + _LOGGER.debug("> stdout: %s", text) + self.write_message({"event": "line", "data": text}) + + def _stdout_thread(self) -> None: + if not self._use_popen: + return + while True: + data = self._proc.stdout.readline() + if data: + data = data.replace(b"\r", b"") + self._queue.put_nowait(data) + if self._proc.poll() is not None: + break + self._proc.wait(1.0) + self._queue.put_nowait(None) + + def _proc_on_exit(self, returncode: int) -> None: + if not self._is_closed: + # Check if the proc was not forcibly closed + _LOGGER.info("Process exited with return code %s", returncode) + self.write_message({"event": "exit", "code": returncode}) + + def on_close(self) -> None: + # Check if proc exists (if 'start' has been run) + if self.is_process_active: + _LOGGER.debug("Terminating process") + if self._use_popen: + self._proc.terminate() + else: + self._proc.proc.terminate() + # Shutdown proc on WS close + self._is_closed = True + + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + raise NotImplementedError + + +DASHBOARD_COMMAND = ["esphome", "--dashboard"] + + +class EsphomePortCommandWebSocket(EsphomeCommandWebSocket): + """Base class for commands that require a port.""" + + async def build_device_command( + self, args: list[str], json_message: dict[str, Any] + ) -> list[str]: + """Build the command to run.""" + dashboard = DASHBOARD + entries = dashboard.entries + configuration = json_message["configuration"] + config_file = settings.rel_path(configuration) + port = json_message["port"] + if ( + port == "OTA" + and (mdns := dashboard.mdns_status) + and (entry := entries.get(config_file)) + and (address := await mdns.async_resolve_host(entry.name)) + ): + port = address + + return [ + *DASHBOARD_COMMAND, + *args, + config_file, + "--device", + port, + ] + + +class EsphomeLogsHandler(EsphomePortCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + """Build the command to run.""" + return await self.build_device_command(["logs"], json_message) + + +class EsphomeRenameHandler(EsphomeCommandWebSocket): + old_name: str + + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + config_file = settings.rel_path(json_message["configuration"]) + self.old_name = json_message["configuration"] + return [ + *DASHBOARD_COMMAND, + "rename", + config_file, + json_message["newName"], + ] + + def _proc_on_exit(self, returncode): + super()._proc_on_exit(returncode) + + if returncode != 0: + return + + # Remove the old ping result from the cache + entries = DASHBOARD.entries + if entry := entries.get(self.old_name): + entries.async_set_state(entry, EntryState.UNKNOWN) + + +class EsphomeUploadHandler(EsphomePortCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + """Build the command to run.""" + return await self.build_device_command(["upload"], json_message) + + +class EsphomeRunHandler(EsphomePortCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + """Build the command to run.""" + return await self.build_device_command(["run"], json_message) + + +class EsphomeCompileHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + config_file = settings.rel_path(json_message["configuration"]) + command = [*DASHBOARD_COMMAND, "compile"] + if json_message.get("only_generate", False): + command.append("--only-generate") + command.append(config_file) + return command + + +class EsphomeValidateHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + config_file = settings.rel_path(json_message["configuration"]) + command = [*DASHBOARD_COMMAND, "config", config_file] + if not settings.streamer_mode: + command.append("--show-secrets") + return command + + +class EsphomeCleanMqttHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + config_file = settings.rel_path(json_message["configuration"]) + return [*DASHBOARD_COMMAND, "clean-mqtt", config_file] + + +class EsphomeCleanHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + config_file = settings.rel_path(json_message["configuration"]) + return [*DASHBOARD_COMMAND, "clean", config_file] + + +class EsphomeVscodeHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + return [*DASHBOARD_COMMAND, "-q", "vscode", "dummy"] + + +class EsphomeAceEditorHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + return [*DASHBOARD_COMMAND, "-q", "vscode", "--ace", settings.config_dir] + + +class EsphomeUpdateAllHandler(EsphomeCommandWebSocket): + async def build_command(self, json_message: dict[str, Any]) -> list[str]: + return [*DASHBOARD_COMMAND, "update-all", settings.config_dir] + + +class SerialPortRequestHandler(BaseHandler): + @authenticated + async def get(self): + ports = await asyncio.get_running_loop().run_in_executor(None, get_serial_ports) + data = [] + for port in ports: + desc = port.description + if port.path == "/dev/ttyAMA0": + desc = "UART pins on GPIO header" + split_desc = desc.split(" - ") + if len(split_desc) == 2 and split_desc[0] == split_desc[1]: + # Some serial ports repeat their values + desc = split_desc[0] + data.append({"port": port.path, "desc": desc}) + data.append({"port": "OTA", "desc": "Over-The-Air"}) + data.sort(key=lambda x: x["port"], reverse=True) + self.set_header("content-type", "application/json") + self.write(json.dumps(data)) + + +class WizardRequestHandler(BaseHandler): + @authenticated + def post(self): + from esphome import wizard + + kwargs = { + k: v + for k, v in json.loads(self.request.body.decode()).items() + if k in ("name", "platform", "board", "ssid", "psk", "password") + } + if not kwargs["name"]: + self.set_status(422) + self.set_header("content-type", "application/json") + self.write(json.dumps({"error": "Name is required"})) + return + + kwargs["friendly_name"] = kwargs["name"] + kwargs["name"] = friendly_name_slugify(kwargs["friendly_name"]) + + kwargs["ota_password"] = secrets.token_hex(16) + noise_psk = secrets.token_bytes(32) + kwargs["api_encryption_key"] = base64.b64encode(noise_psk).decode() + filename = f"{kwargs['name']}.yaml" + destination = settings.rel_path(filename) + wizard.wizard_write(path=destination, **kwargs) + self.set_status(200) + self.set_header("content-type", "application/json") + self.write(json.dumps({"configuration": filename})) + self.finish() + + +class ImportRequestHandler(BaseHandler): + @authenticated + def post(self): + from esphome.components.dashboard_import import import_config + + dashboard = DASHBOARD + args = json.loads(self.request.body.decode()) + try: + name = args["name"] + friendly_name = args.get("friendly_name") + encryption = args.get("encryption", False) + + imported_device = next( + ( + res + for res in dashboard.import_result.values() + if res.device_name == name + ), + None, + ) + + if imported_device is not None: + network = imported_device.network + if friendly_name is None: + friendly_name = imported_device.friendly_name + else: + network = const.CONF_WIFI + + import_config( + settings.rel_path(f"{name}.yaml"), + name, + friendly_name, + args["project_name"], + args["package_import_url"], + network, + encryption, + ) + # Make sure the device gets marked online right away + dashboard.ping_request.set() + except FileExistsError: + self.set_status(500) + self.write("File already exists") + return + except ValueError: + self.set_status(422) + self.write("Invalid package url") + return + + self.set_status(200) + self.set_header("content-type", "application/json") + self.write(json.dumps({"configuration": f"{name}.yaml"})) + self.finish() + + +class DownloadListRequestHandler(BaseHandler): + @authenticated + @bind_config + def get(self, configuration=None): + storage_path = ext_storage_path(configuration) + storage_json = StorageJSON.load(storage_path) + if storage_json is None: + self.send_error(404) + return + + from esphome.components.esp32 import VARIANTS as ESP32_VARIANTS + from esphome.components.esp32 import get_download_types as esp32_types + from esphome.components.esp8266 import get_download_types as esp8266_types + from esphome.components.libretiny import get_download_types as libretiny_types + from esphome.components.rp2040 import get_download_types as rp2040_types + + downloads = [] + platform = storage_json.target_platform.lower() + if platform == const.PLATFORM_RP2040: + downloads = rp2040_types(storage_json) + elif platform == const.PLATFORM_ESP8266: + downloads = esp8266_types(storage_json) + elif platform.upper() in ESP32_VARIANTS: + downloads = esp32_types(storage_json) + elif platform == const.PLATFORM_BK72XX: + downloads = libretiny_types(storage_json) + elif platform == const.PLATFORM_RTL87XX: + downloads = libretiny_types(storage_json) + else: + self.send_error(418) + return + + self.set_status(200) + self.set_header("content-type", "application/json") + self.write(json.dumps(downloads)) + self.finish() + return + + +class DownloadBinaryRequestHandler(BaseHandler): + def _load_file(self, path: str, compressed: bool) -> bytes: + """Load a file from disk and compress it if requested.""" + with open(path, "rb") as f: + data = f.read() + if compressed: + return gzip.compress(data, 9) + return data + + @authenticated + @bind_config + async def get(self, configuration: str | None = None): + """Download a binary file.""" + loop = asyncio.get_running_loop() + compressed = self.get_argument("compressed", "0") == "1" + + storage_path = ext_storage_path(configuration) + storage_json = StorageJSON.load(storage_path) + if storage_json is None: + self.send_error(404) + return + + # fallback to type=, but prioritize file= + file_name = self.get_argument("type", None) + file_name = self.get_argument("file", file_name) + if file_name is None: + self.send_error(400) + return + file_name = file_name.replace("..", "").lstrip("/") + # get requested download name, or build it based on filename + download_name = self.get_argument( + "download", + f"{storage_json.name}-{file_name}", + ) + path = os.path.dirname(storage_json.firmware_bin_path) + path = os.path.join(path, file_name) + + if not Path(path).is_file(): + args = ["esphome", "idedata", settings.rel_path(configuration)] + rc, stdout, _ = await async_run_system_command(args) + + if rc != 0: + self.send_error(404 if rc == 2 else 500) + return + + idedata = platformio_api.IDEData(json.loads(stdout)) + + found = False + for image in idedata.extra_flash_images: + if image.path.endswith(file_name): + path = image.path + download_name = file_name + found = True + break + + if not found: + self.send_error(404) + return + + download_name = download_name + ".gz" if compressed else download_name + + self.set_header("Content-Type", "application/octet-stream") + self.set_header( + "Content-Disposition", f'attachment; filename="{download_name}"' + ) + self.set_header("Cache-Control", "no-cache") + if not Path(path).is_file(): + self.send_error(404) + return + + data = await loop.run_in_executor(None, self._load_file, path, compressed) + self.write(data) + + self.finish() + + +class EsphomeVersionHandler(BaseHandler): + @authenticated + def get(self): + self.set_header("Content-Type", "application/json") + self.write(json.dumps({"version": const.__version__})) + self.finish() + + +class ListDevicesHandler(BaseHandler): + @authenticated + async def get(self): + dashboard = DASHBOARD + await dashboard.entries.async_request_update_entries() + entries = dashboard.entries.async_all() + self.set_header("content-type", "application/json") + configured = {entry.name for entry in entries} + + self.write( + json.dumps( + { + "configured": [entry.to_dict() for entry in entries], + "importable": [ + { + "name": res.device_name, + "friendly_name": res.friendly_name, + "package_import_url": res.package_import_url, + "project_name": res.project_name, + "project_version": res.project_version, + "network": res.network, + } + for res in dashboard.import_result.values() + if res.device_name not in configured + ], + } + ) + ) + + +class MainRequestHandler(BaseHandler): + @authenticated + def get(self): + begin = bool(self.get_argument("begin", False)) + + self.render( + "index.template.html", + begin=begin, + **template_args(), + login_enabled=settings.using_password, + ) + + +class PrometheusServiceDiscoveryHandler(BaseHandler): + @authenticated + async def get(self): + dashboard = DASHBOARD + await dashboard.entries.async_request_update_entries() + entries = dashboard.entries.async_all() + self.set_header("content-type", "application/json") + sd = [] + for entry in entries: + if entry.web_port is None: + continue + labels = { + "__meta_name": entry.name, + "__meta_esp_platform": entry.target_platform, + "__meta_esphome_version": entry.storage.esphome_version, + } + for integration in entry.storage.loaded_integrations: + labels[f"__meta_integration_{integration}"] = "true" + sd.append( + { + "targets": [ + f"{entry.address}:{entry.web_port}", + ], + "labels": labels, + } + ) + self.write(json.dumps(sd)) + + +class BoardsRequestHandler(BaseHandler): + @authenticated + def get(self, platform: str): + from esphome.components.bk72xx.boards import BOARDS as BK72XX_BOARDS + from esphome.components.esp32.boards import BOARDS as ESP32_BOARDS + from esphome.components.esp8266.boards import BOARDS as ESP8266_BOARDS + from esphome.components.rp2040.boards import BOARDS as RP2040_BOARDS + from esphome.components.rtl87xx.boards import BOARDS as RTL87XX_BOARDS + + platform_to_boards = { + const.PLATFORM_ESP32: ESP32_BOARDS, + const.PLATFORM_ESP8266: ESP8266_BOARDS, + const.PLATFORM_RP2040: RP2040_BOARDS, + const.PLATFORM_BK72XX: BK72XX_BOARDS, + const.PLATFORM_RTL87XX: RTL87XX_BOARDS, + } + # filter all ESP32 variants by requested platform + if platform.startswith("esp32"): + boards = { + k: v + for k, v in platform_to_boards[const.PLATFORM_ESP32].items() + if v[const.KEY_VARIANT] == platform.upper() + } + else: + boards = platform_to_boards[platform] + + # map to a {board_name: board_title} dict + platform_boards = {key: val[const.KEY_NAME] for key, val in boards.items()} + # sort by board title + boards_items = sorted(platform_boards.items(), key=lambda item: item[1]) + output = [{"items": dict(boards_items)}] + + self.set_header("content-type", "application/json") + self.write(json.dumps(output)) + + +class PingRequestHandler(BaseHandler): + @authenticated + def get(self): + dashboard = DASHBOARD + dashboard.ping_request.set() + if settings.status_use_mqtt: + dashboard.mqtt_ping_request.set() + self.set_header("content-type", "application/json") + + self.write( + json.dumps( + { + entry.filename: entry_state_to_bool(entry.state) + for entry in dashboard.entries.async_all() + } + ) + ) + + +class InfoRequestHandler(BaseHandler): + @authenticated + @bind_config + async def get(self, configuration=None): + yaml_path = settings.rel_path(configuration) + dashboard = DASHBOARD + entry = dashboard.entries.get(yaml_path) + + if not entry: + self.set_status(404) + return + + self.set_header("content-type", "application/json") + self.write(entry.storage.to_json()) + + +class EditRequestHandler(BaseHandler): + @authenticated + @bind_config + async def get(self, configuration: str | None = None): + """Get the content of a file.""" + loop = asyncio.get_running_loop() + filename = settings.rel_path(configuration) + content = await loop.run_in_executor(None, self._read_file, filename) + self.write(content) + + def _read_file(self, filename: str) -> bytes: + """Read a file and return the content as bytes.""" + with open(file=filename, encoding="utf-8") as f: + return f.read() + + def _write_file(self, filename: str, content: bytes) -> None: + """Write a file with the given content.""" + write_file(filename, content) + + @authenticated + @bind_config + async def post(self, configuration: str | None = None): + """Write the content of a file.""" + loop = asyncio.get_running_loop() + config_file = settings.rel_path(configuration) + await loop.run_in_executor( + None, self._write_file, config_file, self.request.body + ) + # Ensure the StorageJSON is updated as well + await async_run_system_command( + [*DASHBOARD_COMMAND, "compile", "--only-generate", config_file] + ) + self.set_status(200) + + +class DeleteRequestHandler(BaseHandler): + @authenticated + @bind_config + def post(self, configuration=None): + config_file = settings.rel_path(configuration) + storage_path = ext_storage_path(configuration) + + trash_path = trash_storage_path() + mkdir_p(trash_path) + shutil.move(config_file, os.path.join(trash_path, configuration)) + + storage_json = StorageJSON.load(storage_path) + if storage_json is not None: + # Delete build folder (if exists) + name = storage_json.name + build_folder = os.path.join(settings.config_dir, name) + if build_folder is not None: + shutil.rmtree(build_folder, os.path.join(trash_path, name)) + + +class UndoDeleteRequestHandler(BaseHandler): + @authenticated + @bind_config + def post(self, configuration=None): + config_file = settings.rel_path(configuration) + trash_path = trash_storage_path() + shutil.move(os.path.join(trash_path, configuration), config_file) + + +class LoginHandler(BaseHandler): + def get(self): + if is_authenticated(self): + self.redirect("./") + else: + self.render_login_page() + + def render_login_page(self, error=None): + self.render( + "login.template.html", + error=error, + ha_addon=settings.using_ha_addon_auth, + has_username=bool(settings.username), + **template_args(), + ) + + def post_ha_addon_login(self): + import requests + + headers = { + "X-Supervisor-Token": os.getenv("SUPERVISOR_TOKEN"), + } + + data = { + "username": self.get_argument("username", ""), + "password": self.get_argument("password", ""), + } + try: + req = requests.post( + "http://supervisor/auth", headers=headers, json=data, timeout=30 + ) + if req.status_code == 200: + self.set_secure_cookie("authenticated", cookie_authenticated_yes) + self.redirect("/") + return + except Exception as err: # pylint: disable=broad-except + _LOGGER.warning("Error during Hass.io auth request: %s", err) + self.set_status(500) + self.render_login_page(error="Internal server error") + return + self.set_status(401) + self.render_login_page(error="Invalid username or password") + + def post_native_login(self): + username = self.get_argument("username", "") + password = self.get_argument("password", "") + if settings.check_password(username, password): + self.set_secure_cookie("authenticated", cookie_authenticated_yes) + self.redirect("./") + return + error_str = ( + "Invalid username or password" if settings.username else "Invalid password" + ) + self.set_status(401) + self.render_login_page(error=error_str) + + def post(self): + if settings.using_ha_addon_auth: + self.post_ha_addon_login() + else: + self.post_native_login() + + +class LogoutHandler(BaseHandler): + @authenticated + def get(self): + self.clear_cookie("authenticated") + self.redirect("./login") + + +class SecretKeysRequestHandler(BaseHandler): + @authenticated + def get(self): + filename = None + + for secret_filename in const.SECRETS_FILES: + relative_filename = settings.rel_path(secret_filename) + if os.path.isfile(relative_filename): + filename = relative_filename + break + + if filename is None: + self.send_error(404) + return + + secret_keys = list(yaml_util.load_yaml(filename, clear_secrets=False)) + + self.set_header("content-type", "application/json") + self.write(json.dumps(secret_keys)) + + +class SafeLoaderIgnoreUnknown(FastestAvailableSafeLoader): + def ignore_unknown(self, node): + return f"{node.tag} {node.value}" + + def construct_yaml_binary(self, node) -> str: + return super().construct_yaml_binary(node).decode("ascii") + + +SafeLoaderIgnoreUnknown.add_constructor(None, SafeLoaderIgnoreUnknown.ignore_unknown) +SafeLoaderIgnoreUnknown.add_constructor( + "tag:yaml.org,2002:binary", SafeLoaderIgnoreUnknown.construct_yaml_binary +) + + +class JsonConfigRequestHandler(BaseHandler): + @authenticated + @bind_config + async def get(self, configuration=None): + filename = settings.rel_path(configuration) + if not os.path.isfile(filename): + self.send_error(404) + return + + args = ["esphome", "config", filename, "--show-secrets"] + + rc, stdout, _ = await async_run_system_command(args) + + if rc != 0: + self.send_error(422) + return + + data = yaml.load(stdout, Loader=SafeLoaderIgnoreUnknown) + self.set_header("content-type", "application/json") + self.write(json.dumps(data)) + self.finish() + + +def get_base_frontend_path(): + if ENV_DEV not in os.environ: + import esphome_dashboard + + return esphome_dashboard.where() + + static_path = os.environ[ENV_DEV] + if not static_path.endswith("/"): + static_path += "/" + + # This path can be relative, so resolve against the root or else templates don't work + return os.path.abspath(os.path.join(os.getcwd(), static_path, "esphome_dashboard")) + + +def get_static_path(*args): + return os.path.join(get_base_frontend_path(), "static", *args) + + +@functools.cache +def get_static_file_url(name): + base = f"./static/{name}" + + if ENV_DEV in os.environ: + return base + + # Module imports can't deduplicate if stuff added to url + if name == "js/esphome/index.js": + import esphome_dashboard + + return base.replace("index.js", esphome_dashboard.entrypoint()) + + path = get_static_path(name) + with open(path, "rb") as f_handle: + hash_ = hashlib.md5(f_handle.read()).hexdigest()[:8] + return f"{base}?hash={hash_}" + + +def make_app(debug=get_bool_env(ENV_DEV)) -> tornado.web.Application: + def log_function(handler): + if handler.get_status() < 400: + log_method = access_log.info + + if isinstance(handler, SerialPortRequestHandler) and not debug: + return + if isinstance(handler, PingRequestHandler) and not debug: + return + elif handler.get_status() < 500: + log_method = access_log.warning + else: + log_method = access_log.error + + request_time = 1000.0 * handler.request.request_time() + # pylint: disable=protected-access + log_method( + "%d %s %.2fms", + handler.get_status(), + handler._request_summary(), + request_time, + ) + + class StaticFileHandler(tornado.web.StaticFileHandler): + def get_cache_time( + self, path: str, modified: datetime.datetime | None, mime_type: str + ) -> int: + """Override to customize cache control behavior.""" + if debug: + return 0 + # Assets that are hashed have ?hash= in the URL, all javascript + # filenames hashed so we can cache them for a long time + if "hash" in self.request.arguments or "/javascript" in mime_type: + return self.CACHE_MAX_AGE + return super().get_cache_time(path, modified, mime_type) + + app_settings = { + "debug": debug, + "cookie_secret": settings.cookie_secret, + "log_function": log_function, + "websocket_ping_interval": 30.0, + "template_path": get_base_frontend_path(), + } + rel = settings.relative_url + return tornado.web.Application( + [ + (f"{rel}", MainRequestHandler), + (f"{rel}login", LoginHandler), + (f"{rel}logout", LogoutHandler), + (f"{rel}logs", EsphomeLogsHandler), + (f"{rel}upload", EsphomeUploadHandler), + (f"{rel}run", EsphomeRunHandler), + (f"{rel}compile", EsphomeCompileHandler), + (f"{rel}validate", EsphomeValidateHandler), + (f"{rel}clean-mqtt", EsphomeCleanMqttHandler), + (f"{rel}clean", EsphomeCleanHandler), + (f"{rel}vscode", EsphomeVscodeHandler), + (f"{rel}ace", EsphomeAceEditorHandler), + (f"{rel}update-all", EsphomeUpdateAllHandler), + (f"{rel}info", InfoRequestHandler), + (f"{rel}edit", EditRequestHandler), + (f"{rel}downloads", DownloadListRequestHandler), + (f"{rel}download.bin", DownloadBinaryRequestHandler), + (f"{rel}serial-ports", SerialPortRequestHandler), + (f"{rel}ping", PingRequestHandler), + (f"{rel}delete", DeleteRequestHandler), + (f"{rel}undo-delete", UndoDeleteRequestHandler), + (f"{rel}wizard", WizardRequestHandler), + (f"{rel}static/(.*)", StaticFileHandler, {"path": get_static_path()}), + (f"{rel}devices", ListDevicesHandler), + (f"{rel}import", ImportRequestHandler), + (f"{rel}secret_keys", SecretKeysRequestHandler), + (f"{rel}json-config", JsonConfigRequestHandler), + (f"{rel}rename", EsphomeRenameHandler), + (f"{rel}prometheus-sd", PrometheusServiceDiscoveryHandler), + (f"{rel}boards/([a-z0-9]+)", BoardsRequestHandler), + (f"{rel}version", EsphomeVersionHandler), + ], + **app_settings, + ) + + +def start_web_server( + app: tornado.web.Application, + socket: str | None, + address: str | None, + port: int | None, + config_dir: str, +) -> None: + """Start the web server listener.""" + if socket is None: + _LOGGER.info( + "Starting dashboard web server on http://%s:%s and configuration dir %s...", + address, + port, + config_dir, + ) + app.listen(port, address) + return + + _LOGGER.info( + "Starting dashboard web server on unix socket %s and configuration dir %s...", + socket, + config_dir, + ) + server = tornado.httpserver.HTTPServer(app) + socket = tornado.netutil.bind_unix_socket(socket, mode=0o666) + server.add_socket(socket) diff --git a/esphome/espota2.py b/esphome/espota2.py index 98d6d3a0d9..dbf48a989a 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -1,10 +1,13 @@ +from __future__ import annotations + +import gzip import hashlib +import io import logging import random import socket import sys import time -import gzip from esphome.core import EsphomeError from esphome.helpers import is_ip_address, resolve_ip_address @@ -40,6 +43,10 @@ MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45] FEATURE_SUPPORTS_COMPRESSION = 0x01 + +UPLOAD_BLOCK_SIZE = 8192 +UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8 + _LOGGER = logging.getLogger(__name__) @@ -184,7 +191,9 @@ def send_check(sock, data, msg): raise OTAError(f"Error sending {msg}: {err}") from err -def perform_ota(sock, password, file_handle, filename): +def perform_ota( + sock: socket.socket, password: str, file_handle: io.IOBase, filename: str +) -> None: file_contents = file_handle.read() file_size = len(file_contents) _LOGGER.info("Uploading %s (%s bytes)", filename, file_size) @@ -254,14 +263,16 @@ def perform_ota(sock, password, file_handle, filename): sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 0) # Limit send buffer (usually around 100kB) in order to have progress bar # show the actual progress - sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 8192) + + sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, UPLOAD_BUFFER_SIZE) # Set higher timeout during upload - sock.settimeout(20.0) + sock.settimeout(30.0) + start_time = time.perf_counter() offset = 0 progress = ProgressBar() while True: - chunk = upload_contents[offset : offset + 1024] + chunk = upload_contents[offset : offset + UPLOAD_BLOCK_SIZE] if not chunk: break offset += len(chunk) @@ -277,8 +288,9 @@ def perform_ota(sock, password, file_handle, filename): # Enable nodelay for last checks sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + duration = time.perf_counter() - start_time - _LOGGER.info("Waiting for result...") + _LOGGER.info("Upload took %.2f seconds, waiting for result...", duration) receive_exactly(sock, 1, "receive OK", RESPONSE_RECEIVE_OK) receive_exactly(sock, 1, "Update end", RESPONSE_UPDATE_END_OK) diff --git a/esphome/external_files.py b/esphome/external_files.py new file mode 100644 index 0000000000..5b476286f3 --- /dev/null +++ b/esphome/external_files.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import logging +from pathlib import Path +import os +from datetime import datetime +import requests +import esphome.config_validation as cv +from esphome.core import CORE, TimePeriodSeconds + +_LOGGER = logging.getLogger(__name__) +CODEOWNERS = ["@landonr"] + +NETWORK_TIMEOUT = 30 + +IF_MODIFIED_SINCE = "If-Modified-Since" +CACHE_CONTROL = "Cache-Control" +CACHE_CONTROL_MAX_AGE = "max-age=" +CONTENT_DISPOSITION = "content-disposition" +TEMP_DIR = "temp" + + +def has_remote_file_changed(url, local_file_path): + if os.path.exists(local_file_path): + _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path) + try: + local_modification_time = os.path.getmtime(local_file_path) + local_modification_time_str = datetime.utcfromtimestamp( + local_modification_time + ).strftime("%a, %d %b %Y %H:%M:%S GMT") + + headers = { + IF_MODIFIED_SINCE: local_modification_time_str, + CACHE_CONTROL: CACHE_CONTROL_MAX_AGE + "3600", + } + response = requests.head(url, headers=headers, timeout=NETWORK_TIMEOUT) + + _LOGGER.debug( + "has_remote_file_changed: File %s, Local modified %s, response code %d", + local_file_path, + local_modification_time_str, + response.status_code, + ) + + if response.status_code == 304: + _LOGGER.debug( + "has_remote_file_changed: File not modified since %s", + local_modification_time_str, + ) + return False + _LOGGER.debug("has_remote_file_changed: File modified") + return True + except requests.exceptions.RequestException as e: + raise cv.Invalid( + f"Could not check if {url} has changed, please check if file exists " + f"({e})" + ) + + _LOGGER.debug("has_remote_file_changed: File doesn't exists at %s", local_file_path) + return True + + +def is_file_recent(file_path: str, refresh: TimePeriodSeconds) -> bool: + if os.path.exists(file_path): + creation_time = os.path.getctime(file_path) + current_time = datetime.now().timestamp() + return current_time - creation_time <= refresh.total_seconds + return False + + +def compute_local_file_dir(domain: str) -> Path: + base_directory = Path(CORE.data_dir) / domain + base_directory.mkdir(parents=True, exist_ok=True) + + return base_directory diff --git a/esphome/helpers.py b/esphome/helpers.py index 4012b2067f..00416b591f 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -357,6 +357,9 @@ def snake_case(value): return value.replace(" ", "_").lower() +_DISALLOWED_CHARS = re.compile(r"[^a-zA-Z0-9_]") + + def sanitize(value): """Same behaviour as `helpers.cpp` method `str_sanitize`.""" - return re.sub("[^-_0-9a-zA-Z]", r"", value) + return _DISALLOWED_CHARS.sub("_", value) diff --git a/esphome/storage_json.py b/esphome/storage_json.py index a2619cb536..0a41a4f738 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -1,21 +1,15 @@ +from __future__ import annotations import binascii import codecs -from datetime import datetime import json import logging import os -from typing import Optional +from datetime import datetime from esphome import const +from esphome.const import CONF_DISABLED, CONF_MDNS from esphome.core import CORE from esphome.helpers import write_file_if_changed - - -from esphome.const import ( - CONF_MDNS, - CONF_DISABLED, -) - from esphome.types import CoreType _LOGGER = logging.getLogger(__name__) @@ -40,48 +34,47 @@ def trash_storage_path() -> str: class StorageJSON: def __init__( self, - storage_version, - name, - friendly_name, - comment, - esphome_version, - src_version, - address, - web_port, - target_platform, - build_path, - firmware_bin_path, - loaded_integrations, - no_mdns, - ): + storage_version: int, + name: str, + friendly_name: str, + comment: str, + esphome_version: str, + src_version: int | None, + address: str, + web_port: int | None, + target_platform: str, + build_path: str, + firmware_bin_path: str, + loaded_integrations: set[str], + no_mdns: bool, + ) -> None: # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) - self.storage_version: int = storage_version + self.storage_version = storage_version # The name of the node - self.name: str = name + self.name = name # The friendly name of the node - self.friendly_name: str = friendly_name + self.friendly_name = friendly_name # The comment of the node - self.comment: str = comment + self.comment = comment # The esphome version this was compiled with - self.esphome_version: str = esphome_version + self.esphome_version = esphome_version # The version of the file in src/main.cpp - Used to migrate the file assert src_version is None or isinstance(src_version, int) - self.src_version: int = src_version + self.src_version = src_version # Address of the ESP, for example livingroom.local or a static IP - self.address: str = address + self.address = address # Web server port of the ESP, for example 80 assert web_port is None or isinstance(web_port, int) - self.web_port: int = web_port + self.web_port = web_port # The type of hardware in use, like "ESP32", "ESP32C3", "ESP8266", etc. - self.target_platform: str = target_platform + self.target_platform = target_platform # The absolute path to the platformio project - self.build_path: str = build_path + self.build_path = build_path # The absolute path to the firmware binary - self.firmware_bin_path: str = firmware_bin_path - # A list of strings of names of loaded integrations - self.loaded_integrations: list[str] = loaded_integrations - self.loaded_integrations.sort() + self.firmware_bin_path = firmware_bin_path + # A set of strings of names of loaded integrations + self.loaded_integrations = loaded_integrations # Is mDNS disabled self.no_mdns = no_mdns @@ -98,7 +91,7 @@ class StorageJSON: "esp_platform": self.target_platform, "build_path": self.build_path, "firmware_bin_path": self.firmware_bin_path, - "loaded_integrations": self.loaded_integrations, + "loaded_integrations": sorted(self.loaded_integrations), "no_mdns": self.no_mdns, } @@ -109,9 +102,7 @@ class StorageJSON: write_file_if_changed(path, self.to_json()) @staticmethod - def from_esphome_core( - esph: CoreType, old: Optional["StorageJSON"] - ) -> "StorageJSON": + def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON: hardware = esph.target_platform.upper() if esph.is_esp32: from esphome.components import esp32 @@ -129,7 +120,7 @@ class StorageJSON: target_platform=hardware, build_path=esph.build_path, firmware_bin_path=esph.firmware_bin, - loaded_integrations=list(esph.loaded_integrations), + loaded_integrations=esph.loaded_integrations, no_mdns=( CONF_MDNS in esph.config and CONF_DISABLED in esph.config[CONF_MDNS] @@ -140,7 +131,7 @@ class StorageJSON: @staticmethod def from_wizard( name: str, friendly_name: str, address: str, platform: str - ) -> "StorageJSON": + ) -> StorageJSON: return StorageJSON( storage_version=1, name=name, @@ -153,12 +144,12 @@ class StorageJSON: target_platform=platform, build_path=None, firmware_bin_path=None, - loaded_integrations=[], + loaded_integrations=set(), no_mdns=False, ) @staticmethod - def _load_impl(path: str) -> Optional["StorageJSON"]: + def _load_impl(path: str) -> StorageJSON | None: with codecs.open(path, "r", encoding="utf-8") as f_handle: storage = json.load(f_handle) storage_version = storage["storage_version"] @@ -174,7 +165,7 @@ class StorageJSON: esp_platform = storage.get("esp_platform") build_path = storage.get("build_path") firmware_bin_path = storage.get("firmware_bin_path") - loaded_integrations = storage.get("loaded_integrations", []) + loaded_integrations = set(storage.get("loaded_integrations", [])) no_mdns = storage.get("no_mdns", False) return StorageJSON( storage_version, @@ -193,7 +184,7 @@ class StorageJSON: ) @staticmethod - def load(path: str) -> Optional["StorageJSON"]: + def load(path: str) -> StorageJSON | None: try: return StorageJSON._load_impl(path) except Exception: # pylint: disable=broad-except @@ -215,7 +206,7 @@ class EsphomeStorageJSON: # The last time ESPHome checked for an update as an isoformat encoded str self.last_update_check_str: str = last_update_check # Cache of the version gotten in the last version check - self.remote_version: Optional[str] = remote_version + self.remote_version: str | None = remote_version def as_dict(self) -> dict: return { @@ -226,7 +217,7 @@ class EsphomeStorageJSON: } @property - def last_update_check(self) -> Optional[datetime]: + def last_update_check(self) -> datetime | None: try: return datetime.strptime(self.last_update_check_str, "%Y-%m-%dT%H:%M:%S") except Exception: # pylint: disable=broad-except @@ -243,7 +234,7 @@ class EsphomeStorageJSON: write_file_if_changed(path, self.to_json()) @staticmethod - def _load_impl(path: str) -> Optional["EsphomeStorageJSON"]: + def _load_impl(path: str) -> EsphomeStorageJSON | None: with codecs.open(path, "r", encoding="utf-8") as f_handle: storage = json.load(f_handle) storage_version = storage["storage_version"] @@ -255,14 +246,14 @@ class EsphomeStorageJSON: ) @staticmethod - def load(path: str) -> Optional["EsphomeStorageJSON"]: + def load(path: str) -> EsphomeStorageJSON | None: try: return EsphomeStorageJSON._load_impl(path) except Exception: # pylint: disable=broad-except return None @staticmethod - def get_default() -> "EsphomeStorageJSON": + def get_default() -> EsphomeStorageJSON: return EsphomeStorageJSON( storage_version=1, cookie_secret=binascii.hexlify(os.urandom(64)).decode(), diff --git a/esphome/zeroconf.py b/esphome/zeroconf.py index f4cb7f080b..72cc4c00c6 100644 --- a/esphome/zeroconf.py +++ b/esphome/zeroconf.py @@ -1,22 +1,21 @@ from __future__ import annotations +import asyncio import logging from dataclasses import dataclass from typing import Callable -from zeroconf import ( - IPVersion, - ServiceBrowser, - ServiceInfo, - ServiceStateChange, - Zeroconf, -) +from zeroconf import IPVersion, ServiceInfo, ServiceStateChange, Zeroconf +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf from esphome.storage_json import StorageJSON, ext_storage_path _LOGGER = logging.getLogger(__name__) +_BACKGROUND_TASKS: set[asyncio.Task] = set() + + class HostResolver(ServiceInfo): """Resolve a host name to an IP address.""" @@ -65,7 +64,7 @@ class DiscoveredImport: network: str -class DashboardBrowser(ServiceBrowser): +class DashboardBrowser(AsyncServiceBrowser): """A class to browse for ESPHome nodes.""" @@ -94,7 +93,28 @@ class DashboardImportDiscovery: # Ignore updates for devices that are not in the import state return - info = zeroconf.get_service_info(service_type, name) + info = AsyncServiceInfo( + service_type, + name, + ) + if info.load_from_cache(zeroconf): + self._process_service_info(name, info) + return + task = asyncio.create_task( + self._async_process_service_info(zeroconf, info, service_type, name) + ) + _BACKGROUND_TASKS.add(task) + task.add_done_callback(_BACKGROUND_TASKS.discard) + + async def _async_process_service_info( + self, zeroconf: Zeroconf, info: AsyncServiceInfo, service_type: str, name: str + ) -> None: + """Process a service info.""" + if await info.async_request(zeroconf): + self._process_service_info(name, info) + + def _process_service_info(self, name: str, info: ServiceInfo) -> None: + """Process a service info.""" _LOGGER.debug("-> resolved info: %s", info) if info is None: return @@ -146,14 +166,34 @@ class DashboardImportDiscovery: ) +def _make_host_resolver(host: str) -> HostResolver: + """Create a new HostResolver for the given host name.""" + name = host.partition(".")[0] + info = HostResolver( + ESPHOME_SERVICE_TYPE, f"{name}.{ESPHOME_SERVICE_TYPE}", server=f"{name}.local." + ) + return info + + class EsphomeZeroconf(Zeroconf): def resolve_host(self, host: str, timeout: float = 3.0) -> str | None: """Resolve a host name to an IP address.""" - name = host.partition(".")[0] - info = HostResolver(ESPHOME_SERVICE_TYPE, f"{name}.{ESPHOME_SERVICE_TYPE}") + info = _make_host_resolver(host) if ( info.load_from_cache(self) or (timeout and info.request(self, timeout * 1000)) ) and (addresses := info.ip_addresses_by_version(IPVersion.V4Only)): return str(addresses[0]) return None + + +class AsyncEsphomeZeroconf(AsyncZeroconf): + async def async_resolve_host(self, host: str, timeout: float = 3.0) -> str | None: + """Resolve a host name to an IP address.""" + info = _make_host_resolver(host) + if ( + info.load_from_cache(self.zeroconf) + or (timeout and await info.async_request(self.zeroconf, timeout * 1000)) + ) and (addresses := info.ip_addresses_by_version(IPVersion.V4Only)): + return str(addresses[0]) + return None diff --git a/platformio.ini b/platformio.ini index a9847beafd..5c4c17572d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -39,7 +39,7 @@ lib_deps = bblanchon/ArduinoJson@6.18.5 ; json wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.0 ; mlx90393 - pavlodn/HaierProtocol@0.9.20 ; haier + pavlodn/HaierProtocol@0.9.24 ; haier ; This is using the repository until a new release is published to PlatformIO https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library build_flags = @@ -160,7 +160,7 @@ board_build.filesystem_size = 0.5m platform = https://github.com/maxgerhardt/platform-raspberrypi.git platform_packages = ; earlephilhower/framework-arduinopico@~1.20602.0 ; Cannot use the platformio package until old releases stop getting deleted - earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/3.4.0/rp2040-3.4.0.zip + earlephilhower/framework-arduinopico@https://github.com/earlephilhower/arduino-pico/releases/download/3.6.0/rp2040-3.6.0.zip framework = arduino lib_deps = diff --git a/requirements.txt b/requirements.txt index 51799bc685..3d096ffd52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,8 +10,9 @@ platformio==6.1.11 # When updating platformio, also update Dockerfile esptool==4.6.2 click==8.1.7 esphome-dashboard==20231107.0 -aioesphomeapi==18.4.0 -zeroconf==0.122.3 +aioesphomeapi==18.5.9 +zeroconf==0.127.0 +python-magic==0.4.27 # esp-idf requires this, but doesn't bundle it by default # https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24 diff --git a/requirements_optional.txt b/requirements_optional.txt index 40c27f8547..bc4ea08c92 100644 --- a/requirements_optional.txt +++ b/requirements_optional.txt @@ -1,3 +1,3 @@ -pillow==10.0.1 +pillow==10.1.0 cairosvg==2.7.1 cryptography==41.0.4 diff --git a/script/bump-version.py b/script/bump-version.py index 1f034344f9..3e1e473c4b 100755 --- a/script/bump-version.py +++ b/script/bump-version.py @@ -45,7 +45,7 @@ def sub(path, pattern, repl, expected_count=1): content, count = re.subn(pattern, repl, content, flags=re.MULTILINE) if expected_count is not None: assert count == expected_count, f"Pattern {pattern} replacement failed!" - with open(path, "wt") as fh: + with open(path, "w") as fh: fh.write(content) diff --git a/script/test b/script/test index 36a58cd75a..e227c17f9f 100755 --- a/script/test +++ b/script/test @@ -6,12 +6,6 @@ cd "$(dirname "$0")/.." set -x -esphome compile tests/test1.yaml -esphome compile tests/test2.yaml -esphome compile tests/test3.yaml -esphome compile tests/test3.1.yaml -esphome compile tests/test4.yaml -esphome compile tests/test5.yaml -esphome compile tests/test6.yaml -esphome compile tests/test7.yaml -esphome compile tests/test8.yaml +for f in ./tests/test*.yaml; do + esphome compile $f +done diff --git a/tests/dashboard/__init__.py b/tests/dashboard/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/util/__init__.py b/tests/dashboard/util/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/dashboard/util/test_file.py b/tests/dashboard/util/test_file.py new file mode 100644 index 0000000000..89e6b97086 --- /dev/null +++ b/tests/dashboard/util/test_file.py @@ -0,0 +1,53 @@ +import os +from pathlib import Path +from unittest.mock import patch + +import py +import pytest + +from esphome.dashboard.util.file import write_file, write_utf8_file + + +def test_write_utf8_file(tmp_path: Path) -> None: + write_utf8_file(tmp_path.joinpath("foo.txt"), "foo") + assert tmp_path.joinpath("foo.txt").read_text() == "foo" + + with pytest.raises(OSError): + write_utf8_file(Path("/not-writable"), "bar") + + +def test_write_file(tmp_path: Path) -> None: + write_file(tmp_path.joinpath("foo.txt"), b"foo") + assert tmp_path.joinpath("foo.txt").read_text() == "foo" + + +def test_write_utf8_file_fails_at_rename( + tmpdir: py.path.local, caplog: pytest.LogCaptureFixture +) -> None: + """Test that if rename fails not not remove, we do not log the failed cleanup.""" + test_dir = tmpdir.mkdir("files") + test_file = Path(test_dir / "test.json") + + with pytest.raises(OSError), patch( + "esphome.dashboard.util.file.os.replace", side_effect=OSError + ): + write_utf8_file(test_file, '{"some":"data"}', False) + + assert not os.path.exists(test_file) + + assert "File replacement cleanup failed" not in caplog.text + + +def test_write_utf8_file_fails_at_rename_and_remove( + tmpdir: py.path.local, caplog: pytest.LogCaptureFixture +) -> None: + """Test that if rename and remove both fail, we log the failed cleanup.""" + test_dir = tmpdir.mkdir("files") + test_file = Path(test_dir / "test.json") + + with pytest.raises(OSError), patch( + "esphome.dashboard.util.file.os.remove", side_effect=OSError + ), patch("esphome.dashboard.util.file.os.replace", side_effect=OSError): + write_utf8_file(test_file, '{"some":"data"}', False) + + assert "File replacement cleanup failed" in caplog.text diff --git a/tests/test1.yaml b/tests/test1.yaml index 7b54ddc06a..f4a2b6f7a4 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -3070,6 +3070,9 @@ remote_receiver: on_coolix: then: delay: !lambda "return x.first + x.second;" + on_rc_switch: + then: + delay: !lambda "return uint32_t(x.code) + x.protocol;" status_led: pin: GPIO2 diff --git a/tests/test2.yaml b/tests/test2.yaml index bfc886eaa4..b258b103d3 100644 --- a/tests/test2.yaml +++ b/tests/test2.yaml @@ -718,6 +718,7 @@ stepper: interval: interval: 5s + startup_delay: 10s then: - logger.log: Interval Run @@ -752,6 +753,18 @@ image: file: pnglogo.png type: RGB565 use_transparency: no + - id: web_svg_image + file: https://raw.githubusercontent.com/esphome/esphome-docs/a62d7ab193c1a464ed791670170c7d518189109b/images/logo.svg + resize: 256x48 + type: TRANSPARENT_BINARY + - id: web_tiff_image + file: https://upload.wikimedia.org/wikipedia/commons/b/b6/SIPI_Jelly_Beans_4.1.07.tiff + type: RGB24 + resize: 48x48 + - id: web_redirect_image + file: https://avatars.githubusercontent.com/u/3060199?s=48&v=4 + type: RGB24 + resize: 48x48 - id: mdi_alert file: mdi:alert-circle-outline diff --git a/tests/test3.1.yaml b/tests/test3.1.yaml index 151e53fd62..9000636f63 100644 --- a/tests/test3.1.yaml +++ b/tests/test3.1.yaml @@ -225,6 +225,13 @@ sensor: name: "ADE7953 Reactive Power B" update_interval: 1s + - platform: ens160 + eco2: + name: "ENS160 eCO2" + tvoc: + name: "ENS160 Total Volatile Organic Compounds" + aqi: + name: "ENS160 Air Quality Index" - platform: tmp102 name: TMP102 Temperature - platform: hm3301 @@ -424,7 +431,6 @@ switch: direction: BACKWARD id: test_motor - custom_component: lambda: |- auto s = new CustomComponent(); @@ -613,7 +619,6 @@ mcp23017: mcp23008: id: mcp23008_hub - light: - platform: hbridge name: Icicle Lights @@ -633,7 +638,6 @@ ttp229_bsf: sdo_pin: D2 scl_pin: D1 - display: - platform: max7219digit cs_pin: GPIO15 @@ -645,7 +649,6 @@ display: lambda: |- it.printdigit("hello"); - http_request: useragent: esphome/device timeout: 10s diff --git a/tests/test3.yaml b/tests/test3.yaml index 41ded7ee39..0a405a2841 100644 --- a/tests/test3.yaml +++ b/tests/test3.yaml @@ -480,7 +480,6 @@ sensor: name: PZEMDC Power energy: name: PZEMDC Energy - - platform: pmsx003 uart_id: uart_9 type: PMSX003 diff --git a/tests/test5.yaml b/tests/test5.yaml index 82c201f017..46cedcabd2 100644 --- a/tests/test5.yaml +++ b/tests/test5.yaml @@ -41,7 +41,9 @@ uart: rx_pin: 3 baud_rate: 9600 - id: uart_2 - tx_pin: 17 + tx_pin: + number: 17 + inverted: true rx_pin: 16 baud_rate: 19200 diff --git a/tests/unit_tests/test_cpp_generator.py b/tests/unit_tests/test_cpp_generator.py index 331c500c04..6f4b5a40bc 100644 --- a/tests/unit_tests/test_cpp_generator.py +++ b/tests/unit_tests/test_cpp_generator.py @@ -1,4 +1,4 @@ -from typing import Iterator +from collections.abc import Iterator import math diff --git a/tests/unit_tests/test_cpp_helpers.py b/tests/unit_tests/test_cpp_helpers.py index ad234250ce..497b3966fb 100644 --- a/tests/unit_tests/test_cpp_helpers.py +++ b/tests/unit_tests/test_cpp_helpers.py @@ -1,5 +1,5 @@ import pytest -from mock import Mock +from unittest.mock import Mock from esphome import cpp_helpers as ch from esphome import const diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py index 67fabd7af8..79d39901f0 100644 --- a/tests/unit_tests/test_helpers.py +++ b/tests/unit_tests/test_helpers.py @@ -258,9 +258,9 @@ def test_snake_case(text, expected): "text, expected", ( ("foo_bar", "foo_bar"), - ('!"§$%&/()=?foo_bar', "foo_bar"), - ('foo_!"§$%&/()=?bar', "foo_bar"), - ('foo_bar!"§$%&/()=?', "foo_bar"), + ('!"§$%&/()=?foo_bar', "___________foo_bar"), + ('foo_!"§$%&/()=?bar', "foo____________bar"), + ('foo_bar!"§$%&/()=?', "foo_bar___________"), ), ) def test_sanitize(text, expected):