Merge branch 'dev' into optolink

This commit is contained in:
j0ta29 2023-11-23 21:51:49 +01:00 committed by GitHub
commit 212ca30505
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
87 changed files with 4186 additions and 2604 deletions

97
.github/actions/build-image/action.yaml vendored Normal file
View file

@ -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

View file

@ -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,34 +102,105 @@ 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

View file

@ -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 \

View file

@ -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)
image_name = f"esphome/esphome{suffix}"
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:
full_tags += [f"ghcr.io/esphome/esphome{suffix}:{tag}"]
full_tags += [f"esphome/esphome{suffix}:{tag}"]
print(f"tags={','.join(full_tags)}", file=f)
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__":

View file

@ -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):

View file

@ -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()

View file

@ -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(
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)
)
.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))

View file

@ -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<climate::ClimateFanMode> 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<climate::ClimateSwingMode> swing_modes_ = {};
std::set<climate::ClimatePreset> presets_ = {};
remote_transmitter::RemoteTransmitterComponent *transmitter_;
sensor::Sensor *sensor_{nullptr};
};

View file

@ -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_base::CoolixProtocol>(remote_state);
}
bool CoolixClimate::on_coolix(climate::Climate *parent, remote_base::RemoteReceiveData data) {

View file

@ -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,6 +497,9 @@ 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)
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():
@ -495,6 +545,9 @@ async def to_code(config):
[f"platformio/framework-arduinoespressif32@{conf[CONF_SOURCE]}"],
)
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(
@ -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,

View file

@ -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

View file

@ -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")

View file

@ -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<std::chrono::milliseconds>(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<std::chrono::milliseconds>(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<haier_protocol::HaierMessage>()});
}
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<haier_protocol::HaierMessage>()});
}
void HaierClimateBase::toggle_power() { this->action_request_ = ActionRequest::TOGGLE_POWER; }
void HaierClimateBase::toggle_power() {
this->action_request_ =
PendingAction({ActionRequest::TOGGLE_POWER, esphome::optional<haier_protocol::HaierMessage>()});
}
void HaierClimateBase::set_supported_swing_modes(const std::set<climate::ClimateSwingMode> &modes) {
this->traits_.set_supported_swing_modes(modes);
@ -165,9 +165,7 @@ void HaierClimateBase::set_supported_swing_modes(const std::set<climate::Climate
this->traits_.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<climate::ClimateMode> &modes) {
this->traits_.set_supported_modes(modes);
@ -183,29 +181,42 @@ void HaierClimateBase::set_supported_presets(const std::set<climate::ClimatePres
void HaierClimateBase::set_send_wifi(bool send_wifi) { this->send_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,
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<std::chrono::milliseconds>(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) ||
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)) {
(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) {
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->set_phase(ProtocolPhases::SENDING_POWER_ON_COMMAND);
break;
this->action_request_.value().message = this->get_power_message(true);
return true;
case ActionRequest::TURN_POWER_OFF:
this->set_phase(ProtocolPhases::SENDING_POWER_OFF_COMMAND);
break;
this->action_request_.value().message = this->get_power_message(false);
return true;
case ActionRequest::TOGGLE_POWER:
case ActionRequest::NO_ACTION:
// shouldn't get here, do nothing
break;
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_);
break;
ESP_LOGW(TAG, "Unsupported action: %d", (uint8_t) this->action_request_.value().action);
this->action_request_.reset();
return false;
}
this->action_request_ = ActionRequest::NO_ACTION;
} 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();
}

View file

@ -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<esphome::climate::ClimateMode> &modes);
void set_supported_swing_modes(const std::set<esphome::climate::ClimateSwingMode> &modes);
void set_supported_presets(const std::set<esphome::climate::ClimatePreset> &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,28 +115,33 @@ class HaierClimateBase : public esphome::Component,
esphome::optional<esphome::climate::ClimatePreset> preset;
bool valid;
HvacSettings() : valid(false){};
HvacSettings(const HvacSettings &) = default;
HvacSettings &operator=(const HvacSettings &) = default;
void reset();
};
struct PendingAction {
ActionRequest action;
esphome::optional<haier_protocol::HaierMessage> message;
};
haier_protocol::ProtocolHandler haier_protocol_;
ProtocolPhases protocol_phase_;
ActionRequest action_request_;
esphome::optional<PendingAction> 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<uint8_t[]> 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<std::chrono::milliseconds> answer_timeout_; // Message answer timeout
bool send_wifi_signal_;
std::chrono::steady_clock::time_point last_signal_request_; // To send WiFI signal level
};

View file

@ -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<uint8_t[]>(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<haier_protocol::HaierMessage>()});
}
}
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::HaierMessage>()});
}
}
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) {
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);
} 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)) {
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);
} else if (this->protocol_phase_ == ProtocolPhases::WAITING_CONTROL_ANSWER) {
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->set_force_send_control_(false);
if (this->hvac_settings_.valid)
this->hvac_settings_.reset();
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,
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, (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 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,
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) {
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,
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,108 +324,94 @@ 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)) {
@ -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<uint8_t>({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<uint8_t>({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<haier_protocol::HaierMessage>()});
}
this->cleaning_status_ = new_cleaning;
}
@ -783,50 +765,256 @@ 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::milliseconds>(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::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);
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:
HaierClimateBase::process_pending_action();
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::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:
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

View file

@ -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<uint8_t[]> 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<HardwareInfo> hvac_hardware_info_;
uint8_t active_alarms_[8];
int extra_control_packet_bytes_;
HonControlMethod control_method_;
esphome::sensor::Sensor *outdoor_sensor_;
std::queue<haier_protocol::HaierMessage> control_messages_queue_;
};
} // namespace haier

View file

@ -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)

View file

@ -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<uint8_t[]>(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) {
switch (this->protocol_phase_) {
case ProtocolPhases::SENDING_FIRST_STATUS_REQUEST:
ESP_LOGI(TAG, "First HVAC status received");
this->set_phase(ProtocolPhases::IDLE);
} else if (this->protocol_phase_ == ProtocolPhases::WAITING_STATUS_ANSWER) {
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);
} else if (this->protocol_phase_ == ProtocolPhases::WAITING_CONTROL_ANSWER) {
break;
case ProtocolPhases::SENDING_CONTROL:
this->set_phase(ProtocolPhases::IDLE);
this->set_force_send_control_(false);
if (this->hvac_settings_.valid)
this->hvac_settings_.reset();
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,
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 != (uint8_t) smartair2_protocol::FrameType::GET_DEVICE_VERSION)
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) {
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);
} else {
// Returning to the previous state to try again
this->set_phase((ProtocolPhases) ((int) this->protocol_phase_ - 1));
}
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 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);
} 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_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()) {
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_both = 0;
out_data->swing_mode = 0;
break;
case CLIMATE_SWING_VERTICAL:
out_data->swing_both = 0;
out_data->swing_mode = 0;
out_data->vertical_swing = 1;
out_data->horizontal_swing = 0;
break;
case CLIMATE_SWING_HORIZONTAL:
out_data->swing_both = 0;
out_data->swing_mode = 0;
out_data->vertical_swing = 0;
out_data->horizontal_swing = 1;
break;
case CLIMATE_SWING_BOTH:
out_data->swing_both = 1;
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,16 +480,26 @@ 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 (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 {
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) {
@ -513,50 +510,27 @@ haier_protocol::HandlerError Smartair2Climate::process_status_message_(const uin
} 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::milliseconds>(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

View file

@ -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<uint8_t[]> last_status_message_;
unsigned int timeouts_counter_;
bool use_alternative_swing_control_;
};
} // namespace haier

View file

@ -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

View file

@ -3,7 +3,6 @@
#ifdef USE_ARDUINO
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/components/remote_transmitter/remote_transmitter.h"
#include <IRSender.h> // 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

View file

@ -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):

View file

@ -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]))

View file

@ -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

View file

@ -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_) {

View file

@ -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(); }

View file

@ -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);
{
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;
{
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);
}
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

View file

@ -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_;

View file

@ -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)

View file

@ -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();

View file

@ -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)

View file

@ -67,20 +67,7 @@ class MideaProtocol : public RemoteProtocol<MideaData> {
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<uint8_t> &code) { this->data_ = code; }
protected:
MideaData data_;
};
using MideaTrigger = RemoteReceiverTrigger<MideaProtocol, MideaData>;
using MideaDumper = RemoteReceiverDumper<MideaProtocol, MideaData>;
DECLARE_REMOTE_PROTOCOL(Midea)
template<typename... Ts> class MideaAction : public RemoteTransmitterActionBase<Ts...> {
TEMPLATABLE_VALUE(std::vector<uint8_t>, code)

View file

@ -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<RCSwitchBase, RCSwitchData>;
using RCSwitchTrigger = RemoteReceiverTrigger<RCSwitchBase>;
} // namespace remote_base
} // namespace esphome

View file

@ -127,6 +127,14 @@ class RemoteTransmitterBase : public RemoteComponentBase {
this->temp_.reset();
return TransmitCall(this);
}
template<typename Protocol>
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<typename T> class RemoteProtocol {
public:
virtual void encode(RemoteTransmitData *dst, const T &data) = 0;
virtual optional<T> 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<ProtocolData> decode(RemoteReceiveData src) = 0;
virtual void dump(const ProtocolData &data) = 0;
};
template<typename T, typename D> class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase {
template<typename T> class RemoteReceiverBinarySensor : public RemoteReceiverBinarySensorBase {
public:
RemoteReceiverBinarySensor() : RemoteReceiverBinarySensorBase() {}
@ -201,13 +210,14 @@ template<typename T, typename D> 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<typename T, typename D> class RemoteReceiverTrigger : public Trigger<D>, public RemoteReceiverListener {
template<typename T>
class RemoteReceiverTrigger : public Trigger<typename T::ProtocolData>, public RemoteReceiverListener {
protected:
bool on_receive(RemoteReceiveData src) override {
auto proto = T();
@ -220,28 +230,36 @@ template<typename T, typename D> class RemoteReceiverTrigger : public Trigger<D>
}
};
template<typename... Ts> class RemoteTransmitterActionBase : public Action<Ts...> {
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<typename Protocol>
void transmit_(const typename Protocol::ProtocolData &data, uint32_t send_times = 1, uint32_t send_wait = 0) {
this->transmitter_->transmit<Protocol>(data, send_times, send_wait);
}
RemoteTransmitterBase *transmitter_;
};
template<typename... Ts> class RemoteTransmitterActionBase : public RemoteTransmittable, public Action<Ts...> {
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<typename T, typename D> class RemoteReceiverDumper : public RemoteReceiverDumperBase {
template<typename T> class RemoteReceiverDumper : public RemoteReceiverDumperBase {
public:
bool dump(RemoteReceiveData src) override {
auto proto = T();
@ -254,9 +272,9 @@ template<typename T, typename D> class RemoteReceiverDumper : public RemoteRecei
};
#define DECLARE_REMOTE_PROTOCOL_(prefix) \
using prefix##BinarySensor = RemoteReceiverBinarySensor<prefix##Protocol, prefix##Data>; \
using prefix##Trigger = RemoteReceiverTrigger<prefix##Protocol, prefix##Data>; \
using prefix##Dumper = RemoteReceiverDumper<prefix##Protocol, prefix##Data>;
using prefix##BinarySensor = RemoteReceiverBinarySensor<prefix##Protocol>; \
using prefix##Trigger = RemoteReceiverTrigger<prefix##Protocol>; \
using prefix##Dumper = RemoteReceiverDumper<prefix##Protocol>;
#define DECLARE_REMOTE_PROTOCOL(prefix) DECLARE_REMOTE_PROTOCOL_(prefix)
} // namespace remote_base

View file

@ -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):

View file

@ -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;

View file

@ -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();

View file

@ -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_,
}
),

View file

@ -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"))

View file

@ -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);
// 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_);
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);
}
if (this->is_ssd1305_()) {
// SSD1305 does not have charge pump
if (!this->is_ssd1305_()) {
} 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,14 +106,17 @@ void SSD1306::setup() {
// Set addressing mode to horizontal (0x20)
this->command(SSD1306_COMMAND_MEMORY_MODE);
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));
if (!this->is_sh1107_()) {
// Set pin configuration (0xDA)
this->command(SSD1306_COMMAND_SET_COM_PINS);
switch (this->model_) {
@ -98,12 +131,16 @@ void SSD1306::setup() {
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;
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:

View file

@ -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;

View file

@ -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
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)

View file

@ -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);
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++) {

View file

@ -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")

View file

@ -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<uint8_t> speaker_allocator(ExternalRAMAllocator<uint8_t>::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<int16_t> allocator(ExternalRAMAllocator<int16_t>::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<uint8_t> send_allocator(ExternalRAMAllocator<uint8_t>::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;

View file

@ -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<std::string> *get_stt_end_trigger() const { return this->stt_end_trigger_; }
Trigger<std::string> *get_tts_start_trigger() const { return this->tts_start_trigger_; }
Trigger<std::string> *get_tts_end_trigger() const { return this->tts_end_trigger_; }
Trigger<> *get_end_trigger() const { return this->end_trigger_; }
Trigger<std::string> *get_tts_start_trigger() const { return this->tts_start_trigger_; }
Trigger<std::string, std::string> *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::Socket> 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<std::string> *stt_end_trigger_ = new Trigger<std::string>();
Trigger<std::string> *tts_start_trigger_ = new Trigger<std::string>();
Trigger<std::string> *tts_end_trigger_ = new Trigger<std::string>();
Trigger<> *end_trigger_ = new Trigger<>();
Trigger<std::string> *tts_start_trigger_ = new Trigger<std::string>();
Trigger<std::string, std::string> *error_trigger_ = new Trigger<std::string, std::string>();
Trigger<> *client_connected_trigger_ = new Trigger<>();

View file

@ -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]))

View file

@ -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],

View file

@ -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<ManualIP> 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_();

View file

@ -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<ManualIP> manual_ip) {
esp_err_t err;
@ -654,6 +656,7 @@ bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> 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() {

View file

@ -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<ManualIP> manual_ip) {
// enable AP
if (!this->wifi_mode_({}, true))
@ -753,6 +755,7 @@ bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> 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();

View file

@ -17,7 +17,11 @@
#ifdef USE_WIFI_WPA2_EAP
#include <esp_wpa2.h>
#endif
#ifdef USE_WIFI_AP
#include "dhcpserver/dhcpserver.h"
#endif // USE_WIFI_AP
#include "lwip/err.h"
#include "lwip/dns.h"
@ -35,7 +39,11 @@ 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)
#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)
@ -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<wifi_ap_record_t> 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<ManualIP> manual_ip) {
esp_err_t err;
@ -816,6 +835,7 @@ bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> 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);

View file

@ -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<ManualIP> manual_ip) {
// enable AP
if (!this->wifi_mode_({}, true))
@ -423,6 +425,7 @@ bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> 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() {

View file

@ -138,6 +138,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) {
return true;
}
#ifdef USE_WIFI_AP
bool WiFiComponent::wifi_ap_ip_config_(optional<ManualIP> 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);

View file

@ -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

View file

@ -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()

135
esphome/dashboard/core.py Normal file
View file

@ -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()

File diff suppressed because it is too large Load diff

View file

@ -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

19
esphome/dashboard/enum.py Normal file
View file

@ -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)

View file

@ -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

View file

View file

@ -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

View file

@ -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()

View file

@ -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))

View file

View file

@ -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,
)

View file

@ -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)), [])

View file

@ -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()

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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)

75
esphome/external_files.py Normal file
View file

@ -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

View file

@ -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(),

View file

@ -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

View file

@ -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 =

View file

@ -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.5
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

View file

@ -1,3 +1,3 @@
pillow==10.0.1
pillow==10.1.0
cairosvg==2.7.1
cryptography==41.0.4

View file

@ -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)

View file

View file

View file

@ -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

View file

@ -3072,6 +3072,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

View file

@ -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

View file

@ -1,4 +1,4 @@
from typing import Iterator
from collections.abc import Iterator
import math

View file

@ -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