Merge branch 'dev' into ota-udp-transport

This commit is contained in:
J. Nick Koston
2026-10-10 13:27:11 -10:00
committed by GitHub
59 changed files with 1549 additions and 182 deletions
+86
View File
@@ -1335,6 +1335,91 @@ jobs:
# config first, so a separate config pass is redundant.
python3 script/test_build_components.py -e compile -t esp8266-ard -c "$TEST_COMPONENTS" -f --toolchain arduino --fail-on-no-tests
test-esp8266-native-windows:
name: Compile esp8266 with the native toolchain on Windows (ccache ${{ matrix.ccache }})
runs-on: windows-latest
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp8266-native == 'true'
# A hung compiler must fail the job, not hold the runner for six hours.
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
# Both ways the build runs on Windows: ESPHome Desktop bundles
# ccache and puts it on PATH, a pip install has none.
ccache: ["1", "0"]
env:
ESPHOME_CCACHE_ENABLE: ${{ matrix.ccache }}
# The test script prints characters the cp1252 console cannot encode
PYTHONUTF8: "1"
# The exact static build ESPHome Desktop ships
CCACHE_URL: https://github.com/ccache/ccache/releases/download/v4.14/ccache-4.14-windows-x86_64.zip
CCACHE_SHA256: 2568347a697e103ca1b073981c704ad76fb2507d066c38dba038dd73399d968f
steps:
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Install ccache
if: matrix.ccache == '1'
shell: pwsh
run: |
$dir = Join-Path $env:RUNNER_TEMP "ccache"
New-Item -ItemType Directory -Force $dir | Out-Null
$zip = Join-Path $dir "ccache.zip"
Invoke-WebRequest -Uri $env:CCACHE_URL -OutFile $zip
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
if ($hash -ne $env:CCACHE_SHA256) {
Write-Host "::error::ccache archive sha256 $hash does not match $env:CCACHE_SHA256"
exit 1
}
Expand-Archive -Path $zip -DestinationPath $dir
$bin = Get-ChildItem -Path $dir -Recurse -Filter ccache.exe | Select-Object -First 1
Add-Content -Path $env:GITHUB_PATH -Value $bin.DirectoryName
& $bin.FullName --version
- name: Compile
shell: pwsh
# One component test: web_server brings wifi, api and the async web
# server libraries, the shape of a typical ESP8266 device
run: |
. .\venv\Scripts\Activate.ps1
$arguments = "script/test_build_components.py", "-e", "compile", "-t", "esp8266-ard", "-c", "web_server", "--base-only", "--toolchain", "arduino", "--fail-on-no-tests"
$p = Start-Process -FilePath python -ArgumentList $arguments -NoNewWindow -PassThru
# Reading the handle keeps ExitCode available after the process exits
$null = $p.Handle
if (-not $p.WaitForExit(25 * 60 * 1000)) {
Write-Host "::error::The compile did not finish in 25 minutes; processes still running:"
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -match 'xtensa|ccache|ninja|esphome' } |
Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine |
Format-List | Out-String -Width 4096 | Write-Host
Get-Process |
Where-Object { $_.Name -match 'cc1|ccache|ninja|xtensa|python' } |
Select-Object Id, Name, CPU, WorkingSet64 |
Format-Table | Out-String | Write-Host
taskkill /PID $p.Id /T /F
exit 1
}
exit $p.ExitCode
- name: Show ccache statistics
# Also after a failed or killed compile: the counters say whether
# ccache was ever reached
if: always() && matrix.ccache == '1'
shell: pwsh
# The build keeps its store under the esphome tools cache
run: |
$env:CCACHE_DIR = Join-Path $env:LOCALAPPDATA "esphome\Cache\arduino8266\ccache"
ccache -s
device-builder:
name: Test downstream esphome/device-builder
runs-on: ubuntu-24.04
@@ -1705,6 +1790,7 @@ jobs:
- test-build-components-split
- test-esp32-platformio
- test-esp8266-native
- test-esp8266-native-windows
- device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
+15 -1
View File
@@ -16,16 +16,18 @@ jobs:
# No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
# GitHub App token so the labels, comments and closures come from
# esphome[bot] instead of github-actions[bot].
uses: esphome/workflows/.github/workflows/stale.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # main
uses: esphome/workflows/.github/workflows/stale.yml@4fbce13026c393d6eb6b247e394dcd9ef1af76c2 # main
secrets:
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
with:
# Live only on dev: a workflow_dispatch from any other branch is a dry run
dry-run: ${{ github.ref != 'refs/heads/dev' }}
days-before-stale: 90
days-before-stale-conflicting: 30
days-before-close: 7
stale-label: stale
exempt-label: not-stale
conflict-label: needs-rebase
ignored-users: esphbot,codecov-commenter
stale-pr-message: >
There hasn't been any activity on this pull request recently. This
@@ -36,6 +38,18 @@ jobs:
to keep it open. Also, please merge the latest dev branch into your
branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!
conflict-pr-message: >
This pull request has merge conflicts. Please merge the latest dev
branch into your branch to resolve them.
stale-conflicting-pr-message: >
The merge conflicts on this pull request have not been resolved for
30 days, so it has been marked as stale and will be closed if they
are not resolved within 7 days.
Please merge the latest dev branch into your branch. Once the pull
request is mergeable again the stale label is removed.
Thank you for your contribution!
stale-issue-message: >
There hasn't been any activity on this issue recently. Due to the
+1 -1
View File
@@ -22,7 +22,7 @@ RUN \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.0
RUN uv pip install --no-cache-dir esphome-device-builder==1.23.1
RUN \
platformio settings set enable_telemetry No \
+1 -1
View File
@@ -1057,7 +1057,7 @@ def write_project(paths: InstalledPaths, ccache: str | None) -> bool:
cxx = (toolchain_tool(paths.toolchain, "g++"),)
lines = [
*tool_lines((toolchain_tool(paths.toolchain, "gcc"),), cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*ar_rule_lines(toolchain_tool(paths.toolchain, "ar")),
*pch_rule_lines(),
"rule link",
+1 -1
View File
@@ -240,7 +240,7 @@ def write_project(compilers: HostCompilers, ccache: str | None) -> bool:
lines = [
*tool_lines(compilers.cc, compilers.cxx, ccache),
*compile_rule_lines(),
*compile_rule_lines(ccache),
*pch_rule_lines(),
"rule link",
" command = $cxx -o $out $linkflags @$out.rsp $archives $libdirflags $libflags",
+14 -7
View File
@@ -74,33 +74,40 @@ def tool_lines(
# path gets
f"python = {_q(strip_win_long_path_prefix(sys.executable))}",
f"buildtool = {_q(_BUILD_TOOL)}",
f"ccache = {_q(ccache) if ccache else ''}",
*([f"ccache = {_q(ccache)}"] if ccache else []),
"",
]
def compile_rule_lines() -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it."""
def compile_rule_lines(ccache: str | None) -> list[str]:
"""The compile rules; ``$own_includes`` is empty unless an edge sets it.
``ccache`` is the launcher ``tool_lines`` was given; the same test here
keeps the variable and the rules in step. It is spliced in rather than
left as an empty variable: a leading space on the command line makes
CreateProcess fail on Windows.
"""
launcher = "$ccache " if ccache else ""
return [
"rule c",
" command = $ccache $cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d $own_includes $cflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CC $out",
"rule cxx",
" command = $ccache $cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
f" command = {launcher}$cxx -MMD -MF $out.d $own_includes $cxxflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = CXX $out",
"rule aspp",
" command = $ccache $cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
f" command = {launcher}$cc -MMD -MF $out.d -x assembler-with-cpp $own_includes $asflags $flags -c $in -o $out",
" depfile = $out.d",
" deps = gcc",
" description = AS $out",
# Plain assembler, as SCons's ASCOM: no preprocessor, so no
# depfile and no $flags (defines/includes) either
"rule asm",
" command = $ccache $cc -x assembler $asflags -c $in -o $out",
f" command = {launcher}$cc -x assembler $asflags -c $in -o $out",
" description = AS $out",
]
+1 -1
View File
@@ -1,4 +1,5 @@
import esphome.codegen as cg
from esphome.components.const import CONF_AQI # noqa: F401
CODEOWNERS = ["@jasstrong", "@ximex", "@freekode"]
DOMAIN = "aqi"
@@ -6,7 +7,6 @@ DOMAIN = "aqi"
aqi_ns = cg.esphome_ns.namespace("aqi")
AQICalculatorType = aqi_ns.enum("AQICalculatorType")
CONF_AQI = "aqi"
CONF_CALCULATION_TYPE = "calculation_type"
CONF_EXTENDED_RANGE = "extended_range"
+60 -21
View File
@@ -6,10 +6,16 @@
namespace esphome::cm1106 {
ESPHOME_LOG_TAG(TAG, "cm1106");
static const uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED};
static const uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00};
static const uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6};
static constexpr uint8_t C_M1106_CMD_GET_CO2[4] = {0x11, 0x01, 0x01, 0xED};
static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB[6] = {0x11, 0x03, 0x03, 0x00, 0x00, 0x00};
static constexpr uint8_t C_M1106_CMD_SET_CO2_CALIB_RESPONSE[4] = {0x16, 0x01, 0x03, 0xE6};
// The factory default ABC calibration cycle differs between CM1106 models, so
// every ABC write applies the configured cycle and baseline explicitly.
static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS[10] = {0x11, 0x07, 0x10, 0x64, 0x00, 0x0F, 0x01, 0x90, 0x64, 0x00};
static constexpr uint8_t C_M1106_CMD_SET_ABC_STATUS_RESPONSE[4] = {0x16, 0x01, 0x10, 0xD9};
static constexpr uint8_t CM1106_ABC_FLAG_ENABLE = 0x0;
static constexpr uint8_t CM1106_ABC_FLAG_DISABLE = 0x2;
uint8_t cm1106_checksum(const uint8_t *response, size_t len) {
uint8_t crc = 0;
for (size_t i = 0; i < len - 1; i++) {
@@ -25,34 +31,46 @@ void CM1106Component::setup() {
this->mark_failed();
return;
}
if (this->abc_boot_logic_ != CM1106_ABC_NONE) {
this->abc_set_(this->abc_boot_logic_ == CM1106_ABC_ENABLED);
}
}
void CM1106Component::update() {
uint8_t response[8] = {0};
if (!this->cm1106_write_command_(C_M1106_CMD_GET_CO2, sizeof(C_M1106_CMD_GET_CO2), response, sizeof(response))) {
ESP_LOGW(TAG, "Reading data from CM1106 failed!");
ESP_LOGW(TAG, "Reading data failed!");
this->status_set_warning();
return;
}
if (response[0] != 0x16 || response[1] != 0x05 || response[2] != 0x01) {
ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2],
response[3]);
ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]);
this->status_set_warning();
return;
}
uint8_t checksum = cm1106_checksum(response, sizeof(response));
if (response[7] != checksum) {
ESP_LOGW(TAG, "CM1106 Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum);
ESP_LOGW(TAG, "Checksum doesn't match: 0x%02X!=0x%02X", response[7], checksum);
this->status_set_warning();
return;
}
const uint16_t ppm = response[3] << 8 | response[4];
const uint8_t status = response[5];
if (status & 0x1) {
ESP_LOGW(TAG, "Preheating; CO₂=%uppm", ppm);
this->status_set_warning();
return;
}
if (status) {
ESP_LOGW(TAG, "Status: %02X", status);
}
this->status_clear_warning();
uint16_t ppm = response[3] << 8 | response[4];
ESP_LOGD(TAG, "CM1106 Received CO₂=%uppm DF3=%02X DF4=%02X", ppm, response[5], response[6]);
ESP_LOGD(TAG, "CO₂=%uppm DF4=%02X", ppm, response[6]);
if (this->co2_sensor_ != nullptr)
this->co2_sensor_->publish_state(ppm);
}
@@ -62,24 +80,38 @@ void CM1106Component::calibrate_zero(uint16_t ppm) {
memcpy(cmd, C_M1106_CMD_SET_CO2_CALIB, sizeof(cmd));
cmd[3] = ppm >> 8;
cmd[4] = ppm & 0xFF;
if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_CO2_CALIB_RESPONSE)) {
ESP_LOGD(TAG, "Successfully calibrated sensor to %uppm", ppm);
}
}
void CM1106Component::abc_set_(bool enabled) {
ESP_LOGD(TAG, "%sabling automatic baseline calibration", enabled ? LOG_STR_LITERAL("En") : LOG_STR_LITERAL("Dis"));
uint8_t cmd[10];
memcpy(cmd, C_M1106_CMD_SET_ABC_STATUS, sizeof(cmd));
cmd[4] = enabled ? CM1106_ABC_FLAG_ENABLE : CM1106_ABC_FLAG_DISABLE;
cmd[5] = this->abc_cycle_;
cmd[6] = this->abc_baseline_ >> 8;
cmd[7] = this->abc_baseline_ & 0xFF;
if (this->send_command_expect_ack_(cmd, sizeof(cmd), C_M1106_CMD_SET_ABC_STATUS_RESPONSE)) {
ESP_LOGD(TAG, "Successfully set ABC status");
}
}
bool CM1106Component::send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack) {
uint8_t response[4] = {0};
if (!this->cm1106_write_command_(cmd, sizeof(cmd), response, sizeof(response))) {
ESP_LOGW(TAG, "Reading data from CM1106 failed!");
if (!this->cm1106_write_command_(command, command_len, response, sizeof(response))) {
ESP_LOGW(TAG, "Command %02X failed", command[2]);
this->status_set_warning();
return;
return false;
}
// check if correct response received
if (memcmp(response, C_M1106_CMD_SET_CO2_CALIB_RESPONSE, sizeof(response)) != 0) {
ESP_LOGW(TAG, "Got wrong UART response from CM1106: %02X %02X %02X %02X", response[0], response[1], response[2],
response[3]);
if (memcmp(response, ack, sizeof(response)) != 0) {
ESP_LOGW(TAG, "Unexpected response: %02X %02X %02X %02X", response[0], response[1], response[2], response[3]);
this->status_set_warning();
return;
return false;
}
this->status_clear_warning();
ESP_LOGD(TAG, "CM1106 Successfully calibrated sensor to %uppm", ppm);
return true;
}
bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response,
@@ -100,6 +132,13 @@ bool CM1106Component::cm1106_write_command_(const uint8_t *command, size_t comma
void CM1106Component::dump_config() {
ESP_LOGCONFIG(TAG, "CM1106:");
LOG_SENSOR(" ", "CO2", this->co2_sensor_);
if (this->abc_boot_logic_ != CM1106_ABC_NONE) {
ESP_LOGCONFIG(TAG,
" Automatic baseline calibration on boot: %s\n"
" ABC calibration cycle: %u days\n"
" ABC baseline: %uppm",
ONOFF(this->abc_boot_logic_ == CM1106_ABC_ENABLED), this->abc_cycle_, this->abc_baseline_);
}
if (this->is_failed()) {
ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL);
}
+23 -2
View File
@@ -6,6 +6,14 @@
namespace esphome::cm1106 {
// ABC logic requested at boot; CM1106_ABC_NONE means nothing is sent and the
// sensor keeps its stored setting.
enum CM1106ABCLogic : uint8_t {
CM1106_ABC_NONE = 0,
CM1106_ABC_ENABLED,
CM1106_ABC_DISABLED,
};
class CM1106Component final : public PollingComponent, public uart::UARTDevice {
public:
void setup() override;
@@ -13,13 +21,26 @@ class CM1106Component final : public PollingComponent, public uart::UARTDevice {
void dump_config() override;
void calibrate_zero(uint16_t ppm);
void abc_enable() { this->abc_set_(true); }
void abc_disable() { this->abc_set_(false); }
void set_co2_sensor(sensor::Sensor *co2_sensor) { this->co2_sensor_ = co2_sensor; }
void set_abc(bool enabled, uint8_t cycle_days, uint16_t baseline_ppm) {
this->abc_boot_logic_ = enabled ? CM1106_ABC_ENABLED : CM1106_ABC_DISABLED;
this->abc_cycle_ = cycle_days;
this->abc_baseline_ = baseline_ppm;
}
protected:
sensor::Sensor *co2_sensor_{nullptr};
bool cm1106_write_command_(const uint8_t *command, size_t command_len, uint8_t *response, size_t response_len);
/// Sends a command and checks the sensor's four byte acknowledgement, updating the warning status.
bool send_command_expect_ack_(const uint8_t *command, size_t command_len, const uint8_t *ack);
void abc_set_(bool enabled);
sensor::Sensor *co2_sensor_{nullptr};
CM1106ABCLogic abc_boot_logic_{CM1106_ABC_NONE};
uint8_t abc_cycle_{15};
uint16_t abc_baseline_{400};
};
} // namespace esphome::cm1106
+29
View File
@@ -4,9 +4,12 @@ from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import sensor, uart
from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION
import esphome.config_validation as cv
from esphome.const import (
CONF_BASELINE,
CONF_CO2,
CONF_CYCLE,
CONF_ID,
DEVICE_CLASS_CARBON_DIOXIDE,
ICON_MOLECULE_CO2,
@@ -18,11 +21,22 @@ from esphome.types import ConfigType
DEPENDENCIES = ["uart"]
CODEOWNERS = ["@andrewjswan"]
# true enables ABC with these defaults, false disables it, a mapping enables it with custom values
_ABC_SETTINGS_SCHEMA = cv.Schema(
{
cv.Optional(CONF_CYCLE, default=15): cv.int_range(min=1, max=90),
cv.Optional(CONF_BASELINE, default=400): cv.int_range(min=400, max=10000),
}
)
cm1106_ns = cg.esphome_ns.namespace("cm1106")
CM1106Component = cm1106_ns.class_(
"CM1106Component", cg.PollingComponent, uart.UARTDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
@@ -34,6 +48,9 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_AUTOMATIC_BASELINE_CALIBRATION): cv.Any(
cv.boolean, _ABC_SETTINGS_SCHEMA
),
},
)
.extend(cv.polling_component_schema("60s"))
@@ -57,6 +74,11 @@ async def to_code(config: ConfigType) -> None:
sensors = sensor.sub_sensors(config)
await sensors(CONF_CO2, var.set_co2_sensor)
if (abc := config.get(CONF_AUTOMATIC_BASELINE_CALIBRATION)) is not None:
enabled = abc if isinstance(abc, bool) else True
settings = _ABC_SETTINGS_SCHEMA({}) if isinstance(abc, bool) else abc
cg.add(var.set_abc(enabled, settings[CONF_CYCLE], settings[CONF_BASELINE]))
CALIBRATION_ACTION_SCHEMA = maybe_simple_id(
{
@@ -70,3 +92,10 @@ automation.register_apply_action(
CALIBRATION_ACTION_SCHEMA,
automation.ApplyCall("calibrate_zero(400)"),
)
for _name, _call in (
("cm1106.abc_enable", "abc_enable()"),
("cm1106.abc_disable", "abc_disable()"),
):
automation.register_apply_action(
_name, CALIBRATION_ACTION_SCHEMA, automation.ApplyCall(_call)
)
+3
View File
@@ -9,6 +9,8 @@ BYTE_ORDER_BIG = "big_endian"
CONF_ACCELEROMETER_ODR = "accelerometer_odr"
CONF_ACCELEROMETER_RANGE = "accelerometer_range"
CONF_ALLOWED_IPS = "allowed_ips"
CONF_AQI = "aqi"
CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration"
CONF_B_CONSTANT = "b_constant"
CONF_BREATH_VOC_EQUIVALENT = "breath_voc_equivalent"
CONF_BYTE_ORDER = "byte_order"
@@ -71,3 +73,4 @@ KEY_METADATA = "metadata"
UNIT_AMPERE_HOUR = "Ah"
UNIT_COUNTS = "#"
UNIT_MILLIAMPERE_HOUR = "mAh"
+1 -1
View File
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_AQI
import esphome.config_validation as cv
from esphome.const import (
CONF_COMPENSATION,
@@ -26,7 +27,6 @@ DOMAIN = "ens160_base"
ens160_ns = cg.esphome_ns.namespace("ens160_base")
CONF_AQI = "aqi"
CONFIG_SCHEMA_BASE = cv.Schema(
{
+87 -3
View File
@@ -551,9 +551,88 @@ assert all(variant in CPU_FREQUENCIES for variant in VARIANTS)
FULL_CPU_FREQUENCIES = set(itertools.chain.from_iterable(CPU_FREQUENCIES.values()))
_SDKCONFIG_CPU_FREQUENCY_OPTION = "CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ"
_SDKCONFIG_CPU_FREQUENCY_PREFIX = f"{_SDKCONFIG_CPU_FREQUENCY_OPTION}_"
# Older IDF versions named the CPU frequency options after the chip
_LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS = frozenset(
{VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3}
)
def _get_sdkconfig_cpu_frequencies(
sdkconfig_options: dict[str, str], variant: str
) -> tuple[set[int], set[int]]:
"""Return the CPU frequencies in MHz that sdkconfig_options selects and disables."""
# (integer option, choice option prefix) pairs that can carry a frequency
keys = [(_SDKCONFIG_CPU_FREQUENCY_OPTION, _SDKCONFIG_CPU_FREQUENCY_PREFIX)]
if variant in _LEGACY_SDKCONFIG_CPU_FREQUENCY_VARIANTS:
keys.append(
(
f"CONFIG_{variant}_DEFAULT_CPU_FREQ_MHZ",
f"CONFIG_{variant}_DEFAULT_CPU_FREQ_",
)
)
selected: set[int] = set()
disabled: set[int] = set()
for integer_key, choice_prefix in keys:
if (value := sdkconfig_options.get(integer_key)) is not None:
try:
selected.add(int(value))
except ValueError as err:
raise cv.Invalid(
f"{integer_key} must be an integer MHz value",
path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS],
) from err
for name, value in sdkconfig_options.items():
mhz = name.removeprefix(choice_prefix)
if mhz == name or not mhz.isdigit():
continue
if value.lower() == "y":
selected.add(int(mhz))
elif value.lower() == "n":
disabled.add(int(mhz))
else:
raise cv.Invalid(
f"{name} must be y or n",
path=[CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS],
)
return selected, disabled
def set_core_data(config):
cpu_frequency = config.get(CONF_CPU_FREQUENCY, None)
cpu_frequency = config.get(CONF_CPU_FREQUENCY)
variant = config[CONF_VARIANT]
sdkconfig_path = [CONF_FRAMEWORK, CONF_SDKCONFIG_OPTIONS]
selected, disabled = _get_sdkconfig_cpu_frequencies(
config[CONF_FRAMEWORK][CONF_SDKCONFIG_OPTIONS], variant
)
if len(selected) > 1:
conflicting = ", ".join(f"{mhz}MHz" for mhz in sorted(selected))
raise cv.Invalid(
f"sdkconfig_options contains conflicting CPU frequencies ({conflicting})",
path=sdkconfig_path,
)
sdkconfig_cpu_frequency = f"{selected.pop()}MHZ" if selected else None
if cpu_frequency is None and sdkconfig_cpu_frequency is not None:
if sdkconfig_cpu_frequency not in CPU_FREQUENCIES[variant]:
raise cv.Invalid(
f"sdkconfig_options selects {sdkconfig_cpu_frequency}, which {variant} does not support",
path=sdkconfig_path,
)
_LOGGER.warning(
"sdkconfig_options contains a CPU frequency setting; using %s. "
"Set 'esp32.cpu_frequency' to configure it directly.",
sdkconfig_cpu_frequency,
)
cpu_frequency = sdkconfig_cpu_frequency
elif (
sdkconfig_cpu_frequency is not None and sdkconfig_cpu_frequency != cpu_frequency
):
raise cv.Invalid(
f"esp32.cpu_frequency ({cpu_frequency}) conflicts with sdkconfig_options "
f"({sdkconfig_cpu_frequency})",
path=sdkconfig_path,
)
# if not specified in config, default to the maximum supported frequency
# (ESP32-P4 engineering samples are limited to 360MHz, non-engineering can do 400MHz)
if cpu_frequency is None:
@@ -562,12 +641,17 @@ def set_core_data(config):
cpu_frequency = "360MHZ"
else:
cpu_frequency = choices[-1]
config[CONF_CPU_FREQUENCY] = cpu_frequency
elif cpu_frequency not in CPU_FREQUENCIES[variant]:
raise cv.Invalid(
f"Invalid CPU frequency '{cpu_frequency}' for {config[CONF_VARIANT]}",
path=[CONF_CPU_FREQUENCY],
)
if int(cpu_frequency[:-3]) in disabled:
raise cv.Invalid(
f"sdkconfig_options disables the selected CPU frequency ({cpu_frequency})",
path=sdkconfig_path,
)
config[CONF_CPU_FREQUENCY] = cpu_frequency
if variant == VARIANT_ESP32P4 and cpu_frequency == "400MHZ":
_LOGGER.warning(
@@ -3138,7 +3222,7 @@ async def to_code(config):
# Set default CPU frequency
add_idf_sdkconfig_option(
f"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{config[CONF_CPU_FREQUENCY][:-3]}", True
f"{_SDKCONFIG_CPU_FREQUENCY_PREFIX}{config[CONF_CPU_FREQUENCY][:-3]}", True
)
# Apply LWIP optimization settings
+2 -1
View File
@@ -2,7 +2,8 @@ import logging
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_AQI, CONF_CALCULATION_TYPE
from esphome.components.aqi import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE
from esphome.components.const import CONF_AQI
import esphome.config_validation as cv
from esphome.const import (
CONF_ID,
+10
View File
@@ -4,6 +4,7 @@
#include <utility>
#include <vector>
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
namespace esphome::i2c {
@@ -53,6 +54,15 @@ class I2CBus {
return this->write_readv(address, buffer, len, nullptr, 0);
}
#ifdef I2C_PORT_FREQUENCY_COUNT
/// @brief Switch the bus frequency for the transactions that follow; an external bus
/// that does not override this reports an error and keeps its frequency
virtual ErrorCode switch_frequency(uint32_t frequency) { return ERROR_UNKNOWN; }
/// @brief The frequency the bus runs at
virtual uint32_t get_frequency() const { return 0; }
#endif
protected:
/// @brief Scans the I2C bus for devices. Devices presence is kept in an array of std::pair
/// that contains the address and the corresponding bool presence flag.
+53 -40
View File
@@ -15,7 +15,7 @@ ESPHOME_LOG_TAG(TAG, "i2c");
static constexpr size_t I2C_MAX_LOG_BYTES = 32;
void ArduinoI2CBus::setup() {
recover_();
this->recover_();
#if defined(USE_ESP8266)
wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory)
@@ -25,9 +25,9 @@ void ArduinoI2CBus::setup() {
// RP2040 datasheet Table 2 (section 1.4.3): https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf
// RP2350 datasheet Table 7 (section 9.4): https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf
if ((this->sda_pin_ / 2) % 2 == 0) {
wire_ = &Wire;
this->wire_ = &Wire;
} else {
wire_ = &Wire1;
this->wire_ = &Wire1;
}
#endif
@@ -42,22 +42,22 @@ void ArduinoI2CBus::setup() {
void ArduinoI2CBus::set_pins_and_clock_() {
#ifdef USE_RP2
wire_->setSDA(this->sda_pin_);
wire_->setSCL(this->scl_pin_);
wire_->begin();
this->wire_->setSDA(this->sda_pin_);
this->wire_->setSCL(this->scl_pin_);
this->wire_->begin();
#else
wire_->begin(static_cast<int>(sda_pin_), static_cast<int>(scl_pin_));
this->wire_->begin(static_cast<int>(this->sda_pin_), static_cast<int>(this->scl_pin_));
#endif
if (timeout_ > 0) { // if timeout specified in yaml
if (this->timeout_ > 0) { // if timeout specified in yaml
#if defined(USE_ESP8266)
// https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h
wire_->setClockStretchLimit(timeout_); // unit: us
this->wire_->setClockStretchLimit(this->timeout_); // unit: us
#elif defined(USE_RP2)
// https://github.com/earlephilhower/ArduinoCore-API/blob/e37df85425e0ac020bfad226d927f9b00d2e0fb7/api/Stream.h
wire_->setTimeout(timeout_ / 1000); // unit: ms
this->wire_->setTimeout(this->timeout_ / 1000); // unit: ms
#endif
}
wire_->setClock(frequency_);
this->wire_->setClock(this->frequency_);
}
void ArduinoI2CBus::dump_config() {
@@ -67,7 +67,7 @@ void ArduinoI2CBus::dump_config() {
" SCL Pin: GPIO%u\n"
" Frequency: %u Hz",
this->sda_pin_, this->scl_pin_, this->frequency_);
if (timeout_ > 0) {
if (this->timeout_ > 0) {
#if defined(USE_ESP8266)
ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_);
#elif defined(USE_RP2)
@@ -87,10 +87,10 @@ void ArduinoI2CBus::dump_config() {
}
if (this->scan_) {
ESP_LOGI(TAG, "Results from bus scan:");
if (scan_results_.empty()) {
if (this->scan_results_.empty()) {
ESP_LOGI(TAG, "Found no devices");
} else {
for (const auto &s : scan_results_) {
for (const auto &s : this->scan_results_) {
if (s.second) {
ESP_LOGI(TAG, "Found device at address 0x%02X", s.first);
} else {
@@ -106,7 +106,7 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe
#if defined(USE_ESP8266)
this->set_pins_and_clock_(); // reconfigure Wire global state in case there are multiple instances
#endif
if (!initialized_) {
if (!this->initialized_) {
ESP_LOGD(TAG, "i2c bus not initialized!");
return ERROR_NOT_INITIALIZED;
}
@@ -118,22 +118,22 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe
uint8_t status = 0;
if (write_count != 0 || read_count == 0) {
wire_->beginTransmission(address);
size_t ret = wire_->write(write_buffer, write_count);
this->wire_->beginTransmission(address);
size_t ret = this->wire_->write(write_buffer, write_count);
if (ret != write_count) {
ESP_LOGV(TAG, "TX failed");
return ERROR_UNKNOWN;
}
status = wire_->endTransmission(read_count == 0);
status = this->wire_->endTransmission(read_count == 0);
}
if (status == 0 && read_count != 0) {
size_t ret2 = wire_->requestFrom(address, read_count, true);
size_t ret2 = this->wire_->requestFrom(address, read_count, true);
if (ret2 != read_count) {
ESP_LOGVV(TAG, "RX %u from %02X failed with error %u", read_count, address, ret2);
return ERROR_TIMEOUT;
}
for (size_t j = 0; j != read_count; j++)
read_buffer[j] = wire_->read();
read_buffer[j] = this->wire_->read();
}
// Avoid switch to prevent compiler-generated lookup table in RAM on ESP8266
if (status == 0)
@@ -154,6 +154,19 @@ ErrorCode ArduinoI2CBus::write_readv(uint8_t address, const uint8_t *write_buffe
return ERROR_UNKNOWN;
}
#ifdef I2C_PORT_FREQUENCY_COUNT
ErrorCode ArduinoI2CBus::switch_frequency(uint32_t frequency) {
if (this->frequency_ == frequency) {
return ERROR_OK;
}
this->frequency_ = frequency;
if (this->initialized_) {
this->wire_->setClock(this->frequency_);
}
return ERROR_OK;
}
#endif
/// Perform I2C bus recovery, see:
/// https://www.nxp.com/docs/en/user-guide/UM10204.pdf
/// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf
@@ -167,15 +180,15 @@ void ArduinoI2CBus::recover_() {
const auto half_period_usec = 1000000 / 100000 / 2;
// Activate input and pull up resistor for the SCL pin.
pinMode(scl_pin_, INPUT_PULLUP); // NOLINT
pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT
// This should make the signal on the line HIGH. If SCL is pulled low
// on the I2C bus however, then some device is interfering with the SCL
// line. In that case, the I2C bus cannot be recovered.
delayMicroseconds(half_period_usec);
if (digitalRead(scl_pin_) == LOW) { // NOLINT
if (digitalRead(this->scl_pin_) == LOW) { // NOLINT
ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus");
recovery_result_ = RECOVERY_FAILED_SCL_LOW;
this->recovery_result_ = RECOVERY_FAILED_SCL_LOW;
return;
}
@@ -189,18 +202,18 @@ void ArduinoI2CBus::recover_() {
// Make sure that switching to output mode will make SCL low, just in
// case other code has setup the pin for a HIGH signal.
digitalWrite(scl_pin_, LOW); // NOLINT
digitalWrite(this->scl_pin_, LOW); // NOLINT
delayMicroseconds(half_period_usec);
for (auto i = 0; i < 9; i++) {
// Release pull up resistor and switch to output to make the signal LOW.
pinMode(scl_pin_, INPUT); // NOLINT
pinMode(scl_pin_, OUTPUT); // NOLINT
pinMode(this->scl_pin_, INPUT); // NOLINT
pinMode(this->scl_pin_, OUTPUT); // NOLINT
delayMicroseconds(half_period_usec);
// Release output and activate pull up resistor to make the signal HIGH.
pinMode(scl_pin_, INPUT); // NOLINT
pinMode(scl_pin_, INPUT_PULLUP); // NOLINT
pinMode(this->scl_pin_, INPUT); // NOLINT
pinMode(this->scl_pin_, INPUT_PULLUP); // NOLINT
delayMicroseconds(half_period_usec);
// When SCL is kept LOW at this point, we might be looking at a device
@@ -211,28 +224,28 @@ void ArduinoI2CBus::recover_() {
// should recover in a few ms or less else not likely to recovery at
// all.
auto wait = 250;
while (wait-- && digitalRead(scl_pin_) == LOW) { // NOLINT
while (wait-- && digitalRead(this->scl_pin_) == LOW) { // NOLINT
App.feed_wdt();
delayMicroseconds(half_period_usec * 2);
}
if (digitalRead(scl_pin_) == LOW) { // NOLINT
if (digitalRead(this->scl_pin_) == LOW) { // NOLINT
ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle");
recovery_result_ = RECOVERY_FAILED_SCL_LOW;
this->recovery_result_ = RECOVERY_FAILED_SCL_LOW;
return;
}
}
// Activate input and pull resistor for the SDA pin, so we can verify
// that SDA is pulled HIGH in the following step.
pinMode(sda_pin_, INPUT_PULLUP); // NOLINT
digitalWrite(sda_pin_, LOW); // NOLINT
pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT
digitalWrite(this->sda_pin_, LOW); // NOLINT
// By now, any stuck device ought to have sent all remaining bits of its
// transaction, meaning that it should have freed up the SDA line, resulting
// in SDA being pulled up.
if (digitalRead(sda_pin_) == LOW) { // NOLINT
if (digitalRead(this->sda_pin_) == LOW) { // NOLINT
ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle");
recovery_result_ = RECOVERY_FAILED_SDA_LOW;
this->recovery_result_ = RECOVERY_FAILED_SDA_LOW;
return;
}
@@ -248,8 +261,8 @@ void ArduinoI2CBus::recover_() {
// SCL and SDA are already high at this point, so we can generate a START
// condition by making the SDA signal LOW.
delayMicroseconds(half_period_usec);
pinMode(sda_pin_, INPUT); // NOLINT
pinMode(sda_pin_, OUTPUT); // NOLINT
pinMode(this->sda_pin_, INPUT); // NOLINT
pinMode(this->sda_pin_, OUTPUT); // NOLINT
// From the specification:
// "A START condition immediately followed by a STOP condition (void
@@ -258,10 +271,10 @@ void ArduinoI2CBus::recover_() {
// Finally, we'll bring the I2C bus into a starting state by generating
// a STOP condition.
delayMicroseconds(half_period_usec);
pinMode(sda_pin_, INPUT); // NOLINT
pinMode(sda_pin_, INPUT_PULLUP); // NOLINT
pinMode(this->sda_pin_, INPUT); // NOLINT
pinMode(this->sda_pin_, INPUT_PULLUP); // NOLINT
recovery_result_ = RECOVERY_COMPLETED;
this->recovery_result_ = RECOVERY_COMPLETED;
}
} // namespace esphome::i2c
+9 -5
View File
@@ -22,13 +22,17 @@ class ArduinoI2CBus final : public InternalI2CBus, public Component {
size_t read_count) override;
float get_setup_priority() const override { return setup_priority::BUS; }
void set_scan(bool scan) { scan_ = scan; }
void set_sda_pin(uint8_t sda_pin) { sda_pin_ = sda_pin; }
void set_scl_pin(uint8_t scl_pin) { scl_pin_ = scl_pin; }
void set_frequency(uint32_t frequency) { frequency_ = frequency; }
void set_timeout(uint32_t timeout) { timeout_ = timeout; }
void set_scan(bool scan) { this->scan_ = scan; }
void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; }
void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; }
void set_timeout(uint32_t timeout) { this->timeout_ = timeout; }
int get_port() const override { return 0; }
void set_frequency(uint32_t frequency) { this->frequency_ = frequency; }
#ifdef I2C_PORT_FREQUENCY_COUNT
ErrorCode switch_frequency(uint32_t frequency) override;
uint32_t get_frequency() const override { return this->frequency_; }
#endif
private:
void recover_();
+72 -24
View File
@@ -32,8 +32,8 @@ void IDFI2CBus::setup() {
i2c_master_bus_config_t bus_conf{};
memset(&bus_conf, 0, sizeof(bus_conf));
bus_conf.sda_io_num = gpio_num_t(sda_pin_);
bus_conf.scl_io_num = gpio_num_t(scl_pin_);
bus_conf.sda_io_num = gpio_num_t(this->sda_pin_);
bus_conf.scl_io_num = gpio_num_t(this->scl_pin_);
bus_conf.glitch_ignore_cnt = 7;
#if SOC_LP_I2C_SUPPORTED
if (this->lp_mode_) {
@@ -59,7 +59,7 @@ void IDFI2CBus::setup() {
}
#endif
bus_conf.i2c_port = this->port_;
bus_conf.flags.enable_internal_pullup = sda_pullup_enabled_ || scl_pullup_enabled_;
bus_conf.flags.enable_internal_pullup = this->sda_pullup_enabled_ || this->scl_pullup_enabled_;
esp_err_t err = i2c_new_master_bus(&bus_conf, &this->bus_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "i2c_new_master_bus failed: %s", esp_err_to_name(err));
@@ -67,18 +67,21 @@ void IDFI2CBus::setup() {
return;
}
i2c_device_config_t dev_conf{};
memset(&dev_conf, 0, sizeof(dev_conf));
dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7;
dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED;
dev_conf.scl_speed_hz = this->frequency_;
dev_conf.scl_wait_us = this->timeout_;
err = i2c_master_bus_add_device(this->bus_, &dev_conf, &this->dev_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err));
this->dev_ = this->add_device_(this->frequency_);
if (this->dev_ == nullptr) {
this->mark_failed();
return;
}
#ifdef I2C_PORT_FREQUENCY_COUNT
this->add_frequency(this->frequency_);
for (auto &device : this->devices_) {
device.dev = device.frequency == this->frequency_ ? this->dev_ : this->add_device_(device.frequency);
if (device.dev == nullptr) {
this->mark_failed();
return;
}
}
#endif
this->initialized_ = true;
@@ -88,6 +91,21 @@ void IDFI2CBus::setup() {
}
}
i2c_master_dev_handle_t IDFI2CBus::add_device_(uint32_t frequency) {
i2c_device_config_t dev_conf{};
dev_conf.dev_addr_length = I2C_ADDR_BIT_LEN_7;
dev_conf.device_address = I2C_DEVICE_ADDRESS_NOT_USED;
dev_conf.scl_speed_hz = frequency;
dev_conf.scl_wait_us = this->timeout_;
i2c_master_dev_handle_t dev{};
const esp_err_t err = i2c_master_bus_add_device(this->bus_, &dev_conf, &dev);
if (err != ESP_OK) {
ESP_LOGW(TAG, "i2c_master_bus_add_device failed: %s", esp_err_to_name(err));
return nullptr;
}
return dev;
}
void IDFI2CBus::dump_config() {
ESP_LOGCONFIG(TAG, "I2C Bus:");
ESP_LOGCONFIG(TAG,
@@ -95,7 +113,7 @@ void IDFI2CBus::dump_config() {
" SCL Pin: GPIO%u\n"
" Frequency: %" PRIu32 " Hz",
this->sda_pin_, this->scl_pin_, this->frequency_);
if (timeout_ > 0) {
if (this->timeout_ > 0) {
ESP_LOGCONFIG(TAG, " Timeout: %" PRIu32 "us", this->timeout_);
}
switch (this->recovery_result_) {
@@ -111,10 +129,10 @@ void IDFI2CBus::dump_config() {
}
if (this->scan_) {
ESP_LOGCONFIG(TAG, "Results from bus scan:");
if (scan_results_.empty()) {
if (this->scan_results_.empty()) {
ESP_LOGCONFIG(TAG, "Found no devices");
} else {
for (const auto &s : scan_results_) {
for (const auto &s : this->scan_results_) {
if (s.second) {
ESP_LOGCONFIG(TAG, "Found device at address 0x%02X", s.first);
} else {
@@ -129,7 +147,7 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s
size_t read_count) {
// logging is only enabled with v level, if warnings are shown the caller
// should log them
if (!initialized_) {
if (!this->initialized_) {
ESP_LOGW(TAG, "i2c bus not initialized!");
return ERROR_NOT_INITIALIZED;
}
@@ -198,14 +216,44 @@ ErrorCode IDFI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, s
return ERROR_OK;
}
#ifdef I2C_PORT_FREQUENCY_COUNT
void IDFI2CBus::add_frequency(uint32_t frequency) {
for (const auto &device : this->devices_) {
if (device.frequency == frequency) {
return;
}
}
this->devices_.push_back({frequency, nullptr});
}
ErrorCode IDFI2CBus::switch_frequency(uint32_t frequency) {
if (this->frequency_ == frequency) {
return ERROR_OK;
}
if (!this->initialized_) {
this->frequency_ = frequency;
return ERROR_OK;
}
// Every handle exists since setup(); a frequency nobody registered has none
for (const auto &device : this->devices_) {
if (device.frequency == frequency) {
this->dev_ = device.dev;
this->frequency_ = frequency;
return ERROR_OK;
}
}
return ERROR_UNKNOWN;
}
#endif
/// Perform I2C bus recovery, see:
/// https://www.nxp.com/docs/en/user-guide/UM10204.pdf
/// https://www.analog.com/media/en/technical-documentation/application-notes/54305147357414AN686_0.pdf
void IDFI2CBus::recover_() {
ESP_LOGI(TAG, "Performing bus recovery");
const auto scl_pin = static_cast<gpio_num_t>(scl_pin_);
const auto sda_pin = static_cast<gpio_num_t>(sda_pin_);
const auto scl_pin = static_cast<gpio_num_t>(this->scl_pin_);
const auto sda_pin = static_cast<gpio_num_t>(this->sda_pin_);
// For the upcoming operations, target for a 60kHz toggle frequency.
// 1000kHz is the maximum frequency for I2C running in standard-mode,
@@ -218,7 +266,7 @@ void IDFI2CBus::recover_() {
// Configure SCL pin for open drain input/output, with a pull up resistor.
gpio_set_level(scl_pin, 1);
gpio_config_t scl_config{};
scl_config.pin_bit_mask = 1ULL << scl_pin_;
scl_config.pin_bit_mask = 1ULL << this->scl_pin_;
scl_config.mode = GPIO_MODE_INPUT_OUTPUT_OD;
scl_config.pull_up_en = GPIO_PULLUP_ENABLE;
scl_config.pull_down_en = GPIO_PULLDOWN_DISABLE;
@@ -228,7 +276,7 @@ void IDFI2CBus::recover_() {
// Configure SDA pin for open drain input/output, with a pull up resistor.
gpio_set_level(sda_pin, 1);
gpio_config_t sda_conf{};
sda_conf.pin_bit_mask = 1ULL << sda_pin_;
sda_conf.pin_bit_mask = 1ULL << this->sda_pin_;
sda_conf.mode = GPIO_MODE_INPUT_OUTPUT_OD;
sda_conf.pull_up_en = GPIO_PULLUP_ENABLE;
sda_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
@@ -240,7 +288,7 @@ void IDFI2CBus::recover_() {
delayMicroseconds(half_period_usec);
if (gpio_get_level(scl_pin) == 0) {
ESP_LOGE(TAG, "Recovery failed: SCL is held LOW on the bus");
recovery_result_ = RECOVERY_FAILED_SCL_LOW;
this->recovery_result_ = RECOVERY_FAILED_SCL_LOW;
return;
}
@@ -271,7 +319,7 @@ void IDFI2CBus::recover_() {
}
if (gpio_get_level(scl_pin) == 0) {
ESP_LOGE(TAG, "Recovery failed: SCL is held LOW during clock pulse cycle");
recovery_result_ = RECOVERY_FAILED_SCL_LOW;
this->recovery_result_ = RECOVERY_FAILED_SCL_LOW;
return;
}
}
@@ -281,7 +329,7 @@ void IDFI2CBus::recover_() {
// in SDA being pulled up.
if (gpio_get_level(sda_pin) == 0) {
ESP_LOGE(TAG, "Recovery failed: SDA is held LOW after clock pulse cycle");
recovery_result_ = RECOVERY_FAILED_SDA_LOW;
this->recovery_result_ = RECOVERY_FAILED_SDA_LOW;
return;
}
@@ -308,7 +356,7 @@ void IDFI2CBus::recover_() {
delayMicroseconds(half_period_usec);
gpio_set_level(sda_pin, 1);
recovery_result_ = RECOVERY_COMPLETED;
this->recovery_result_ = RECOVERY_COMPLETED;
}
} // namespace esphome::i2c
+15
View File
@@ -34,13 +34,28 @@ class IDFI2CBus final : public InternalI2CBus, public Component {
#endif
int get_port() const override { return this->port_; }
#ifdef I2C_PORT_FREQUENCY_COUNT
/// Register a frequency used on the bus so setup() creates its device handle
void add_frequency(uint32_t frequency);
ErrorCode switch_frequency(uint32_t frequency) override;
uint32_t get_frequency() const override { return this->frequency_; }
#endif
private:
void recover_();
i2c_master_dev_handle_t add_device_(uint32_t frequency);
RecoveryCode recovery_result_{};
protected:
i2c_master_dev_handle_t dev_{};
#ifdef I2C_PORT_FREQUENCY_COUNT
// One handle per frequency used on the bus, registered at code generation
struct FrequencyDevice {
uint32_t frequency;
i2c_master_dev_handle_t dev;
};
StaticVector<FrequencyDevice, I2C_PORT_FREQUENCY_COUNT + 1> devices_;
#endif
i2c_master_bus_handle_t bus_{};
i2c_port_t port_{};
uint8_t sda_pin_{};
+5
View File
@@ -21,7 +21,12 @@ class HostI2CBus final : public I2CBus, public Component {
void set_device(const std::string &device) { this->device_ = device; }
void set_scan(bool scan) { this->scan_ = scan; }
void set_frequency(uint32_t frequency) { this->frequency_ = frequency; }
#ifdef I2C_PORT_FREQUENCY_COUNT
// The kernel driver owns the clock, so switch_frequency keeps the base default
uint32_t get_frequency() const override { return this->frequency_; }
#endif
const std::string &get_device() const { return this->device_; }
+26 -7
View File
@@ -120,15 +120,34 @@ ErrorCode ZephyrI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer
return ERROR_OK;
}
void ZephyrI2CBus::set_frequency(uint32_t frequency) {
this->dev_config_ &= ~I2C_SPEED_MASK;
if (frequency >= 400000) {
this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_FAST);
} else {
this->dev_config_ |= I2C_SPEED_SET(I2C_SPEED_STANDARD);
}
static uint32_t speed_config(uint32_t dev_config, uint32_t frequency) {
const uint32_t speed = frequency >= 400000 ? I2C_SPEED_FAST : I2C_SPEED_STANDARD;
return (dev_config & ~I2C_SPEED_MASK) | I2C_SPEED_SET(speed);
}
void ZephyrI2CBus::set_frequency(uint32_t frequency) {
#ifdef I2C_PORT_FREQUENCY_COUNT
this->frequency_ = frequency;
#endif
this->dev_config_ = speed_config(this->dev_config_, frequency);
}
#ifdef I2C_PORT_FREQUENCY_COUNT
ErrorCode ZephyrI2CBus::switch_frequency(uint32_t frequency) {
if (this->frequency_ == frequency) {
return ERROR_OK;
}
const uint32_t dev_config = speed_config(this->dev_config_, frequency);
// Until ready, setup() applies dev_config_
if (this->is_ready() && i2c_configure(this->i2c_dev_, dev_config) < 0) {
return ERROR_UNKNOWN;
}
this->dev_config_ = dev_config;
this->frequency_ = frequency;
return ERROR_OK;
}
#endif
} // namespace esphome::i2c
#endif
+7
View File
@@ -22,6 +22,10 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component {
void set_sda_pin(uint8_t sda_pin) { this->sda_pin_ = sda_pin; }
void set_scl_pin(uint8_t scl_pin) { this->scl_pin_ = scl_pin; }
void set_frequency(uint32_t frequency);
#ifdef I2C_PORT_FREQUENCY_COUNT
ErrorCode switch_frequency(uint32_t frequency) override;
uint32_t get_frequency() const override { return this->frequency_; }
#endif
int get_port() const override { return 0; }
@@ -31,6 +35,9 @@ class ZephyrI2CBus final : public InternalI2CBus, public Component {
uint8_t sda_pin_{};
uint8_t scl_pin_{};
uint32_t dev_config_{};
#ifdef I2C_PORT_FREQUENCY_COUNT
uint32_t frequency_{};
#endif
};
} // namespace esphome::i2c
+1 -4
View File
@@ -19,10 +19,7 @@ class ESPColorSettable {
virtual void lighten(uint8_t delta) = 0;
virtual void darken(uint8_t delta) = 0;
void set(const ESPHSVColor &color) { this->set_hsv(color); }
void set_hsv(const ESPHSVColor &color) {
Color rgb = color.to_rgb();
this->set_rgb(rgb.r, rgb.g, rgb.b);
}
void set_hsv(const ESPHSVColor &color) { this->set(color.to_rgb()); }
void set_rgb(uint8_t red, uint8_t green, uint8_t blue) {
this->set_red(red);
this->set_green(green);
+1 -1
View File
@@ -2,6 +2,7 @@ from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import sensor, uart
from esphome.components.const import CONF_AUTOMATIC_BASELINE_CALIBRATION
import esphome.config_validation as cv
from esphome.const import (
CONF_CO2,
@@ -19,7 +20,6 @@ from esphome.types import ConfigType
DEPENDENCIES = ["uart"]
CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration"
CONF_DETECTION_RANGE = "detection_range"
mhz19_ns = cg.esphome_ns.namespace("mhz19")
+4 -1
View File
@@ -1,3 +1,4 @@
from esphome.components.mipi import MODE_RGB
from esphome.const import CONF_IGNORE_STRAPPING_WARNING, CONF_NUMBER
from .ili import GC9A01A, ILI9341, ILI9342, ST7789V
@@ -21,8 +22,10 @@ ST7789V.extend(
# fmt: off
ILI9342.extend(
# ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller
# ESP32-2432S028 CYD board with USB C + Micro USB, has ILI9342 controller.
# The panel on this board is wired RGB, not the BGR default.
"ESP32-2432S028-9342",
color_order=MODE_RGB,
data_rate="40MHz",
cs_pin={"number": 15, "ignore_strapping_warning": True},
dc_pin={"number": 2, "ignore_strapping_warning": True},
+7
View File
@@ -20,6 +20,7 @@ SETFANMODE = {
CONF_SUPPORTS_DRY = "supports_dry"
CONF_SUPPORTS_FAN_ONLY = "supports_fan_only"
CONF_FAHRENHEIT_COMPATIBILITY = "fahrenheit_compatibility"
CONF_HORIZONTAL_DEFAULT = "horizontal_default"
HorizontalDirections = mitsubishi_ns.enum("HorizontalDirections")
@@ -43,18 +44,22 @@ VERTICAL_DIRECTIONS = {
"down": VerticalDirections.VERTICAL_DIRECTION_DOWN,
}
CONF_VERTICAL_VANES = "vertical_vanes"
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(MitsubishiClimate).extend(
{
cv.Optional(CONF_SET_FAN_MODE, default="3levels"): cv.enum(SETFANMODE),
cv.Optional(CONF_SUPPORTS_DRY, default=False): cv.boolean,
cv.Optional(CONF_SUPPORTS_FAN_ONLY, default=False): cv.boolean,
cv.Optional(CONF_FAHRENHEIT_COMPATIBILITY, default=False): cv.boolean,
cv.Optional(CONF_HORIZONTAL_DEFAULT, default="middle"): cv.enum(
HORIZONTAL_DIRECTIONS
),
cv.Optional(CONF_VERTICAL_DEFAULT, default="middle"): cv.enum(
VERTICAL_DIRECTIONS
),
cv.Optional(CONF_VERTICAL_VANES, default=1): cv.int_range(1, 2),
}
)
@@ -65,5 +70,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_fan_mode(config[CONF_SET_FAN_MODE]))
cg.add(var.set_supports_dry(config[CONF_SUPPORTS_DRY]))
cg.add(var.set_supports_fan_only(config[CONF_SUPPORTS_FAN_ONLY]))
cg.add(var.set_fahrenheit_compatibility(config[CONF_FAHRENHEIT_COMPATIBILITY]))
cg.add(var.set_horizontal_default(config[CONF_HORIZONTAL_DEFAULT]))
cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT]))
cg.add(var.set_vertical_vanes(config[CONF_VERTICAL_VANES]))
+44 -7
View File
@@ -1,4 +1,5 @@
#include "mitsubishi.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
@@ -26,6 +27,7 @@ const uint8_t MITSUBISHI_WIDE_VANE_SWING = 0xC0;
const uint8_t MITSUBISHI_FAN_AUTO = 0x00;
const uint8_t MITSUBISHI_VERTICAL_VANE_MASK = 0x38; // Bits 3,4,5
const uint8_t MITSUBISHI_VERTICAL_VANE_SWING = 0x38;
// const uint8_t MITSUBISHI_AUTO = 0x80;
@@ -51,7 +53,33 @@ const uint8_t MITSUBISHI_BYTE02 = 0x26;
const uint8_t MITSUBISHI_BYTE03 = 0x01;
const uint8_t MITSUBISHI_BYTE04 = 0x00;
const uint8_t MITSUBISHI_BYTE13 = 0x00;
const uint8_t MITSUBISHI_BYTE16 = 0x00;
// Byte 7 codes for 61°F through 88°F. Bit 4 is the half degree bit Mitsubishi uses so every Fahrenheit degree
// maps to its own code.
static const uint8_t FAHRENHEIT_CODES[] PROGMEM = {0x00, 0x10, 0x01, 0x11, 0x02, 0x12, 0x03, 0x04, 0x05, 0x15,
0x06, 0x16, 0x07, 0x17, 0x08, 0x18, 0x09, 0x19, 0x0a, 0x1a,
0x0b, 0x1b, 0x0c, 0x1c, 0x0d, 0x1d, 0x0e, 0x0f};
static constexpr uint8_t FAHRENHEIT_MIN = 61;
static constexpr uint8_t MITSUBISHI_HALF_DEGREE = 0x10;
static uint8_t fahrenheit_code(float celsius) {
int fahrenheit = (int) roundf(celsius_to_fahrenheit(celsius));
size_t index = clamp<int>(fahrenheit - FAHRENHEIT_MIN, 0, sizeof(FAHRENHEIT_CODES) - 1);
return progmem_read_byte(&FAHRENHEIT_CODES[index]);
}
// In Fahrenheit mode a code from the table decodes to the exact Fahrenheit value. Any other code, and every code in
// Celsius mode, is a whole degree plus the half degree bit, which a remote set to Fahrenheit also sets.
static float decode_temperature(uint8_t code, bool fahrenheit) {
if (fahrenheit) {
for (size_t i = 0; i < sizeof(FAHRENHEIT_CODES); i++) {
if (code == progmem_read_byte(&FAHRENHEIT_CODES[i])) {
return fahrenheit_to_celsius(FAHRENHEIT_MIN + i);
}
}
}
return MITSUBISHI_TEMP_MIN + (code & 0x0F) + ((code & MITSUBISHI_HALF_DEGREE) ? 0.5f : 0.0f);
}
void MitsubishiClimate::transmit_state() {
// Byte 0-4: Constant: 0x23, 0xCB, 0x26, 0x01, 0x00
@@ -72,7 +100,7 @@ void MitsubishiClimate::transmit_state() {
// Byte 13: Constant 0x00
// Byte 14: HVAC specfic, i.e. ECONO COOL, CLEAN MODE, always 0x00
// Byte 15: HVAC specfic, i.e. POWERFUL, SMART SET, PLASMA, always 0x00
// Byte 16: Constant 0x00
// Byte 16: Left vane control (for specific models). Constants match right vane controls.
// Byte 17: Checksum: SUM[Byte0...Byte16]
uint8_t remote_state[18] = {0x23, 0xCB, 0x26, 0x01, 0x00, 0x20, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
@@ -113,6 +141,8 @@ void MitsubishiClimate::transmit_state() {
// Temperature
if (this->mode == climate::CLIMATE_MODE_DRY) {
remote_state[7] = 24 - MITSUBISHI_TEMP_MIN; // Remote sends always 24°C if "Dry" mode is selected
} else if (this->fahrenheit_compatibility_) {
remote_state[7] = fahrenheit_code(this->target_temperature);
} else {
remote_state[7] = (uint8_t) roundf(
clamp<float>(this->target_temperature, MITSUBISHI_TEMP_MIN, MITSUBISHI_TEMP_MAX) - MITSUBISHI_TEMP_MIN);
@@ -184,7 +214,7 @@ void MitsubishiClimate::transmit_state() {
break;
}
ESP_LOGD(TAG, "default_vertical_direction_: %02X", this->default_vertical_direction_);
ESP_LOGD(TAG, "Vertical default: 0x%02X, vanes: %u", this->default_vertical_direction_, this->vertical_vanes_);
// Special modes
switch (this->preset.value_or(climate::CLIMATE_PRESET_NONE)) {
@@ -206,6 +236,11 @@ void MitsubishiClimate::transmit_state() {
break;
}
if (this->vertical_vanes_ > 1) {
// Heads with two vertical vanes carry the left vane in byte 16; it follows the right one, presets included
remote_state[16] = remote_state[9] & MITSUBISHI_VERTICAL_VANE_MASK;
}
// Checksum
for (int i = 0; i < 17; i++) {
remote_state[17] += remote_state[i];
@@ -266,11 +301,11 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) {
}
state_frame[pos] = byte;
// Check Header && Footer
// Check Header && Footer. Byte 16 only ever carries the left vane position (bits 3,4,5).
if ((pos == 0 && byte != MITSUBISHI_BYTE00) || (pos == 1 && byte != MITSUBISHI_BYTE01) ||
(pos == 2 && byte != MITSUBISHI_BYTE02) || (pos == 3 && byte != MITSUBISHI_BYTE03) ||
(pos == 4 && byte != MITSUBISHI_BYTE04) || (pos == 13 && byte != MITSUBISHI_BYTE13) ||
(pos == 16 && byte != MITSUBISHI_BYTE16)) {
(pos == 16 && (byte & ~MITSUBISHI_VERTICAL_VANE_MASK) != 0)) {
ESP_LOGV(TAG, "Bytes 0,1,2,3,4,13 or 16 fail - invalid value");
return false;
}
@@ -300,7 +335,7 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) {
}
// Temp
this->target_temperature = state_frame[7] + MITSUBISHI_TEMP_MIN;
this->target_temperature = decode_temperature(state_frame[7], this->fahrenheit_compatibility_);
// Fan
uint8_t fan = state_frame[9] & 0x07; //(Bit 0,1,2 = Speed)
@@ -332,7 +367,9 @@ bool MitsubishiClimate::on_receive(remote_base::RemoteReceiveData data) {
}
// Vertical Vane
uint8_t vertical_vane = state_frame[9] & 0x38; // Bits 3,4,5
// On dual vane heads, the left vane is in [16] and right vane is in [9]. Left is ignored here because
// the swing_mode enum doesn't convey that level of detail.
uint8_t vertical_vane = state_frame[9] & MITSUBISHI_VERTICAL_VANE_MASK;
switch (vertical_vane) {
case MITSUBISHI_VERTICAL_VANE_SWING:
if (this->swing_mode == climate::CLIMATE_SWING_HORIZONTAL) {
@@ -1,6 +1,7 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/core/helpers.h"
#include <cinttypes>
@@ -9,6 +10,8 @@ namespace esphome::mitsubishi {
// Temperature
const uint8_t MITSUBISHI_TEMP_MIN = 16; // Celsius
const uint8_t MITSUBISHI_TEMP_MAX = 31; // Celsius
// Remotes set to Fahrenheit go up to 88°F, one step past the Celsius range
static constexpr float MITSUBISHI_TEMP_MAX_FAHRENHEIT = fahrenheit_to_celsius(88.0f);
// Fan mode
enum SetFanMode : uint8_t {
@@ -55,6 +58,12 @@ class MitsubishiClimate final : public climate_ir::ClimateIR {
void set_supports_fan_only(bool supports_fan_only) {
this->set_mode_supported_(climate::CLIMATE_MODE_FAN_ONLY, supports_fan_only);
}
void set_fahrenheit_compatibility(bool fahrenheit_compatibility) {
this->fahrenheit_compatibility_ = fahrenheit_compatibility;
if (fahrenheit_compatibility) {
this->maximum_temperature_ = MITSUBISHI_TEMP_MAX_FAHRENHEIT;
}
}
void set_fan_mode(SetFanMode fan_mode) {
this->fan_mode_ = fan_mode;
@@ -71,6 +80,7 @@ class MitsubishiClimate final : public climate_ir::ClimateIR {
void set_vertical_default(VerticalDirection vertical_direction) {
this->default_vertical_direction_ = vertical_direction;
}
void set_vertical_vanes(uint8_t vertical_vanes) { this->vertical_vanes_ = vertical_vanes; }
protected:
// Transmit via IR the state of this climate controller.
@@ -80,9 +90,11 @@ class MitsubishiClimate final : public climate_ir::ClimateIR {
bool parse_state_frame_(const uint8_t frame[]);
SetFanMode fan_mode_;
bool fahrenheit_compatibility_{false};
HorizontalDirection default_horizontal_direction_;
VerticalDirection default_vertical_direction_;
uint8_t vertical_vanes_{1};
};
} // namespace esphome::mitsubishi
+5 -5
View File
@@ -732,13 +732,13 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
return True # Handled: PYOCD upload
# Deferred imports: bleak/smpclient are heavy, only load for BLE/mcumgr paths
from .ble_logger import is_mac_address
from .ble_logger import is_ble_address
from .ota import smpmgr_scan, smpmgr_upload
if host == "BLE":
mcumgr_device = asyncio.run(smpmgr_scan(CORE.name))
if is_mac_address(host):
if is_ble_address(host):
mcumgr_device = host
if mcumgr_device:
@@ -753,7 +753,7 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
address = devices[0]
from .ble_logger import is_mac_address, logger_connect, logger_scan
from .ble_logger import is_ble_address, logger_connect, logger_scan
if devices[0] == "BLE":
ble_device = asyncio.run(logger_scan(CORE.name))
@@ -762,7 +762,7 @@ def show_logs(config: ConfigType, args, devices: list[str]) -> bool:
else:
return True
if is_mac_address(address):
if is_ble_address(address):
asyncio.run(logger_connect(address))
return True
return False
@@ -835,7 +835,7 @@ _PCH_CMAKE_LINES = [
"set(esphome_kept_options)",
"set(esphome_pch_headers)",
"foreach(option IN LISTS esphome_options)",
' if(option MATCHES "imacros> (.+)$")',
' if(option MATCHES "imacros> ([^>]+)")',
' list(APPEND esphome_pch_headers "${CMAKE_MATCH_1}")',
" list(APPEND esphome_kept_options",
' "$<$<NOT:$<AND:$<COMPILE_LANGUAGE:CXX>,$<STREQUAL:$<TARGET_PROPERTY:NAME>,app>>>:${option}>")',
+20 -3
View File
@@ -4,6 +4,7 @@ import re
from typing import Final
from bleak import BleakClient, BleakScanner, BLEDevice
from bleak.backends.scanner import AdvertisementData
from bleak.exc import (
BleakCharacteristicNotFoundError,
BleakDBusError,
@@ -19,15 +20,31 @@ NUS_TX_CHAR_UUID = "6E400003-B5A3-F393-E0A9-E50E24DCCA9E"
MAC_ADDRESS_PATTERN: Final = re.compile(
r"([0-9A-F]{2}[:]){5}[0-9A-F]{2}$", flags=re.IGNORECASE
)
# macOS identifies BLE peripherals by a CoreBluetooth UUID instead of a MAC address
UUID_PATTERN: Final = re.compile(
r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$",
flags=re.IGNORECASE,
)
def is_mac_address(value: str) -> bool:
return MAC_ADDRESS_PATTERN.match(value)
def is_ble_address(value: str) -> bool:
return bool(MAC_ADDRESS_PATTERN.match(value) or UUID_PATTERN.match(value))
def _name_matches(device: BLEDevice, adv: AdvertisementData, name: str) -> bool:
# device.name can be a stale cached name on macOS; adv.local_name is what is on the air now
return name in (device.name, adv.local_name)
async def find_device_by_name(name: str, timeout: float = 10.0) -> BLEDevice | None:
return await BleakScanner.find_device_by_filter(
lambda device, adv: _name_matches(device, adv, name), timeout=timeout
)
async def logger_scan(name: str) -> BLEDevice | None:
_LOGGER.info("Scanning bluetooth for %s...", name)
device = await BleakScanner.find_device_by_name(name)
device = await find_device_by_name(name)
if not device:
_LOGGER.error("%s Bluetooth LE device was not found!", name)
return device
+10 -11
View File
@@ -4,7 +4,6 @@ import json
import logging
from pathlib import Path
from bleak import BleakScanner
from bleak.exc import BleakDBusError, BleakDeviceNotFoundError
from smp.exceptions import SMPBadStartDelimiter
from smpclient import SMPClient
@@ -23,9 +22,8 @@ from smpclient.transport.serial import SMPSerialTransport
from esphome.core import EsphomeError
from esphome.espota2 import ProgressBar
from .ble_logger import is_mac_address
from .ble_logger import find_device_by_name, is_ble_address
SMP_SERVICE_UUID = "8D53DC1D-1DB7-4CD3-868B-8A527460AA84"
BLE_SCAN_TIMEOUT = 10.0 # seconds
RESET_DELAY = 2.0 # seconds to wait before reset, allows on_end action to execute
@@ -45,12 +43,12 @@ def _json_state(o: object) -> object:
async def smpmgr_scan(name: str) -> str:
_LOGGER.info("Scanning bluetooth for %s...", name)
for device in await BleakScanner.discover(
timeout=BLE_SCAN_TIMEOUT, service_uuids=[SMP_SERVICE_UUID]
):
if device.name == name:
return device.address
raise EsphomeError(f"BLE device {name} with OTA service not found")
# No service filter: the SMP UUID only fits in the scan response, which macOS does not filter on.
# The SMP service is checked on connect.
device = await find_device_by_name(name, timeout=BLE_SCAN_TIMEOUT)
if device is None:
raise EsphomeError(f"BLE device {name} not found")
return device.address
async def smpmgr_upload(device: str, firmware: Path) -> None:
@@ -88,7 +86,7 @@ def _get_image_tlv_sha256(file: Path) -> bytes:
async def _smpmgr_upload(device: str, firmware: Path) -> None:
image_tlv_sha256 = _get_image_tlv_sha256(firmware)
if is_mac_address(device):
if is_ble_address(device):
smp_client = SMPClient(SMPBLETransport(), device)
else:
smp_client = SMPClient(SMPSerialTransport(), device)
@@ -105,7 +103,8 @@ async def _smpmgr_upload(device: str, firmware: Path) -> None:
) from exc
raise EsphomeError(f"BLE error connecting to {device}: {exc}") from exc
except SMPBLETransportException as exc:
raise EsphomeError(f"Connection error with {device}") from exc
# Reached when a device with the right name has no SMP service, among other causes
raise EsphomeError(f"Connection error with {device}: {exc}") from exc
_LOGGER.info("Connected %s...", device)
try:
+52 -1
View File
@@ -1,7 +1,14 @@
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_CHANNELS, CONF_ID
from esphome.const import (
CONF_CHANNEL,
CONF_CHANNELS,
CONF_FREQUENCY,
CONF_I2C_ID,
CONF_ID,
)
from esphome.core import CORE, ID
from esphome.types import ConfigType
CODEOWNERS = ["@andreashergert1984"]
@@ -16,14 +23,51 @@ TCA9548AChannel = tca9548a_ns.class_("TCA9548AChannel", i2c.I2CBus)
MULTI_CONF = True
CONF_BUS_ID = "bus_id"
# Sizes the root bus's frequency table; only emitted when a frequency is set
_request_port_frequency_slot = cg.slot_counter("I2C_PORT_FREQUENCY_COUNT")
def _root_bus_id(bus_id: ID) -> ID:
"""The hardware bus behind a port, through any multiplexers in between."""
parents = {
str(chan[CONF_BUS_ID]): mux[CONF_I2C_ID]
for mux in CORE.config.get(DOMAIN, [])
for chan in mux.get(CONF_CHANNELS, [])
}
while (parent := parents.get(str(bus_id))) is not None:
bus_id = parent
return bus_id
def _declared_bus_type(bus_id: ID) -> cg.MockObjClass | None:
"""The class the i2c component declared for a bus, or None for an external bus."""
for bus in CORE.config.get(i2c.DOMAIN, []):
if bus[CONF_ID].id == bus_id.id:
return bus[CONF_ID].type
return None
async def _add_frequency(root_bus_id: ID, frequency: int) -> None:
"""Count the frequency against the root bus; the built-in ESP32 bus also
creates its device handle in setup()."""
_request_port_frequency_slot(str(root_bus_id))
if _declared_bus_type(root_bus_id) is i2c.IDFI2CBus:
root = await cg.get_variable(root_bus_id)
cg.add(root.add_frequency(frequency))
_FREQUENCY = cv.All(cv.frequency, cv.Range(min=0, min_included=False))
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(TCA9548AComponent),
cv.Optional(CONF_FREQUENCY): _FREQUENCY,
cv.Optional(CONF_CHANNELS, default=[]): cv.ensure_list(
{
cv.Required(CONF_BUS_ID): cv.declare_id(TCA9548AChannel),
cv.Required(CONF_CHANNEL): cv.int_range(min=0, max=7),
cv.Optional(CONF_FREQUENCY): _FREQUENCY,
}
),
}
@@ -37,8 +81,15 @@ async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
root_bus_id = _root_bus_id(config[CONF_I2C_ID])
if (frequency := config.get(CONF_FREQUENCY)) is not None:
await _add_frequency(root_bus_id, int(frequency))
cg.add(var.set_frequency(int(frequency)))
for conf in config[CONF_CHANNELS]:
chan = cg.new_Pvariable(conf[CONF_BUS_ID])
cg.add(chan.set_parent(var))
cg.add(chan.set_channel(conf[CONF_CHANNEL]))
if (frequency := conf.get(CONF_FREQUENCY)) is not None:
await _add_frequency(root_bus_id, int(frequency))
cg.add(chan.set_frequency(int(frequency)))
+97 -5
View File
@@ -1,4 +1,6 @@
#include "tca9548a.h"
#include <algorithm>
#include "esphome/core/log.h"
namespace esphome::tca9548a {
@@ -7,20 +9,111 @@ ESPHOME_LOG_TAG(TAG, "tca9548a");
i2c::ErrorCode TCA9548AChannel::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count,
uint8_t *read_buffer, size_t read_count) {
auto err = this->parent_->switch_to_channel(channel_);
#ifdef I2C_PORT_FREQUENCY_COUNT
const uint32_t select_frequency = this->select_frequency_();
if (select_frequency != 0) {
return this->write_readv_at_port_frequency_(select_frequency, address, write_buffer, write_count, read_buffer,
read_count);
}
#endif
auto err = this->parent_->switch_to_channel(this->channel_);
if (err != i2c::ERROR_OK)
return err;
err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count);
this->parent_->disable_all_channels();
return err;
}
#ifdef I2C_PORT_FREQUENCY_COUNT
// The multiplexer's own frequency, else the slower of the bus and the port
uint32_t TCA9548AChannel::select_frequency_() const {
if (this->parent_->frequency_ != 0)
return this->parent_->frequency_;
if (this->frequency_ == 0)
return 0;
// A bus that does not report its frequency gets the port's, and fails the switch
const uint32_t bus_frequency = this->parent_->bus_->get_frequency();
return bus_frequency != 0 ? std::min(this->frequency_, bus_frequency) : this->frequency_;
}
// The port frequency, else the multiplexer's
uint32_t TCA9548AChannel::transfer_frequency_() const {
return this->frequency_ != 0 ? this->frequency_ : this->parent_->frequency_;
}
i2c::ErrorCode TCA9548AChannel::write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address,
const uint8_t *write_buffer, size_t write_count,
uint8_t *read_buffer, size_t read_count) {
const uint32_t original_frequency = this->parent_->bus_->get_frequency();
// A failed switch leaves the bus as it was
auto err = this->switch_bus_(select_frequency);
if (err != i2c::ERROR_OK)
return err;
err = this->parent_->switch_to_channel(this->channel_);
if (err == i2c::ERROR_OK) {
err = this->transfer_(select_frequency, address, write_buffer, write_count, read_buffer, read_count);
this->parent_->disable_all_channels();
}
const i2c::ErrorCode restored = this->switch_bus_(original_frequency);
return err != i2c::ERROR_OK ? err : restored;
}
// Transfer at the port frequency, back to the select frequency for the deselect
i2c::ErrorCode TCA9548AChannel::transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer,
size_t write_count, uint8_t *read_buffer, size_t read_count) {
const uint32_t transfer_frequency = this->transfer_frequency_();
if (transfer_frequency == select_frequency) {
return this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count);
}
auto err = this->switch_bus_(transfer_frequency);
if (err == i2c::ERROR_OK) {
err = this->parent_->bus_->write_readv(address, write_buffer, write_count, read_buffer, read_count);
}
const i2c::ErrorCode restored = this->switch_bus_(select_frequency);
return err != i2c::ERROR_OK ? err : restored;
}
// Forward to the shared upstream bus
i2c::ErrorCode TCA9548AChannel::switch_frequency(uint32_t frequency) {
return this->parent_->bus_->switch_frequency(frequency);
}
i2c::ErrorCode TCA9548AChannel::switch_bus_(uint32_t frequency) {
const i2c::ErrorCode err = this->switch_frequency(frequency);
if (err != i2c::ERROR_OK) {
this->parent_->status_set_error(LOG_STR("Failed to switch the bus frequency"));
}
return err;
}
uint32_t TCA9548AChannel::get_frequency() const { return this->parent_->bus_->get_frequency(); }
#endif
void TCA9548AComponent::setup() {
uint8_t status = 0;
if (this->read(&status, 1) != i2c::ERROR_OK) {
#ifdef I2C_PORT_FREQUENCY_COUNT
const uint32_t original_frequency = this->bus_->get_frequency();
if (this->frequency_ != 0 && this->bus_->switch_frequency(this->frequency_) != i2c::ERROR_OK) {
this->mark_failed(LOG_STR("Failed to switch the bus frequency"));
return;
}
#endif
const i2c::ErrorCode err = this->read(&status, 1);
#ifdef I2C_PORT_FREQUENCY_COUNT
const bool restored = this->frequency_ == 0 || this->bus_->switch_frequency(original_frequency) == i2c::ERROR_OK;
#endif
if (err != i2c::ERROR_OK) {
ESP_LOGE(TAG, "TCA9548A failed");
this->mark_failed();
return;
}
#ifdef I2C_PORT_FREQUENCY_COUNT
if (!restored) {
this->mark_failed(LOG_STR("Failed to switch the bus frequency"));
return;
}
#endif
ESP_LOGD(TAG, "Channels currently open: %d", status);
}
void TCA9548AComponent::dump_config() {
@@ -32,14 +125,13 @@ i2c::ErrorCode TCA9548AComponent::switch_to_channel(uint8_t channel) {
if (this->is_failed())
return i2c::ERROR_NOT_INITIALIZED;
uint8_t channel_val = 1 << channel;
const uint8_t channel_val = 1 << channel;
return this->write(&channel_val, 1);
}
void TCA9548AComponent::disable_all_channels() {
if (this->write(&TCA9548A_DISABLE_CHANNELS_COMMAND, 1) != i2c::ERROR_OK) {
ESP_LOGE(TAG, "Failed to disable all channels.");
this->status_set_error(); // couldn't disable channels, set error status
this->status_set_error(LOG_STR("Failed to disable all channels."));
}
}
+26 -2
View File
@@ -10,13 +10,30 @@ static const uint8_t TCA9548A_DISABLE_CHANNELS_COMMAND = 0x00;
class TCA9548AComponent;
class TCA9548AChannel final : public i2c::I2CBus {
public:
void set_channel(uint8_t channel) { channel_ = channel; }
void set_parent(TCA9548AComponent *parent) { parent_ = parent; }
void set_channel(uint8_t channel) { this->channel_ = channel; }
void set_parent(TCA9548AComponent *parent) { this->parent_ = parent; }
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer,
size_t read_count) override;
#ifdef I2C_PORT_FREQUENCY_COUNT
void set_frequency(uint32_t frequency) { this->frequency_ = frequency; }
i2c::ErrorCode switch_frequency(uint32_t frequency) override;
uint32_t get_frequency() const override;
#endif
protected:
#ifdef I2C_PORT_FREQUENCY_COUNT
uint32_t select_frequency_() const;
uint32_t transfer_frequency_() const;
i2c::ErrorCode write_readv_at_port_frequency_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer,
size_t write_count, uint8_t *read_buffer, size_t read_count);
i2c::ErrorCode transfer_(uint32_t select_frequency, uint8_t address, const uint8_t *write_buffer, size_t write_count,
uint8_t *read_buffer, size_t read_count);
/// Switch the upstream bus, flagging the multiplexer on failure
i2c::ErrorCode switch_bus_(uint32_t frequency);
uint32_t frequency_{0};
#endif
uint8_t channel_;
TCA9548AComponent *parent_;
};
@@ -30,8 +47,15 @@ class TCA9548AComponent final : public Component, public i2c::I2CDevice {
i2c::ErrorCode switch_to_channel(uint8_t channel);
void disable_all_channels();
#ifdef I2C_PORT_FREQUENCY_COUNT
/// Addressing frequency, and the default for the ports
void set_frequency(uint32_t frequency) { this->frequency_ = frequency; }
#endif
protected:
friend class TCA9548AChannel;
#ifdef I2C_PORT_FREQUENCY_COUNT
uint32_t frequency_{0};
#endif
};
} // namespace esphome::tca9548a
+23 -5
View File
@@ -13,7 +13,14 @@ from esphome.components.esp32.const import (
VARIANT_ESP32S31,
)
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME, CONF_ON_START
from esphome.const import (
CONF_ID,
CONF_INTERNAL,
CONF_MODEL,
CONF_NAME,
CONF_ON_START,
CONF_POLLING_INTERVAL,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.types import ConfigType
@@ -25,6 +32,7 @@ from .const import (
CONF_POWER_SOURCE,
CONF_REPORT,
CONF_ROUTER,
CONF_SLEEPY,
CONF_USE_DEVICE_TYPE,
CONF_WIPE_ON_BOOT,
KEY_ZIGBEE,
@@ -35,7 +43,6 @@ from .const import (
from .const_zephyr import (
CONF_IEEE802154_VENDOR_OUI,
CONF_MAX_EP_NUMBER_ZEPHYR,
CONF_SLEEPY,
CONF_ZIGBEE_ID,
KEY_EP_NUMBER,
)
@@ -128,8 +135,11 @@ NUMBER_SCHEMA = (
def _validate_router_sleepy(config: ConfigType) -> ConfigType:
if config.get(CONF_ROUTER) and config.get(CONF_SLEEPY):
raise cv.Invalid("router and sleepy are mutually exclusive")
if config.get(CONF_ROUTER):
if config.get(CONF_SLEEPY):
raise cv.Invalid("router and sleepy are mutually exclusive")
if config.get(CONF_POLLING_INTERVAL):
raise cv.Invalid(f"{CONF_POLLING_INTERVAL} is only valid for end devices.")
return config
@@ -160,9 +170,17 @@ CONFIG_SCHEMA = cv.All(
),
cv.requires_component("nrf52"),
),
cv.OnlyWith(CONF_SLEEPY, "nrf52", default=False): cv.All(
cv.Optional(CONF_SLEEPY, default=False): cv.All(
cv.boolean,
),
cv.Optional(CONF_POLLING_INTERVAL): cv.All(
cv.requires_component(ESP32_DOMAIN),
cv.positive_time_period_milliseconds,
cv.Range(
min=cv.TimePeriodMilliseconds(milliseconds=200),
max=cv.TimePeriodMinutes(minutes=60), # 1 hour
),
),
}
).extend(cv.COMPONENT_SCHEMA),
_validate_router_sleepy,
+1
View File
@@ -72,6 +72,7 @@ CONF_WIPE_ON_BOOT = "wipe_on_boot"
CONF_REPORT = "report"
CONF_ROUTER = "router"
CONF_POWER_SOURCE = "power_source"
CONF_SLEEPY = "sleepy"
CONF_USE_DEVICE_TYPE = "use_device_type"
POWER_SOURCE = {
"UNKNOWN": 0x00, # ZB_ZCL_BASIC_POWER_SOURCE_UNKNOWN
@@ -4,7 +4,6 @@ CONF_ZIGBEE_BINARY_SENSOR = "zigbee_binary_sensor"
CONF_ZIGBEE_SENSOR = "zigbee_sensor"
CONF_ZIGBEE_SWITCH = "zigbee_switch"
CONF_ZIGBEE_NUMBER = "zigbee_number"
CONF_SLEEPY = "sleepy"
CONF_IEEE802154_VENDOR_OUI = "ieee802154_vendor_oui"
# Keys for CORE.data storage
+24 -13
View File
@@ -319,7 +319,7 @@ void ZigbeeComponent::setup() {
#else
esp_zigbee_zed_config_s zb_zed_cfg = {
.ed_timeout = EZB_NWK_ED_TIMEOUT_64MIN,
.keep_alive = ED_KEEP_ALIVE,
.keep_alive = this->keep_alive_,
};
device_config.zed_config = zb_zed_cfg;
#endif
@@ -339,6 +339,8 @@ void ZigbeeComponent::setup() {
#ifdef CONFIG_ZB_ZCZR
ezb_bdb_set_router_rejoin_required(true);
#else
ezb_nwk_set_rx_on_when_idle(!this->sleepy_); // if sleepy, disable RX when idle
#endif
ezb_aps_secur_enable_distributed_security(false);
@@ -419,18 +421,27 @@ void ZigbeeComponent::loop() {
void ZigbeeComponent::dump_config() {
if (esp_zigbee_lock_acquire(10 / portTICK_PERIOD_MS)) {
ESP_LOGCONFIG(TAG,
"Zigbee\n"
" Model: %.*s\n"
" Router: %s\n"
" Device is joined to the network: %s\n"
" Current channel: %d\n"
" Short addr: 0x%04X\n"
" Short pan id: 0x%04X",
this->basic_cluster_data_.model[0],
reinterpret_cast<const char *>(this->basic_cluster_data_.model + 1),
YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER), YESNO(ezb_bdb_dev_joined()),
ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid());
ESP_LOGCONFIG(
TAG,
"Zigbee\n"
" Model: %.*s\n"
#ifdef CONFIG_ZB_ZCZR
" Router: %s\n"
#else
" Sleepy end device: %s\n"
" Poll interval: %" PRIu32 " ms\n"
#endif
" Device is joined to the network: %s\n"
" Current channel: %d\n"
" Short addr: 0x%04X\n"
" Short pan id: 0x%04X",
this->basic_cluster_data_.model[0], reinterpret_cast<const char *>(this->basic_cluster_data_.model + 1),
#ifdef CONFIG_ZB_ZCZR
YESNO(this->device_role_ == EZB_NWK_DEVICE_TYPE_ROUTER),
#else
YESNO(this->sleepy_), this->keep_alive_,
#endif
YESNO(ezb_bdb_dev_joined()), ezb_nwk_get_current_channel(), ezb_nwk_get_short_address(), ezb_nwk_get_panid());
esp_zigbee_lock_release();
} else {
ESP_LOGCONFIG(TAG,
+4 -1
View File
@@ -20,7 +20,6 @@
namespace esphome::zigbee {
/* Zigbee configuration */
static const uint16_t ED_KEEP_ALIVE = 3000; /* 3000 millisecond */
static const uint8_t MAX_CHILDREN = 10;
static const uint32_t EZB_PRIMARY_CHANNEL_MASK = 0x07FFF800U; /* channels 11-26 */
static constexpr uint8_t MAX_ZB_QUEUE_SIZE = 32;
@@ -43,6 +42,8 @@ class ZigbeeComponent final : public Component {
void loop() override;
void dump_config() override;
void set_sleepy(bool sleepy) { this->sleepy_ = sleepy; }
void set_keep_alive(uint32_t keep_alive) { this->keep_alive_ = keep_alive; }
void set_basic_cluster(const char *model, const char *manufacturer, uint8_t power_source);
void add_cluster(uint8_t endpoint_id, uint16_t cluster_id, uint8_t role);
void create_default_cluster(uint8_t endpoint_id, uint16_t device_id);
@@ -73,6 +74,8 @@ class ZigbeeComponent final : public Component {
bool is_joined() { return this->joined_; }
protected:
bool sleepy_{false};
uint32_t keep_alive_{3000};
struct {
uint8_t *model;
uint8_t *manufacturer;
@@ -24,6 +24,7 @@ from esphome.const import (
CONF_MAX_LENGTH,
CONF_MODEL,
CONF_NAME,
CONF_POLLING_INTERVAL,
CONF_TYPE,
CONF_UNIT_OF_MEASUREMENT,
CONF_VALUE,
@@ -45,6 +46,7 @@ from .const import (
CONF_POWER_SOURCE,
CONF_REPORT,
CONF_ROUTER,
CONF_SLEEPY,
CONF_USE_DEVICE_TYPE,
KEY_ZIGBEE,
POWER_SOURCE,
@@ -412,6 +414,11 @@ async def esp32_to_code(config: ConfigType) -> "MockObj":
# setup zigbee components
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
if config.get(CONF_SLEEPY):
cg.add(var.set_sleepy(True))
if (keep_alive := config.get(CONF_POLLING_INTERVAL, 3000)) != 3000:
cg.add(var.set_keep_alive(keep_alive))
cg.add(
var.set_basic_cluster(
config[CONF_MODEL],
+1 -1
View File
@@ -24,6 +24,7 @@ from .const import (
BACNET_UNITS,
CONF_POWER_SOURCE,
CONF_ROUTER,
CONF_SLEEPY,
CONF_WIPE_ON_BOOT,
KEY_ZIGBEE,
POWER_SOURCE,
@@ -35,7 +36,6 @@ from .const import (
)
from .const_zephyr import (
CONF_IEEE802154_VENDOR_OUI,
CONF_SLEEPY,
CONF_ZIGBEE_BINARY_SENSOR,
CONF_ZIGBEE_ID,
CONF_ZIGBEE_NUMBER,
+1
View File
@@ -137,6 +137,7 @@
#define USE_LVGL_TOUCHSCREEN
#define USE_MDNS
#define USE_MDNS_STORE_SERVICES
#define I2C_PORT_FREQUENCY_COUNT 1
#define MDNS_SERVICE_COUNT 3
#define USE_MDNS_DYNAMIC_TXT
#define MDNS_DYNAMIC_TXT_COUNT 2
+50
View File
@@ -1,5 +1,6 @@
"""ESP-IDF direct build API for ESPHome."""
import contextlib
from dataclasses import dataclass, field
import fnmatch
import json
@@ -124,6 +125,8 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]:
env_cache[version] = os.environ.copy()
# Do not leak PYTHONPATH into child env
env_cache[version].pop("PYTHONPATH", None)
# Do not let an inherited IDF_TARGET override the variant
env_cache[version].pop("IDF_TARGET", None)
# Use provided IDF framework if available
if _esphome_manages_idf():
@@ -227,6 +230,8 @@ FILTER_IDF_LINES: list[str] = [
# click's boolean spellings, which idf.py applies to IDF_CCACHE_ENABLE.
_CLICK_TRUE = frozenset({"1", "true", "t", "yes", "y", "on"})
_CMAKECACHE_LINE = re.compile(r"^([^#/:=]+):([^:=]+)=(.*)$")
_SDKCONFIG_TARGET_LINE = re.compile(r'^CONFIG_IDF_TARGET="([^"]*)"$', re.MULTILINE)
_TOOLCHAIN_FILE_TARGET = re.compile(r"toolchain-(?:clang-)?(\w+)\.cmake$")
@dataclass(frozen=True, kw_only=True)
@@ -310,6 +315,50 @@ def tree_skips_bootloader(build_dir: Path) -> bool:
return cache.get(SKIP_BOOTLOADER_DEFINE) == "1"
def _foreign_idf_target(target: str) -> str | None:
"""Return another IDF target recorded in the CMake caches or sdkconfig."""
build_dir = _build_dir()
sdkconfig = CORE.relative_build_path(f"sdkconfig.{CORE.name}")
found = []
for cache_path in (
build_dir / "CMakeCache.txt",
build_dir / "bootloader" / "CMakeCache.txt",
):
with contextlib.suppress(OSError, ValueError):
cache = _parse_cmakecache(cache_path)
found.append(cache.get("IDF_TARGET"))
if m := _TOOLCHAIN_FILE_TARGET.search(
cache.get("CMAKE_TOOLCHAIN_FILE", "")
):
found.append(m.group(1))
with contextlib.suppress(OSError, ValueError):
if m := _SDKCONFIG_TARGET_LINE.search(sdkconfig.read_text(encoding="utf-8")):
found.append(m.group(1))
return next((t for t in found if t and t != target), None)
def _clean_foreign_target_tree() -> None:
"""Clean a build configured for another IDF target, like idf.py set-target."""
variant = CORE.data.get(KEY_ESP32, {}).get(KEY_VARIANT)
if variant is None:
return
target = variant_to_idf_target(variant)
if (old := _foreign_idf_target(target)) is None:
return
from esphome.writer import clean_build
_LOGGER.info(
"IDF target changed from %s to %s, cleaning build files...", old, target
)
clean_build(clear_pio_cache=False)
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
return
# Restart sdkconfig from ESPHome's options
internal = CORE.relative_build_path(f"sdkconfig.{CORE.name}.esphomeinternal")
if internal.is_file():
shutil.copyfile(internal, CORE.relative_build_path(f"sdkconfig.{CORE.name}"))
def _skip_bootloader() -> bool:
"""Whether this tree should not build a bootloader at all; per-run memo."""
cache = _cache()
@@ -804,6 +853,7 @@ def run_compile(config, verbose: bool) -> int:
3. Run full build
"""
jobs = _build_jobs(config)
_clean_foreign_target_tree()
if need_reconfigure():
if (rc := _configure_project(verbose)) != 0:
return rc
+4 -1
View File
@@ -21,6 +21,7 @@ from dataclasses import dataclass
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
@@ -83,7 +84,9 @@ def show_disk_space_if_ci(esphome_command: str) -> None:
print("=" * 80)
# Use sys.stdout.flush() to ensure output appears immediately
sys.stdout.flush()
subprocess.run(["df", "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr)
# Windows has no df
if df := shutil.which("df"):
subprocess.run([df, "-h"], check=False, stdout=sys.stdout, stderr=sys.stderr)
print("=" * 80 + "\n")
sys.stdout.flush()
+239
View File
@@ -111,6 +111,245 @@ def test_esp32_config(
assert VARIANT_FRIENDLY[variant].lower() in config["board"]
@pytest.mark.parametrize(
(
"sdkconfig_options",
"cpu_frequency",
"expected_frequency",
"warns",
"variant",
"framework_type",
),
[
pytest.param(
{
"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y",
"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160",
},
None,
"160MHZ",
True,
VARIANT_ESP32,
"esp-idf",
id="legacy-frequency-options-with-default",
),
pytest.param(
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"},
"160MHz",
"160MHZ",
False,
VARIANT_ESP32,
"esp-idf",
id="explicit-cpu-frequency",
),
pytest.param(
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ": "160"},
None,
"160MHZ",
True,
VARIANT_ESP32,
"esp-idf",
id="legacy-scalar-frequency",
),
pytest.param(
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "y"},
None,
"160MHZ",
True,
VARIANT_ESP32,
"esp-idf",
id="legacy-frequency-choice",
),
pytest.param(
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y"},
None,
"160MHZ",
True,
VARIANT_ESP32,
"esp-idf",
id="current-idf-frequency-choice",
),
pytest.param(
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "n"},
None,
"240MHZ",
False,
VARIANT_ESP32,
"esp-idf",
id="disabled-frequency-choice",
),
pytest.param(
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ_240": "n"},
"240MHz",
"240MHZ",
False,
VARIANT_ESP32,
"esp-idf",
id="ignore-unrecognized-legacy-disabled-choice",
),
pytest.param(
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"},
None,
"160MHZ",
True,
VARIANT_ESP32C3,
"esp-idf",
id="current-scalar-frequency",
),
pytest.param(
{"CONFIG_ESP32S2_DEFAULT_CPU_FREQ_MHZ": "160"},
None,
"160MHZ",
True,
VARIANT_ESP32S2,
"esp-idf",
id="s2-legacy-scalar-frequency",
),
pytest.param(
{"CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160": "y"},
None,
"160MHZ",
True,
VARIANT_ESP32S3,
"esp-idf",
id="s3-legacy-choice-frequency",
),
pytest.param(
{"CONFIG_ESP32C3_DEFAULT_CPU_FREQ_80": "y"},
None,
"80MHZ",
True,
VARIANT_ESP32C3,
"esp-idf",
id="c3-legacy-choice-frequency",
),
pytest.param(
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "160"},
None,
"160MHZ",
True,
VARIANT_ESP32,
"arduino",
id="arduino-framework-current-scalar-frequency",
),
],
)
def test_sdkconfig_cpu_frequency_is_honored(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
sdkconfig_options: dict[str, str],
cpu_frequency: str | None,
expected_frequency: str,
warns: bool,
variant: str,
framework_type: str,
) -> None:
"""SDK config frequency settings are honored and recommend the native config key."""
platform_framework = (
PlatformFramework.ESP32_ARDUINO
if framework_type == "arduino"
else PlatformFramework.ESP32_IDF
)
set_core_config(platform_framework)
from esphome.components.esp32 import CONFIG_SCHEMA
config: dict[str, Any] = {
"variant": variant,
"framework": {
"type": framework_type,
"sdkconfig_options": sdkconfig_options,
},
}
if cpu_frequency is not None:
config["cpu_frequency"] = cpu_frequency
with caplog.at_level(logging.WARNING):
config = CONFIG_SCHEMA(config)
assert ("CPU frequency setting" in caplog.text) is warns
assert config["cpu_frequency"] == expected_frequency
@pytest.mark.parametrize(
("variant", "cpu_frequency", "sdkconfig_options", "error_match"),
[
pytest.param(
VARIANT_ESP32C3,
None,
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ": "fast"},
"must be an integer MHz value",
id="integer-option-not-a-number",
),
pytest.param(
VARIANT_ESP32C3,
None,
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"},
"ESP32C3 does not support",
id="unsupported-frequency",
),
pytest.param(
VARIANT_ESP32C3,
None,
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "yes"},
"must be y or n",
id="choice-option-not-y-or-n",
),
pytest.param(
VARIANT_ESP32C3,
None,
{
"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160": "y",
"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y",
},
r"conflicting CPU frequencies \(160MHz, 240MHz\)",
id="two-frequencies-selected",
),
pytest.param(
VARIANT_ESP32,
"160MHz",
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "y"},
"conflicts with sdkconfig_options",
id="conflicts-with-explicit-value",
),
pytest.param(
VARIANT_ESP32,
None,
{"CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240": "n"},
"disables the selected CPU frequency",
id="default-frequency-disabled",
),
pytest.param(
VARIANT_ESP32,
"160MHz",
{"CONFIG_ESP32_DEFAULT_CPU_FREQ_160": "n"},
"disables the selected CPU frequency",
id="explicit-frequency-disabled-by-legacy-option",
),
],
)
def test_sdkconfig_cpu_frequency_errors(
set_core_config: SetCoreConfigCallable,
variant: str,
cpu_frequency: str | None,
sdkconfig_options: dict[str, str],
error_match: str,
) -> None:
"""Bad, conflicting or disabled sdkconfig frequencies fail at validation."""
set_core_config(PlatformFramework.ESP32_IDF)
from esphome.components.esp32 import CONFIG_SCHEMA
config: dict[str, Any] = {
"variant": variant,
"framework": {"type": "esp-idf", "sdkconfig_options": sdkconfig_options},
}
if cpu_frequency is not None:
config["cpu_frequency"] = cpu_frequency
with pytest.raises(cv.Invalid, match=error_match) as exc_info:
CONFIG_SCHEMA(config)
assert exc_info.value.path == ["framework", "sdkconfig_options"]
@pytest.mark.parametrize(
("config_toolchain", "expected"),
[
+13
View File
@@ -0,0 +1,13 @@
adalight:
light:
- platform: neopixelbus
id: adalight_light
name: Neopixelbus Light
method: esp8266_uart
num_leds: 5
pin: 2
type: GRBW
variant: SK6812
effects:
- adalight:
@@ -0,0 +1,3 @@
packages:
uart_115200: !include ../../test_build_components/common/uart_115200/esp8266-ard.yaml
adalight: !include common.yaml
+8
View File
@@ -1,5 +1,13 @@
sensor:
- platform: cm1106
id: cm1106_sensor
co2:
name: CM1106 CO2 Value
on_value:
then:
- cm1106.abc_enable: cm1106_sensor
- cm1106.abc_disable: cm1106_sensor
update_interval: 15s
automatic_baseline_calibration:
cycle: 7
baseline: 420
+5
View File
@@ -2,3 +2,8 @@ climate:
- platform: mitsubishi
name: Mitsubishi
transmitter_id: xmitr
fahrenheit_compatibility: true
vertical_vanes: 2
- platform: mitsubishi
name: Mitsubishi Celsius
transmitter_id: xmitr
+4
View File
@@ -2,9 +2,13 @@ tca9548a:
- id: multiplex0
i2c_id: i2c_bus
address: 0x70
frequency: 400 kHz
channels:
- bus_id: multiplex0_chan0
channel: 0
- bus_id: multiplex0_chan1
channel: 1
frequency: 1 MHz
- id: multiplex1
i2c_id: i2c_bus
address: 0x71
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/nrf52.yaml
tca9548a: !include common.yaml
+3 -2
View File
@@ -58,8 +58,9 @@ switch:
zigbee:
model: zigbee_test
router: false
power_source: MAINS_SINGLE_PHASE
sleepy: true
polling_interval: 2s
power_source: BATTERY
on_join:
then:
- logger.log: "Joined network"
@@ -35,6 +35,7 @@ async def test_socket_tcp_client_link(
link_down = asyncio.Event()
second_link_up = asyncio.Event()
link_up_count = 0
writers: list[asyncio.StreamWriter] = []
def on_log_line(line: str) -> None:
nonlocal link_up_count
@@ -48,6 +49,7 @@ async def test_socket_tcp_client_link(
async def handle(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
writers.append(writer)
if not echo_done.is_set():
writer.write(PAYLOAD)
await writer.drain()
@@ -98,5 +100,9 @@ async def test_socket_tcp_client_link(
"Second session echo wrong; stale bytes from the first session?"
)
finally:
# On Python 3.12 wait_closed() waits for every connection the handlers
# left open; nothing else closes them once the device has exited.
for writer in writers:
writer.close()
server.close()
await server.wait_closed()
@@ -41,12 +41,14 @@ async def test_tcp_uart_bridge(
service_byte_ok = asyncio.Event()
reconnect_echo_ok = asyncio.Event()
sessions = 0
writers: list[asyncio.StreamWriter] = []
async def handle(
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
nonlocal sessions
sessions += 1
writers.append(writer)
if sessions == 1:
writer.write(GREETING)
await writer.drain()
@@ -98,5 +100,9 @@ async def test_tcp_uart_bridge(
reconnect_echo_ok, 15.0, "Bridge did not reconnect and echo again"
)
finally:
# On Python 3.12 wait_closed() waits for every connection the handlers
# left open; nothing else closes them once the device has exited.
for writer in writers:
writer.close()
server.close()
await server.wait_closed()
@@ -888,8 +888,9 @@ def test_write_project_plain_asm_rule_skips_preprocessor(tmp_path: Path) -> None
_set_flags()
content = _write_ninja(paths)
assert "lowlevel.s.o: asm " in content
assert "rule asm\n command = $cc -x assembler $asflags -c $in -o $out" in content
assert "rule asm\n command = $ccache $cc -x assembler $asflags -c $in -o $out" in (
content
_write_ninja(paths, ccache="/cc/ccache")
)
+4 -1
View File
@@ -308,7 +308,10 @@ def test_write_project_pch_skipped_for_a_force_include_in_the_compiler(
def test_write_project_without_ccache(tmp_path: Path) -> None:
_make_src(tmp_path, "main.cpp")
_changed, ninja = _render()
assert "ccache = \n" in ninja
# No launcher variable, and no leading space on the commands either:
# CreateProcess on Windows rejects a command line that starts with one
assert "$ccache" not in ninja
assert " command = $cxx -MMD -MF $out.d" in ninja
def test_write_project_routes_user_link_flags(tmp_path: Path) -> None:
@@ -55,3 +55,21 @@ def test_collect_sources_skips_excluded_and_other_files(tmp_path: Path) -> None:
tmp_path / "b.cpp",
tmp_path / "sub" / "c.S",
]
@pytest.mark.parametrize("ccache", ["/usr/bin/ccache", None])
def test_compile_rule_lines_splice_the_launcher(ccache: str | None) -> None:
"""Without ccache no command keeps a leading space, which CreateProcess
on Windows rejects; with it every compile rule starts with the launcher."""
commands = [
line.removeprefix(" command = ")
for line in ninja_gen.compile_rule_lines(ccache)
if line.startswith(" command = ")
]
assert len(commands) == 4
for command in commands:
if ccache:
assert command.startswith("$ccache $c")
else:
assert command.startswith("$c")
assert "$ccache" not in command
+199
View File
@@ -7,6 +7,7 @@ from contextlib import contextmanager
import json
import os
from pathlib import Path
import shutil
import subprocess
from unittest.mock import MagicMock, call, patch
@@ -262,6 +263,14 @@ def test_get_idf_env_pops_inherited_pythonpath(setup_core: Path) -> None:
assert "PYTHONPATH" not in env
def test_get_idf_env_pops_inherited_idf_target(setup_core: Path) -> None:
"""An inherited IDF_TARGET does not reach cmake."""
toolchain._cache().env.clear()
with patch.dict(os.environ, {"IDF_PATH": str(setup_core), "IDF_TARGET": "esp32"}):
env = toolchain._get_idf_env(version="5.5.4")
assert "IDF_TARGET" not in env
def test_get_cmake_output_without_build_dir(setup_core: Path) -> None:
"""A build dir that was never created raises EsphomeError.
@@ -1575,3 +1584,193 @@ def test_missing_image_hint_names_the_flag(setup_core: Path) -> None:
f"{toolchain.SKIP_BOOTLOADER_DEFINE}:UNINITIALIZED=1\n"
)
assert "--skip-bootloader" in toolchain.missing_image_hint()
def _write_target_tree(
main: str | None = None,
bootloader: str | None = None,
bootloader_toolchain: str | None = None,
sdkconfig: str | None = None,
) -> None:
"""Write the target into the CMake caches and sdkconfig."""
tools = "/idf/tools/cmake"
for rel, target, toolchain_target in (
("build", main, main),
("build/bootloader", bootloader, bootloader_toolchain or bootloader),
):
if target is None:
continue
cache = CORE.relative_build_path(rel, "CMakeCache.txt")
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(
f"CMAKE_TOOLCHAIN_FILE:FILEPATH={tools}/toolchain-{toolchain_target}.cmake\n"
f"IDF_TARGET:STRING={target}\n"
)
if sdkconfig is not None:
path = CORE.relative_build_path("sdkconfig.test")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f'CONFIG_FOO=y\nCONFIG_IDF_TARGET="{sdkconfig}"\n')
@pytest.mark.parametrize(
("tree", "expected"),
[
({}, None),
({"main": "esp32c6", "bootloader": "esp32c6", "sdkconfig": "esp32c6"}, None),
({"main": "esp32h2"}, "esp32h2"),
({"bootloader": "esp32h2"}, "esp32h2"),
# Failed bootloader configure: new IDF_TARGET, old toolchain file
({"bootloader": "esp32c6", "bootloader_toolchain": "esp32h2"}, "esp32h2"),
({"main": "esp32c6", "sdkconfig": "esp32h2"}, "esp32h2"),
(
{
"main": "esp32c6",
"bootloader": "esp32c6",
"bootloader_toolchain": "clang-esp32c6",
},
None,
),
(
{
"main": "esp32c6",
"bootloader": "esp32c6",
"bootloader_toolchain": "clang-esp32h2",
},
"esp32h2",
),
],
)
def test_foreign_idf_target(
setup_core: Path, tree: dict[str, str], expected: str | None
) -> None:
"""Another target in any cache or sdkconfig counts."""
_setup_build(setup_core)
_write_target_tree(**tree)
assert toolchain._foreign_idf_target("esp32c6") == expected
@pytest.mark.parametrize(
("cache", "sdkconfig", "expected"),
[
("IDF_TARGET:STRING=esp32h2\n", None, "esp32h2"),
(None, "CONFIG_FOO=y\n", None),
],
)
def test_foreign_idf_target_partial_entries(
setup_core: Path, cache: str | None, sdkconfig: str | None, expected: str | None
) -> None:
"""Missing entries are skipped."""
_setup_build(setup_core)
for rel, content in (
("build/CMakeCache.txt", cache),
("sdkconfig.test", sdkconfig),
):
if content is not None:
path = CORE.relative_build_path(rel)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
assert toolchain._foreign_idf_target("esp32c6") == expected
def test_foreign_idf_target_ignores_unreadable_files(setup_core: Path) -> None:
"""Unreadable files are ignored."""
_setup_build(setup_core)
for rel in ("build/CMakeCache.txt", "sdkconfig.test"):
path = CORE.relative_build_path(rel)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"\xff\xfe IDF_TARGET:STRING=esp32h2\n")
assert toolchain._foreign_idf_target("esp32c6") is None
@pytest.mark.parametrize("snapshot", [True, False])
def test_clean_foreign_target_tree_cleans_and_resets_sdkconfig(
setup_core: Path, snapshot: bool
) -> None:
"""A foreign tree is cleaned and sdkconfig reset from the snapshot."""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6"
_write_target_tree(bootloader="esp32c6", bootloader_toolchain="esp32h2")
_write_target_tree(sdkconfig="esp32h2")
internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal")
if snapshot:
internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n")
with patch(
"esphome.writer.clean_build",
side_effect=lambda **_: shutil.rmtree(CORE.relative_build_path("build")),
) as mock_clean:
toolchain._clean_foreign_target_tree()
mock_clean.assert_called_once_with(clear_pio_cache=False)
sdkconfig = CORE.relative_build_path("sdkconfig.test").read_text()
if snapshot:
assert sdkconfig == "CONFIG_IDF_TARGET_ESP32C6=y\n"
else:
assert 'CONFIG_IDF_TARGET="esp32h2"' in sdkconfig
@pytest.mark.parametrize("variant", ["ESP32C6", None])
def test_clean_foreign_target_tree_keeps_matching_tree(
setup_core: Path, variant: str | None
) -> None:
"""A matching tree or unknown variant is left alone."""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
_write_target_tree(main="esp32c6", bootloader="esp32c6", sdkconfig="esp32c6")
internal = CORE.relative_build_path("sdkconfig.test.esphomeinternal")
internal.write_text("CONFIG_IDF_TARGET_ESP32C6=y\n")
sdkconfig = CORE.relative_build_path("sdkconfig.test")
before = sdkconfig.read_text()
with patch("esphome.writer.clean_build") as mock_clean:
toolchain._clean_foreign_target_tree()
mock_clean.assert_not_called()
assert sdkconfig.read_text() == before
@pytest.mark.parametrize("main", ["esp32h2", None])
def test_clean_foreign_target_tree_skipped_clean_keeps_sdkconfig(
setup_core: Path, monkeypatch: pytest.MonkeyPatch, main: str | None
) -> None:
"""ESPHOME_SKIP_CLEAN_BUILD keeps the tree and sdkconfig."""
_setup_build(setup_core)
monkeypatch.setenv("ESPHOME_SKIP_CLEAN_BUILD", "1")
CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6"
_write_target_tree(main=main, sdkconfig="esp32h2")
CORE.relative_build_path("sdkconfig.test.esphomeinternal").write_text(
"CONFIG_IDF_TARGET_ESP32C6=y\n"
)
toolchain._clean_foreign_target_tree()
assert CORE.relative_build_path("build/CMakeCache.txt").is_file() == bool(main)
assert 'CONFIG_IDF_TARGET="esp32h2"' in (
CORE.relative_build_path("sdkconfig.test").read_text()
)
def test_run_compile_cleans_foreign_target_tree_first(setup_core: Path) -> None:
"""run_compile cleans a foreign tree before the reconfigure check."""
_setup_build(setup_core)
CORE.data[KEY_ESP32][KEY_VARIANT] = "ESP32C6"
_write_target_tree(main="esp32h2")
calls: list[str] = []
with (
patch(
"esphome.writer.clean_build",
side_effect=lambda **_: calls.append("clean"),
),
patch.object(
toolchain,
"need_reconfigure",
side_effect=lambda: calls.append("check") or False,
),
patch.object(toolchain, "_cache_entries_changed", return_value=False),
patch.object(toolchain, "_run_ninja", return_value=0),
patch.object(toolchain, "print_summary"),
):
assert toolchain.run_compile({CONF_ESPHOME: {}}, verbose=False) == 0
assert calls == ["clean", "check"]
+121
View File
@@ -0,0 +1,121 @@
"""Tests for nRF52 BLE device discovery and OTA transport selection."""
import asyncio
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from esphome.components.nrf52 import ble_logger, ota, show_logs
from esphome.core import EsphomeError
MAC = "AA:BB:CC:DD:EE:FF"
# macOS/CoreBluetooth identifies peripherals by UUID instead of MAC address
UUID = "0FA1B2C3-D4E5-F607-1829-3A4B5C6D7E8F"
@pytest.mark.parametrize(
("value", "expected"),
[
(MAC, True),
(MAC.lower(), True),
(UUID, True),
("/dev/ttyACM0", False),
("COM3", False),
],
)
def test_is_ble_address(value: str, expected: bool) -> None:
assert ble_logger.is_ble_address(value) is expected
def _scan_result(
monkeypatch, devices: list[tuple[str, str | None, str | None]]
) -> AsyncMock:
"""Stub the bleak scan so the filter is applied to the given (address, name, local_name) devices."""
async def find_device_by_filter(filterfunc, timeout):
for address, name, local_name in devices:
device = SimpleNamespace(address=address, name=name)
if filterfunc(device, SimpleNamespace(local_name=local_name)):
return device
return None
mock = AsyncMock(side_effect=find_device_by_filter)
monkeypatch.setattr(ble_logger.BleakScanner, "find_device_by_filter", mock)
return mock
def test_scan_matches_advertised_local_name(monkeypatch) -> None:
"""On macOS device.name is cached from an earlier connection; the live name is adv.local_name."""
_scan_result(monkeypatch, [(UUID, "stale-old-name", "my-device")])
assert asyncio.run(ota.smpmgr_scan("my-device")) == UUID
def test_scan_matches_device_name(monkeypatch) -> None:
_scan_result(monkeypatch, [(MAC, "my-device", None)])
assert asyncio.run(ota.smpmgr_scan("my-device")) == MAC
def test_scan_raises_when_no_device_found(monkeypatch) -> None:
_scan_result(monkeypatch, [(MAC, "someone-else", "someone-else")])
with pytest.raises(EsphomeError, match="not found"):
asyncio.run(ota.smpmgr_scan("my-device"))
@pytest.mark.parametrize(
("device", "transport"),
[("/dev/ttyACM0", "serial"), ("COM3", "serial"), (MAC, "ble"), (UUID, "ble")],
)
def test_upload_transport(monkeypatch, device: str, transport: str) -> None:
"""Serial ports go to the serial transport; MAC addresses and macOS UUIDs to BLE."""
captured: dict = {}
class FakeClient:
def __init__(self, transport, address):
captured["transport"] = transport
captured["address"] = address
async def connect(self) -> None:
pass
async def disconnect(self) -> None:
pass
monkeypatch.setattr(ota, "_get_image_tlv_sha256", lambda firmware: b"")
monkeypatch.setattr(ota, "_smpmgr_upload_connected", AsyncMock())
monkeypatch.setattr(ota, "SMPSerialTransport", lambda: "serial")
monkeypatch.setattr(ota, "SMPBLETransport", lambda: "ble")
monkeypatch.setattr(ota, "SMPClient", FakeClient)
asyncio.run(ota._smpmgr_upload(device, Path("firmware.bin"))) # pylint: disable=protected-access
assert captured == {"transport": transport, "address": device}
@pytest.mark.parametrize(
("device", "connected_to"),
[("BLE", UUID), (MAC, MAC), (UUID, UUID), ("my-device.local", None)],
)
def test_show_logs_routing(monkeypatch, device: str, connected_to: str | None) -> None:
"""A scanned device, a MAC or a UUID is handed to the BLE logger; anything else falls through."""
scan = AsyncMock(return_value=SimpleNamespace(address=UUID))
connect = AsyncMock(return_value=0)
monkeypatch.setattr(ble_logger, "logger_scan", scan)
monkeypatch.setattr(ble_logger, "logger_connect", connect)
handled = show_logs(config={}, args=None, devices=[device])
assert handled is (connected_to is not None)
if connected_to is None:
connect.assert_not_called()
else:
connect.assert_awaited_once_with(connected_to)
def test_show_logs_returns_when_the_scan_finds_nothing(monkeypatch) -> None:
monkeypatch.setattr(ble_logger, "logger_scan", AsyncMock(return_value=None))
connect = AsyncMock()
monkeypatch.setattr(ble_logger, "logger_connect", connect)
assert show_logs(config={}, args=None, devices=["BLE"]) is True
connect.assert_not_called()