Compare commits

...
Author SHA1 Message Date
clyde 4922b2b25f Merge branch 's2-draw' into s2-gray 2026-10-23 13:20:31 +11:00
clydeandClaude Opus 5.5 c43f039631 [runtime_image] Share one scale_boundary() between draw() and the JPEG RGB565 tables; test uneven scales
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:20:30 +11:00
clydeandClaude Opus 5.5 51d2df33a3 [runtime_image] Work out grayscale pixels with integer maths
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:11 +10:00
clydeandClaude Opus 5.5 8d1476d585 [runtime_image] Use draw_width_ and draw_height_ in the JPEG decoder
The scale fields were removed when drawing moved to integer maths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
clydeandClaude Opus 5.5 25b8c93ad6 [runtime_image] Check once per draw() call whether the image is scaled
An image drawn at its own size needs no span maths at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
clydeandClaude Opus 5.5 5b1642b721 [runtime_image] Say the generic draw() path keeps the same pixels as the RGB565 path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
clydeandClaude Opus 5.5 b62e12007f [runtime_image] Skip the span division for an image drawn at its own size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
clydeandClaude Opus 5.5 68d24cb215 [runtime_image] Draw each target pixel once, with integer maths
ImageDecoder::draw() painted every source pixel into the buffer, so a
shrunk image wrote each target pixel many times, and worked out the
spans with double multiplications and ceil() for every pixel. The S3
has no double FPU, so drawing took about 6 us a pixel: 3.7 s of a 1000
x 617 PNG's 6 s. Give each source pixel the span from ceil(start *
scale) to ceil(end * scale), worked out with integers, so neighbouring
spans meet without overlapping. The PNG now loads in 3.2 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:57:10 +10:00
Clyde StubbsandClaude Opus 5.5 dcfad5c439 [runtime_image] Decode at the smallest possible JPEGDEC scale (#21386)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:57:05 +11:00
Clyde StubbsandClaude Opus 5.5 e08cbc5661 [online_image] Add on_download_started and on_download_progress (#21355)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:38:13 +11:00
Clyde StubbsandClaude Opus 5.5 2b183be544 [online_image] Decode a JPEG as it downloads (#21342)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:38:13 +11:00
J. Nick Koston 6a18470ccb [tests] Let LineWaiter wait for several log patterns at once (#21153) 2026-10-22 15:05:30 -10:00
J. Nick Koston e9aaf84ee5 [tests] Add helpers to subscribe after initial states and wait for the next state (#21151) 2026-10-22 15:05:04 -10:00
rwrozelleandClaude Opus 5.5 69785f56f6 [ota] Fit UDP OTA datagrams in Thread's 1280-byte IPv6 MTU (#21429)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:10:31 -10:00
J. Nick Koston 61b52c0a34 [esp32_hosted] Accept a verified image another build cached when Windows refuses the rename (#21431) 2026-10-23 00:10:00 +00:00
esphome[bot] 54c4cc81c5 Bump aioesphomeapi from 46.21.0 to 46.22.0 (#21434) 2026-10-23 00:09:49 +00:00
02ea1b2696 [seeed_multi_channel_relay] Add the Seeed Studio Grove multi-channel relay boards (#7233)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 14:03:45 -10:00
Bonne EgglestonandJ. Nick Koston 16023df4fd [cw2017] Add CW2017 battery fuel gauge (#21369)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 13:58:54 -10:00
0fd72f74f1 [islandaire] Add Islandaire PTAC climate IR component (#20153)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 13:43:32 -10:00
sebo-b 4254d76e5c [ln882x_spi_led_strip] Add SPI DMA addressable LED strip for LN882x (#21409) 2026-10-22 13:30:06 -10:00
7605958db5 [spi] Add nRF52 (Zephyr) hardware SPI support (#18214)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: tomaszduda23 <tomaszduda23@gmail.com>
2026-10-22 13:20:26 -10:00
puddlyandJ. Nick Koston 35ea89859b [usb_uart] Implement hardware flow control (#20823)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 22:51:54 +00:00
J. Nick Koston fd7a0d3679 [components] Restore switches with Switch::restore() (#21428) 2026-10-22 12:48:39 -10:00
backcountrymountainsandJ. Nick Koston dc37e06143 [light] Add `effect to initial_state` (#18795)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 12:44:36 -10:00
9226e03496 [m5stack_4relay] m5stack 4relay switch implementation (#7234)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 12:40:37 -10:00
J. Nick Koston a4fff7738b [ethernet] Check the RGMII ESP-IDF floor with the shared validator (#21430) 2026-10-22 18:29:56 -04:00
4242bcb084 [vnc] Add the Hextile encoding and skip unchanged tiles (#20625)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-22 21:52:53 +00:00
J. Nick Koston 8a88e97497 [weikai] Share print_buffer between the SPI and I2C drivers (#20721) 2026-10-22 21:21:41 +00:00
Frédéric MetrichandClaude d586e20a31 [teleinfo] Fix default units of unitless, text and PMAX tags (#21367)
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-22 17:21:09 -04:00
e6161e64b1 [rx8111] Add alarm and wake-up timer support (#21217)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
2026-10-22 21:14:37 +00:00
J. Nick Koston 431843c27e [tests] Use LineWaiter for the integration tests that parse or count log lines (#21306) 2026-10-22 11:14:34 -10:00
J. Nick Koston a1da3d6b90 [unicode] Stop reading past a truncated UTF-8 sequence (#21374) 2026-10-22 11:14:17 -10:00
J. Nick Koston 930429061a [tests] Use LineWaiter for the integration tests that only wait on log lines (#21305) 2026-10-22 11:14:01 -10:00
J. Nick Kostonandpre-commit-ci-lite[bot] afceb91d90 [tests] Use wait_or_fail for the remaining integration test timeouts (#21304)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-10-22 11:13:55 -10:00
J. Nick Koston 7d741361a3 [weikai] Keep the register name tables in flash (#20712) 2026-10-22 11:13:04 -10:00
miloit 9c70ffd826 [ds3231] Add time platform, alarms, binary sensors and output control (#21054) 2026-10-22 10:58:28 -10:00
J. Nick Koston dac6a9c8cc [cover] Pack the cover traits flags into one byte (#21339) 2026-10-22 16:53:20 -04:00
J. Nick Koston a3a4144e4f [valve] Pack the valve traits flags into one byte (#21343) 2026-10-22 16:52:35 -04:00
J. Nick Koston 29e9ae008d [lock] Pack the lock traits flags into one byte (#21344) 2026-10-22 16:52:05 -04:00
Bonne Eggleston c92c4d7f99 [deep_sleep] Add woke_from_deep_sleep() and build get_wakeup_cause() without on_wake (#21425) 2026-10-22 10:49:15 -10:00
5bcc3f42e9 [switch] Add initial_state and restore_state, with flash or RTC storage (#20978)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 10:35:23 -10:00
Jonathan Swoboda d6aa5ba14c [esp32] Share one validator for options that need a newer ESP-IDF (#21422) 2026-10-22 10:20:22 -10:00
Clyde StubbsandClaude Opus 5.5 59c7178910 [runtime_image] Decode JPEG a step at a time (#21341)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 06:52:41 +11:00
J. Nick Koston e55741a26b [usb_uart] Read the channel limit when the config is validated, not at import (#21331) 2026-10-22 09:34:55 -10:00
dependabot[bot] 0bdc14154b Bump prek from 0.5.5 to 0.5.6 (#21419)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 19:32:52 +00:00
dependabot[bot] 1d6f839ee7 Bump platformdirs from 4.13.4 to 4.13.5 (#21418)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 19:32:20 +00:00
dependabot[bot] 2544518aca Bump filelock from 4.2.2 to 4.2.4 (#21417)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 19:32:17 +00:00
dependabot[bot] 70e56df630 Bump j178/prek-action from 3.0.1 to 3.0.2 (#21420)
Signed-off-by: dependabot[bot] <support@github.com>
2026-10-22 19:29:54 +00:00
Jonathan Swoboda 7d62aae996 [wifi] Allow the lease cache and a second WISP DNS server on the ESP32 Arduino framework (#21412) 2026-10-22 15:27:16 -04:00
c12baaa337 [mqtt] Support stop tilt on covers (#20251)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-10-22 19:14:24 +00:00
Jonathan Swoboda cb9eda3681 [spi] Gate psram_dma on the ESP-IDF version rather than the framework (#21411) 2026-10-22 14:48:04 -04:00
281 changed files with 12167 additions and 3469 deletions

No files matched your search

+1 -1
View File
@@ -286,7 +286,7 @@ jobs:
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Run prek
uses: j178/prek-action@1382ad72dea72f9d53832f2bcb65b3e87a947204 # v3.0.1
uses: j178/prek-action@af4d4fcb6922b8e6f65c0426947125fcc38794ce # v3.0.2
with:
prek-version: ${{ steps.prek.outputs.version }}
# This job only runs on pull requests, so nothing ever populates
+2
View File
@@ -446,6 +446,8 @@ file does, and it is the authority when they disagree. The most useful starting
switches = switch.sub_switches(config, parent=hub)
await switches(CONF_BLUETOOTH, None, apply="set_bluetooth(state)", publish="after")
```
Restore an owned or standalone switch in `setup()` with `sw->restore()`, or `restore(default)`
to still drive a state when the restore mode is disabled.
- **Other codegen helpers:** `uart.new_uart_component(config, *ctor_args)` (and `i2c.new_i2c_component`,
`spi.new_spi_component`, `modbus.new_modbus_client_component`, `ble_client.new_ble_node_component`)
+5
View File
@@ -147,6 +147,7 @@ esphome/components/cst816/* @clydebarrow
esphome/components/cst9220/* @clydebarrow
esphome/components/ct_clamp/* @jesserockz
esphome/components/current_based/* @djwmarcx
esphome/components/cw2017/* @exciton
esphome/components/d01/* @ch604
esphome/components/dac7678/* @NickB1
esphome/components/daikin_arc/* @MagicBear
@@ -325,6 +326,7 @@ esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core
esphome/components/ir_rf_base/* @bdraco @kbx81
esphome/components/ir_rf_proxy/* @kbx81
esphome/components/islandaire/* @ipopov
esphome/components/it8951/* @koosoli @limengdu @Passific
esphome/components/jsn_sr04t/* @Mafus1
esphome/components/json/* @esphome/core
@@ -356,6 +358,7 @@ esphome/components/lm75b/* @beormund
esphome/components/ln882h_ble/* @Bl00d-B0b
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny
esphome/components/ln882x_spi_led_strip/* @sebo-b
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
@@ -366,6 +369,7 @@ esphome/components/ltr390/* @latonita @sjtrny
esphome/components/ltr501/* @latonita
esphome/components/ltr_als_ps/* @latonita
esphome/components/lvgl/* @clydebarrow
esphome/components/m5stack_4relay/* @KoenBreeman
esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
@@ -537,6 +541,7 @@ esphome/components/sdp3x/* @Azimath
esphome/components/seeed_mr24hpc1/* @limengdu
esphome/components/seeed_mr60bha2/* @limengdu
esphome/components/seeed_mr60fda2/* @limengdu
esphome/components/seeed_multi_channel_relay/* @KoenBreeman
esphome/components/selec_meter/* @sourabhjaiswal
esphome/components/select/* @esphome/core
esphome/components/sen0321/* @notjj
+1 -4
View File
@@ -8,10 +8,7 @@ ESPHOME_LOG_TAG(TAG, "copy.lock");
void CopyLock::setup() {
source_->add_on_state_callback([this](lock::LockState state) { this->publish_state(state); });
traits.set_assumed_state(source_->traits.get_assumed_state());
traits.set_requires_code(source_->traits.get_requires_code());
traits.set_supported_states_mask(source_->traits.get_supported_states_mask());
traits.set_supports_open(source_->traits.get_supports_open());
this->traits = this->source_->traits;
this->publish_state(source_->state);
}
+24 -18
View File
@@ -1,31 +1,37 @@
#pragma once
#include <cstdint>
namespace esphome::cover {
class CoverTraits {
public:
CoverTraits() = default;
bool get_is_assumed_state() const { return this->is_assumed_state_; }
void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; }
bool get_supports_position() const { return this->supports_position_; }
void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; }
bool get_supports_tilt() const { return this->supports_tilt_; }
void set_supports_tilt(bool supports_tilt) { this->supports_tilt_ = supports_tilt; }
bool get_supports_toggle() const { return this->supports_toggle_; }
void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; }
bool get_supports_stop() const { return this->supports_stop_; }
void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; }
bool get_supports_stop_tilt() const { return this->supports_stop_tilt_; }
void set_supports_stop_tilt(bool supports_stop_tilt) { this->supports_stop_tilt_ = supports_stop_tilt; }
bool get_is_assumed_state() const { return this->flags_.is_assumed_state; }
void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; }
bool get_supports_position() const { return this->flags_.supports_position; }
void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; }
bool get_supports_tilt() const { return this->flags_.supports_tilt; }
void set_supports_tilt(bool supports_tilt) { this->flags_.supports_tilt = supports_tilt; }
bool get_supports_toggle() const { return this->flags_.supports_toggle; }
void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; }
bool get_supports_stop() const { return this->flags_.supports_stop; }
void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; }
bool get_supports_stop_tilt() const { return this->flags_.supports_stop_tilt; }
void set_supports_stop_tilt(bool supports_stop_tilt) { this->flags_.supports_stop_tilt = supports_stop_tilt; }
protected:
bool is_assumed_state_{false};
bool supports_position_{false};
bool supports_tilt_{false};
bool supports_toggle_{false};
bool supports_stop_{false};
bool supports_stop_tilt_{false};
// Bit-packed flags to save memory (1 byte instead of 6)
struct CoverTraitFlags {
uint8_t is_assumed_state : 1;
uint8_t supports_position : 1;
uint8_t supports_tilt : 1;
uint8_t supports_toggle : 1;
uint8_t supports_stop : 1;
uint8_t supports_stop_tilt : 1;
uint8_t reserved : 2;
} flags_{};
};
} // namespace esphome::cover
+2
View File
@@ -0,0 +1,2 @@
CODEOWNERS = ["@exciton"]
DOMAIN = "cw2017"
+196
View File
@@ -0,0 +1,196 @@
#include "cw2017.h"
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
#endif
namespace esphome::cw2017 {
ESPHOME_LOG_TAG(TAG, "cw2017");
static constexpr uint8_t REG_VERSION = 0x00;
static constexpr uint8_t REG_VCELL = 0x02;
static constexpr uint8_t REG_SOC = 0x04;
static constexpr uint8_t REG_MODE = 0x08;
static constexpr uint8_t REG_SOC_ALERT = 0x0B;
static constexpr uint8_t REG_BATINFO = 0x10;
static constexpr uint8_t MODE_NORMAL = 0x00;
static constexpr uint8_t MODE_SLEEP = 0x30;
static constexpr uint8_t UPDATE_FLAG = 0x80;
static constexpr uint8_t RESTART_MODES[] PROGMEM = {0xF0, MODE_SLEEP, MODE_NORMAL};
static constexpr uint8_t RESTART_STEPS = sizeof(RESTART_MODES);
static constexpr uint16_t VCELL_MASK = 0x3FFF;
static constexpr float VCELL_VOLTS_PER_LSB = 312.5e-6f;
static constexpr uint32_t STEP_DELAY_MS = 20;
static constexpr uint32_t READY_POLL_MS = 100;
static constexpr uint8_t READY_ATTEMPTS = 40;
static constexpr uint32_t INIT_RETRY_MS = 1000;
static constexpr uint8_t INIT_ATTEMPTS = 3;
static bool version_is_running(uint8_t version) { return (version & 0xFD) == 0x0D; }
// The configured profile only changes with new firmware, and flashing never ends in a deep-sleep wake.
static bool woke_from_deep_sleep() {
#ifdef USE_DEEP_SLEEP
return deep_sleep::woke_from_deep_sleep();
#else
return false;
#endif
}
bool CW2017::read_profile_matches_(bool &matches) {
uint8_t stored[PROFILE_SIZE];
uint8_t expected[PROFILE_SIZE];
if (!this->read_bytes(REG_BATINFO, stored, PROFILE_SIZE))
return false;
progmem_memcpy(expected, this->profile_, PROFILE_SIZE);
matches = std::memcmp(stored, expected, PROFILE_SIZE) == 0;
return true;
}
bool CW2017::upload_profile_() {
// Built here with the register first, so the bus layer does not heap-allocate a buffer this size.
uint8_t buffer[1 + PROFILE_SIZE] = {REG_BATINFO};
progmem_memcpy(buffer + 1, this->profile_, PROFILE_SIZE);
return this->write(buffer, sizeof(buffer)) == i2c::ERROR_OK && this->write_byte(REG_SOC_ALERT, UPDATE_FLAG);
}
void CW2017::setup() { this->init_(); }
// Retried because another device resetting on the same bus can disturb an early transfer.
void CW2017::init_failed_(const LogString *reason) {
if (++this->init_attempts_ >= INIT_ATTEMPTS) {
this->mark_failed(reason);
return;
}
this->set_timeout(INIT_RETRY_MS, [this]() { this->init_(); });
}
void CW2017::init_() {
// A profile upload only takes effect after a restart, so a failed restart is retried, not skipped.
if (this->restart_pending_) {
this->restart_();
return;
}
uint8_t mode = 0;
uint8_t version = 0;
uint8_t config = 0;
if (!this->read_byte(REG_MODE, &mode) || !this->read_byte(REG_VERSION, &version) ||
!this->read_byte(REG_SOC_ALERT, &config)) {
this->init_failed_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
const bool has_profile = (config & UPDATE_FLAG) != 0;
bool matches = has_profile && woke_from_deep_sleep();
if (has_profile && !matches && !this->read_profile_matches_(matches)) {
this->init_failed_(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (!matches) {
ESP_LOGI(TAG, "Uploading battery profile");
if (!this->upload_profile_()) {
this->init_failed_(LOG_STR("Battery profile upload failed"));
return;
}
this->restart_();
return;
}
if (mode == MODE_SLEEP) {
// The gauge keeps its profile and state through its own sleep, so a wake is a mode write, not a restart.
if (!this->write_byte(REG_MODE, MODE_NORMAL)) {
this->init_failed_(LOG_STR("Gauge wake failed"));
return;
}
this->set_timeout(STEP_DELAY_MS, [this]() { this->wait_ready_(0); });
return;
}
if (mode != MODE_NORMAL || !version_is_running(version)) {
this->restart_();
return;
}
this->wait_ready_(0);
}
void CW2017::restart_() {
this->restart_pending_ = true;
this->set_timeout(STEP_DELAY_MS, [this]() { this->restart_step_(0); });
}
void CW2017::restart_step_(uint8_t step) {
if (!this->write_byte(REG_MODE, progmem_read_byte(&RESTART_MODES[step]))) {
this->init_failed_(LOG_STR("Restart failed"));
return;
}
if (step + 1 < RESTART_STEPS) {
this->set_timeout(STEP_DELAY_MS, [this, step]() { this->restart_step_(step + 1); });
} else {
this->restart_pending_ = false;
this->set_timeout(STEP_DELAY_MS, [this]() { this->wait_ready_(0); });
}
}
void CW2017::wait_ready_(uint8_t attempt) {
uint8_t version = 0;
uint8_t soc = 0;
if (this->read_byte(REG_VERSION, &version) && version_is_running(version) && this->read_byte(REG_SOC, &soc) &&
soc <= 100) {
ESP_LOGD(TAG, "Gauge ready %u ms after boot", (unsigned) millis());
this->ready_ = true;
this->update();
return;
}
if (attempt + 1 >= READY_ATTEMPTS) {
this->init_failed_(LOG_STR("Gauge did not become ready"));
return;
}
this->set_timeout(READY_POLL_MS, [this, attempt]() { this->wait_ready_(attempt + 1); });
}
void CW2017::update() {
if (!this->ready_)
return;
if (this->battery_level_sensor_ != nullptr) {
uint8_t soc = 0;
if (this->read_byte(REG_SOC, &soc) && soc <= 100) {
this->battery_level_sensor_->publish_state(soc);
} else {
this->status_set_warning();
return;
}
}
if (this->battery_voltage_sensor_ != nullptr) {
uint16_t raw = 0;
if (this->read_byte_16(REG_VCELL, &raw)) {
this->battery_voltage_sensor_->publish_state((raw & VCELL_MASK) * VCELL_VOLTS_PER_LSB);
} else {
this->status_set_warning();
return;
}
}
this->status_clear_warning();
}
void CW2017::dump_config() {
ESP_LOGCONFIG(TAG, "CW2017:");
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Battery Level", this->battery_level_sensor_);
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_sensor_);
}
} // namespace esphome::cw2017
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
namespace esphome::cw2017 {
static constexpr uint8_t PROFILE_SIZE = 80;
class CW2017 : public PollingComponent, public i2c::I2CDevice {
public:
explicit CW2017(const uint8_t *profile) : profile_(profile) {}
void set_battery_level_sensor(sensor::Sensor *sensor) { this->battery_level_sensor_ = sensor; }
void set_battery_voltage_sensor(sensor::Sensor *sensor) { this->battery_voltage_sensor_ = sensor; }
void setup() override;
void update() override;
void dump_config() override;
protected:
void init_();
void init_failed_(const LogString *reason);
bool read_profile_matches_(bool &matches);
bool upload_profile_();
void restart_();
void restart_step_(uint8_t step);
void wait_ready_(uint8_t attempt);
const uint8_t *profile_;
sensor::Sensor *battery_level_sensor_{nullptr};
sensor::Sensor *battery_voltage_sensor_{nullptr};
bool ready_{false};
bool restart_pending_{false};
uint8_t init_attempts_{0};
};
} // namespace esphome::cw2017
+61
View File
@@ -0,0 +1,61 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_BATTERY_VOLTAGE,
CONF_RAW_DATA_ID,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
UNIT_VOLT,
)
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
CONF_BATTERY_PROFILE = "battery_profile"
PROFILE_SIZE = 80
cw2017_ns = cg.esphome_ns.namespace("cw2017")
CW2017 = cw2017_ns.class_("CW2017", cg.PollingComponent, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(CW2017),
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
cv.Required(CONF_BATTERY_PROFILE): cv.All(
cv.ensure_list(cv.hex_uint8_t),
cv.Length(min=PROFILE_SIZE, max=PROFILE_SIZE),
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=0,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x63))
)
async def to_code(config: ConfigType) -> None:
profile = cg.progmem_array(config[CONF_RAW_DATA_ID], config[CONF_BATTERY_PROFILE])
var = await i2c.new_i2c_component(config, profile)
sensors = sensor.sub_sensors(config)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor)
@@ -7,7 +7,6 @@ namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
switch (lt_get_reboot_reason()) {
case REBOOT_REASON_SLEEP_GPIO:
@@ -20,7 +19,6 @@ WakeupCause get_wakeup_cause() {
return WAKEUP_CAUSE_NONE;
}
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
@@ -60,8 +60,6 @@ struct WakeupCauseToRunDuration {
#endif // USE_ESP32
#ifdef USE_DEEP_SLEEP_ON_WAKE
/// Why the device woke from deep sleep. Passed to on_wake automations.
enum WakeupCause : uint8_t {
/// The device did not wake from deep sleep (for example a cold boot, reset or OTA restart).
@@ -79,6 +77,11 @@ enum WakeupCause : uint8_t {
/// Return why the device woke from deep sleep. Implemented per platform.
WakeupCause get_wakeup_cause();
/// Return true when this boot is a wake from deep sleep.
inline bool woke_from_deep_sleep() { return get_wakeup_cause() != WAKEUP_CAUSE_NONE; }
#ifdef USE_DEEP_SLEEP_ON_WAKE
/** Setup priority of on_wake triggers.
*
* Between restoring global variables (setup_priority::HARDWARE, 800) and on_boot automations at
@@ -30,7 +30,6 @@ namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
switch (esp_sleep_get_wakeup_cause()) {
case ESP_SLEEP_WAKEUP_EXT0:
@@ -47,7 +46,6 @@ WakeupCause get_wakeup_cause() {
return WAKEUP_CAUSE_UNKNOWN;
}
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const {
if (this->wakeup_cause_to_run_duration_.has_value()) {
@@ -3,17 +3,14 @@
#include <Esp.h>
#ifdef USE_DEEP_SLEEP_ON_WAKE
extern "C" {
#include <user_interface.h>
}
#endif
namespace esphome::deep_sleep {
ESPHOME_LOG_TAG(TAG, "deep_sleep");
#ifdef USE_DEEP_SLEEP_ON_WAKE
WakeupCause get_wakeup_cause() {
// The ESP8266 can only wake from deep sleep through the RTC timer (via GPIO16 -> RST).
// NOLINTNEXTLINE(readability-static-accessed-through-instance)
@@ -22,7 +19,6 @@ WakeupCause get_wakeup_cause() {
}
return WAKEUP_CAUSE_NONE;
}
#endif // USE_DEEP_SLEEP_ON_WAKE
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
@@ -31,6 +31,9 @@ static bool wakeable_delay_feed_wdt(uint32_t ms) {
return false;
}
// A wake from System OFF is not told apart from a reset yet.
WakeupCause get_wakeup_cause() { return WAKEUP_CAUSE_NONE; }
optional<uint32_t> DeepSleepComponent::get_run_duration_() const { return this->run_duration_; }
void DeepSleepComponent::dump_config_platform_() {}
+239 -6
View File
@@ -1,7 +1,18 @@
from esphome import automation
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components import i2c
from esphome.components.const import CONF_ENABLED
import esphome.config_validation as cv
from esphome.const import (
CONF_FREQUENCY,
CONF_HOUR,
CONF_INTERRUPT_PIN,
CONF_MINUTE,
CONF_MODE,
CONF_OFFSET,
CONF_SECOND,
CONF_STATE,
)
from esphome.types import ConfigType
CODEOWNERS = ["@linkedupbits"]
@@ -10,23 +21,245 @@ MULTI_CONF = True
DOMAIN = "ds3231"
CONF_DS3231_ID = "ds3231_id"
CONF_ALARM = "alarm"
CONF_ALARM_CHECK_INTERVAL = "alarm_check_interval"
CONF_AGING_OFFSET = "aging_offset"
CONF_AUTO_CLEAR_ALARMS = "auto_clear_alarms"
CONF_BATTERY_BACKED_SQUARE_WAVE = "battery_backed_square_wave"
CONF_DAY_OF_MONTH = "day_of_month"
CONF_DAY_OF_WEEK = "day_of_week"
CONF_ON_ALARM_1 = "on_alarm_1"
CONF_ON_ALARM_2 = "on_alarm_2"
CONF_OUTPUT_32KHZ = "output_32khz"
CONF_SQUARE_WAVE_FREQUENCY = "square_wave_frequency"
ds3231_ns = cg.esphome_ns.namespace("ds3231")
DS3231Component = ds3231_ns.class_("DS3231Component", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema({cv.GenerateID(): cv.declare_id(DS3231Component)})
AlarmMode = ds3231_ns.enum("AlarmMode")
ALARM_1_MODES = AlarmMode.options(
"every_second",
"every_minute",
"hourly",
"daily",
"monthly",
"weekly",
prefix="ALARM_MODE_",
)
# Alarm 2 has no seconds register.
ALARM_2_MODES = {k: v for k, v in ALARM_1_MODES.items() if k != "every_second"}
SquareWaveFrequency = ds3231_ns.enum("SquareWaveFrequency")
# Keyed by the frequency in Hz; cv.frequency turns "1024Hz", "1.024kHz" or 1024 into it.
SQUARE_WAVE_FREQUENCIES = {
1: SquareWaveFrequency.SQUARE_WAVE_1HZ,
1024: SquareWaveFrequency.SQUARE_WAVE_1024HZ,
4096: SquareWaveFrequency.SQUARE_WAVE_4096HZ,
8192: SquareWaveFrequency.SQUARE_WAVE_8192HZ,
}
validate_square_wave_frequency = cv.All(
cv.frequency, cv.enum(SQUARE_WAVE_FREQUENCIES, int=True)
)
validate_aging_offset = cv.int_range(min=-128, max=127)
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): cv.declare_id(DS3231Component),
cv.Optional(CONF_INTERRUPT_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_SQUARE_WAVE_FREQUENCY): validate_square_wave_frequency,
cv.Optional(CONF_BATTERY_BACKED_SQUARE_WAVE): cv.boolean,
cv.Optional(CONF_OUTPUT_32KHZ): cv.boolean,
cv.Optional(CONF_AGING_OFFSET): validate_aging_offset,
cv.Optional(CONF_AUTO_CLEAR_ALARMS, default=True): cv.boolean,
cv.Optional(
CONF_ALARM_CHECK_INTERVAL, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_ON_ALARM_1): automation.validate_automation(single=True),
cv.Optional(CONF_ON_ALARM_2): automation.validate_automation(single=True),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x68))
.extend(i2c.i2c_device_schema(0x68)),
cv.has_at_most_one_key(CONF_INTERRUPT_PIN, CONF_SQUARE_WAVE_FREQUENCY),
)
async def to_code(config: ConfigType) -> None:
await i2c.new_i2c_component(config)
var = await i2c.new_i2c_component(config)
await cg.add_pin(var.set_interrupt_pin, config.get(CONF_INTERRUPT_PIN))
cg.add_optional(
var.set_square_wave_frequency, config.get(CONF_SQUARE_WAVE_FREQUENCY)
)
cg.add_optional(
var.set_battery_backed_square_wave, config.get(CONF_BATTERY_BACKED_SQUARE_WAVE)
)
cg.add_optional(var.set_output_32khz_config, config.get(CONF_OUTPUT_32KHZ))
cg.add_optional(var.set_aging_offset_config, config.get(CONF_AGING_OFFSET))
cg.add(var.set_auto_clear_alarms(config[CONF_AUTO_CLEAR_ALARMS]))
cg.add(var.set_alarm_check_interval(config[CONF_ALARM_CHECK_INTERVAL]))
# The alarm flags are only read when something uses them; the binary sensor platform
# turns this on as well. With an interrupt pin the check also releases INT/SQW.
if any(
key in config for key in (CONF_INTERRUPT_PIN, CONF_ON_ALARM_1, CONF_ON_ALARM_2)
):
cg.add(var.set_monitor_alarms(True))
# One callback for both alarms; each automation runs only for its own alarm number.
for conf_key, alarm in ((CONF_ON_ALARM_1, 1), (CONF_ON_ALARM_2, 2)):
if (conf := config.get(conf_key)) is not None:
await automation.build_callback_automation(
var,
"add_on_alarm_callback",
[],
conf,
params=[(cg.uint8, "alarm")],
forward=[],
when=f"alarm == {alarm}",
)
# ---------------------------------------------------------------------------------------------
# Actions
# ---------------------------------------------------------------------------------------------
DS3231_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(DS3231Component)}
)
automation.register_apply_action(
"ds3231.force_temperature_conversion",
automation.maybe_simple_id({cv.GenerateID(): cv.use_id(DS3231Component)}),
DS3231_ACTION_SCHEMA,
automation.ApplyCall("force_temperature_conversion()"),
)
def _validate_alarm_day(config: ConfigType) -> ConfigType:
mode = config[CONF_MODE]
if mode == "monthly" and CONF_DAY_OF_MONTH not in config:
raise cv.Invalid(f"'{CONF_DAY_OF_MONTH}' is required in monthly mode")
if mode == "weekly" and CONF_DAY_OF_WEEK not in config:
raise cv.Invalid(f"'{CONF_DAY_OF_WEEK}' is required in weekly mode")
# The unused day still has to be a valid register value.
config.setdefault(CONF_DAY_OF_MONTH, 1)
config.setdefault(CONF_DAY_OF_WEEK, 1)
return config
def _alarm_schema(modes: dict, with_second: bool) -> cv.Schema:
schema = {
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_MODE): cv.enum(modes, lower=True),
cv.Optional(CONF_MINUTE, default=0): cv.templatable(
cv.int_range(min=0, max=59)
),
cv.Optional(CONF_HOUR, default=0): cv.templatable(cv.int_range(min=0, max=23)),
cv.Optional(CONF_DAY_OF_MONTH): cv.templatable(cv.int_range(min=1, max=31)),
# 1 = Sunday ... 7 = Saturday, as in ESPHome's time component.
cv.Optional(CONF_DAY_OF_WEEK): cv.templatable(cv.int_range(min=1, max=7)),
cv.Optional(CONF_ENABLED, default=True): cv.templatable(cv.boolean),
}
if with_second:
schema[cv.Optional(CONF_SECOND, default=0)] = cv.templatable(
cv.int_range(min=0, max=59)
)
return cv.All(cv.Schema(schema), _validate_alarm_day)
automation.register_apply_action(
"ds3231.set_alarm_1",
_alarm_schema(ALARM_1_MODES, with_second=True),
automation.ApplyCall(
"set_alarm_1({}, {}, {}, {}, {}, {}, {})",
(
(CONF_MODE, AlarmMode),
(CONF_SECOND, cg.uint8),
(CONF_MINUTE, cg.uint8),
(CONF_HOUR, cg.uint8),
(CONF_DAY_OF_MONTH, cg.uint8),
(CONF_DAY_OF_WEEK, cg.uint8),
(CONF_ENABLED, cg.bool_),
),
),
)
automation.register_apply_action(
"ds3231.set_alarm_2",
_alarm_schema(ALARM_2_MODES, with_second=False),
automation.ApplyCall(
"set_alarm_2({}, {}, {}, {}, {}, {})",
(
(CONF_MODE, AlarmMode),
(CONF_MINUTE, cg.uint8),
(CONF_HOUR, cg.uint8),
(CONF_DAY_OF_MONTH, cg.uint8),
(CONF_DAY_OF_WEEK, cg.uint8),
(CONF_ENABLED, cg.bool_),
),
),
)
DS3231_ALARM_ACTION_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_ALARM): cv.templatable(cv.int_range(min=1, max=2)),
}
)
for _name, _method in (
("ds3231.enable_alarm", "enable_alarm"),
("ds3231.disable_alarm", "disable_alarm"),
("ds3231.clear_alarm", "clear_alarm"),
):
automation.register_apply_action(
_name,
DS3231_ALARM_ACTION_SCHEMA,
automation.ApplyField(CONF_ALARM, _method, cg.uint8),
)
automation.register_apply_condition(
"ds3231.is_alarm_triggered",
DS3231_ALARM_ACTION_SCHEMA,
automation.ApplyCall("is_alarm_triggered({})", ((CONF_ALARM, cg.uint8),)),
)
automation.register_apply_action(
"ds3231.enable_square_wave",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_FREQUENCY): validate_square_wave_frequency,
}
),
automation.ApplyField(CONF_FREQUENCY, "enable_square_wave", SquareWaveFrequency),
)
automation.register_apply_action(
"ds3231.disable_square_wave",
DS3231_ACTION_SCHEMA,
automation.ApplyCall("disable_square_wave()"),
)
automation.register_apply_action(
"ds3231.set_32khz_output",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_STATE): cv.templatable(cv.boolean),
}
),
automation.ApplyField(CONF_STATE, "set_output_32khz", cg.bool_),
)
automation.register_apply_action(
"ds3231.set_aging_offset",
cv.Schema(
{
cv.GenerateID(): cv.use_id(DS3231Component),
cv.Required(CONF_OFFSET): cv.templatable(validate_aging_offset),
}
),
automation.ApplyField(CONF_OFFSET, "set_aging_offset", cg.int8),
)
@@ -0,0 +1,47 @@
import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_ID, DEVICE_CLASS_PROBLEM, ENTITY_CATEGORY_DIAGNOSTIC
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component
DEPENDENCIES = ["ds3231"]
CONF_ALARM_1 = "alarm_1"
CONF_ALARM_2 = "alarm_2"
CONF_OSCILLATOR_STOPPED = "oscillator_stopped"
ICON_ALARM = "mdi:alarm"
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(CONF_ID): cv.declare_id(cg.EntityBase),
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
cv.Optional(CONF_ALARM_1): binary_sensor.binary_sensor_schema(
icon=ICON_ALARM
),
cv.Optional(CONF_ALARM_2): binary_sensor.binary_sensor_schema(
icon=ICON_ALARM
),
cv.Optional(CONF_OSCILLATOR_STOPPED): binary_sensor.binary_sensor_schema(
device_class=DEVICE_CLASS_PROBLEM,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
),
cv.has_at_least_one_key(CONF_ALARM_1, CONF_ALARM_2, CONF_OSCILLATOR_STOPPED),
)
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_DS3231_ID])
binary_sensors = binary_sensor.sub_binary_sensors(config)
await binary_sensors(CONF_ALARM_1, hub.set_alarm_1_binary_sensor)
await binary_sensors(CONF_ALARM_2, hub.set_alarm_2_binary_sensor)
await binary_sensors(
CONF_OSCILLATOR_STOPPED, hub.set_oscillator_stopped_binary_sensor
)
if CONF_ALARM_1 in config or CONF_ALARM_2 in config:
cg.add(hub.set_monitor_alarms(True))
+423 -14
View File
@@ -9,29 +9,184 @@ namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231");
static constexpr uint8_t DS3231_REG_SECONDS = 0x00;
static constexpr uint8_t DS3231_REG_ALARM_1 = 0x07;
static constexpr uint8_t DS3231_REG_ALARM_2 = 0x0B;
static constexpr uint8_t DS3231_REG_CONTROL = 0x0E;
static constexpr uint8_t DS3231_REG_STATUS = 0x0F;
static constexpr uint8_t DS3231_REG_AGING_OFFSET = 0x10;
static constexpr uint8_t DS3231_REG_TEMPERATURE = 0x11;
static constexpr uint8_t ALARM_MASK_BIT = 0x80; // AxMy: this register is ignored when matching
static constexpr uint8_t ALARM_DAY_OF_WEEK = 0x40; // DY/DT: match day of week instead of date
/// How long an alarm binary sensor stays on when the flag is cleared automatically.
static constexpr uint32_t ALARM_PULSE_MS = 1000;
static const LogString *alarm_state_to_string(uint8_t control, uint8_t alarm) {
return (control & (alarm == 1 ? 0x01 : 0x02)) != 0 ? LOG_STR("enabled") : LOG_STR("disabled");
}
static const LogString *square_wave_to_string(SquareWaveFrequency frequency) {
switch (frequency) {
case SQUARE_WAVE_1HZ:
return LOG_STR("1 Hz");
case SQUARE_WAVE_1024HZ:
return LOG_STR("1.024 kHz");
case SQUARE_WAVE_4096HZ:
return LOG_STR("4.096 kHz");
case SQUARE_WAVE_8192HZ:
return LOG_STR("8.192 kHz");
default:
return LOG_STR("unknown");
}
}
void IRAM_ATTR DS3231Component::interrupt_handler(DS3231Component *arg) { arg->enable_loop_soon_any_context(); }
void DS3231Component::setup() {
if (!this->read_control_status_()) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->control_reg_ &= ~CONTROL_EOSC; // keep the oscillator running on battery
// INT/SQW is the (unused) interrupt line rather than a square-wave output.
this->control_reg_ |= CONTROL_INTCN;
if (!this->write_control_()) {
// Keep the oscillator running on battery. INT/SQW is the alarm interrupt line unless a square
// wave is configured. The alarm interrupt enables (A1IE/A2IE) are kept as they are: the alarms
// live on the chip and survive a reboot of the ESP on battery.
uint8_t mask = CONTROL_EOSC | CONTROL_INTCN;
uint8_t value = CONTROL_INTCN;
if (this->square_wave_configured_) {
mask |= CONTROL_RS_MASK;
value = this->square_wave_frequency_ << CONTROL_RS_SHIFT;
}
if (this->battery_backed_square_wave_ >= 0) {
mask |= CONTROL_BBSQW;
assign_bits(value, CONTROL_BBSQW, this->battery_backed_square_wave_ != 0);
}
if (!this->update_control_(mask, value)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (this->output_32khz_ >= 0) {
assign_bits(this->status_reg_, STATUS_EN32KHZ, this->output_32khz_ != 0);
if (!this->write_status_(0)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
}
if (this->aging_offset_configured_ && !this->write_aging_offset_(this->aging_offset_)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((this->status_reg_ & STATUS_OSF) != 0) {
ESP_LOGW(TAG, "Oscillator was stopped (battery empty or missing?), the stored time is invalid.");
}
this->publish_oscillator_stopped_();
if (!this->monitor_alarms_) {
this->disable_loop();
return;
}
// Start from a known state; an alarm that fired while the ESP was off is picked up by the first
// check, which runs from the loop right after setup.
this->publish_alarm_state_(1, false);
this->publish_alarm_state_(2, false);
if (this->interrupt_pin_ != nullptr) {
this->interrupt_pin_->setup();
// INT/SQW is open drain and active low: a falling edge means an enabled alarm matched.
// The handler only wakes the loop; the flags are read from there.
this->interrupt_pin_->attach_interrupt(&DS3231Component::interrupt_handler, this, gpio::INTERRUPT_FALLING_EDGE);
}
// Without an interrupt pin this is how alarms are seen. With one it is a fallback: it picks up an
// edge that was missed, and latched flags (auto_clear_alarms: false) that were cleared elsewhere.
this->set_interval(this->alarm_check_interval_, [this]() { this->check_alarms_(); });
}
void DS3231Component::loop() {
// Only runs once after setup and when woken by the interrupt pin.
this->disable_loop();
this->check_alarms_();
}
void DS3231Component::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231:");
LOG_I2C_DEVICE(this);
if (this->is_failed())
return;
LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
if ((this->control_reg_ & CONTROL_INTCN) == 0) {
ESP_LOGCONFIG(TAG, " INT/SQW: square wave %s",
LOG_STR_ARG(square_wave_to_string(
static_cast<SquareWaveFrequency>((this->control_reg_ & CONTROL_RS_MASK) >> CONTROL_RS_SHIFT))));
} else {
ESP_LOGCONFIG(TAG, " INT/SQW: alarm interrupt");
}
ESP_LOGCONFIG(TAG,
" Battery-backed square wave: %s\n"
" 32 kHz output: %s\n"
" Alarm 1: %s\n"
" Alarm 2: %s\n"
" Auto clear alarms: %s",
YESNO(this->control_reg_ & CONTROL_BBSQW), ONOFF(this->status_reg_ & STATUS_EN32KHZ),
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 1)),
LOG_STR_ARG(alarm_state_to_string(this->control_reg_, 2)), YESNO(this->auto_clear_alarms_));
if (this->aging_offset_configured_) {
ESP_LOGCONFIG(TAG, " Aging offset: %d", this->aging_offset_);
}
if (this->monitor_alarms_) {
ESP_LOGCONFIG(TAG, " Alarm check interval: %" PRIu32 " ms", this->alarm_check_interval_);
}
#ifdef USE_BINARY_SENSOR
LOG_BINARY_SENSOR(" ", "Alarm 1", this->alarm_1_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Alarm 2", this->alarm_2_binary_sensor_);
LOG_BINARY_SENSOR(" ", "Oscillator Stopped", this->oscillator_stopped_binary_sensor_);
#endif
}
// ---------------------------------------------------------------------------------------------
// Clock
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_calendar(uint8_t *raw) {
if (this->is_failed() || !this->status_comm_result(this->read_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
return false;
// Without a valid status the oscillator-stop flag is unknown, so the time can't be trusted.
if (!this->read_control_status_())
return false;
this->publish_oscillator_stopped_();
if ((this->status_reg_ & STATUS_OSF) != 0) {
ESP_LOGW(TAG, "Oscillator stop flag set, the RTC time is invalid. Write a valid time first.");
return false;
}
return true;
}
bool DS3231Component::write_calendar(const uint8_t *raw) {
if (this->is_failed() || !this->status_comm_result(this->write_bytes(DS3231_REG_SECONDS, raw, CALENDAR_LEN)))
return false;
// The time is valid again: clear the oscillator stop flag. If that fails, the RTC would keep
// reporting its time as invalid, so the write is not reported as successful.
if (!this->read_control_status_())
return false;
if ((this->status_reg_ & STATUS_OSF) != 0) {
if (!this->write_status_(STATUS_OSF))
return false;
this->status_reg_ &= ~STATUS_OSF;
}
this->publish_oscillator_stopped_();
return true;
}
// ---------------------------------------------------------------------------------------------
// Temperature
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_temperature(float &out) {
uint8_t raw[2];
if (this->is_failed() || !this->read_bytes(DS3231_REG_TEMPERATURE, raw, sizeof(raw))) {
@@ -53,26 +208,280 @@ void DS3231Component::force_temperature_conversion() {
ESP_LOGW(TAG, "A temperature conversion is already in progress.");
return;
}
this->control_reg_ |= CONTROL_CONV;
this->write_control_();
// CONV is a one-shot command bit, so it is not kept in the cached value.
this->status_comm_result(this->write_byte(DS3231_REG_CONTROL, this->control_reg_ | CONTROL_CONV));
}
// ---------------------------------------------------------------------------------------------
// Alarms
// ---------------------------------------------------------------------------------------------
void DS3231Component::set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled) {
if (second > 59 || minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 ||
day_of_week > 7 || mode > ALARM_MODE_WEEKLY) {
ESP_LOGE(TAG, "Invalid alarm 1 setting.");
return;
}
// Mask bits A1M1..A1M4: a set bit means the register is ignored when matching.
uint8_t regs[4] = {
uint8_to_bcd(second),
uint8_to_bcd(minute),
uint8_to_bcd(hour),
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
};
switch (mode) {
case ALARM_MODE_EVERY_SECOND:
regs[0] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_EVERY_MINUTE:
regs[1] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_HOURLY:
regs[2] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_DAILY:
regs[3] |= ALARM_MASK_BIT;
break;
default: // MONTHLY / WEEKLY: everything matches
break;
}
ESP_LOGD(TAG, "Setting alarm 1: mode %u, %02u:%02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
second, day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
this->write_alarm_(1, regs, sizeof(regs), enabled);
}
void DS3231Component::set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled) {
if (minute > 59 || hour > 23 || day_of_month < 1 || day_of_month > 31 || day_of_week < 1 || day_of_week > 7 ||
mode == ALARM_MODE_EVERY_SECOND || mode > ALARM_MODE_WEEKLY) {
ESP_LOGE(TAG, "Invalid alarm 2 setting.");
return;
}
// Mask bits A2M2..A2M4.
uint8_t regs[3] = {
uint8_to_bcd(minute),
uint8_to_bcd(hour),
mode == ALARM_MODE_WEEKLY ? static_cast<uint8_t>(ALARM_DAY_OF_WEEK | day_of_week) : uint8_to_bcd(day_of_month),
};
switch (mode) {
case ALARM_MODE_EVERY_MINUTE:
regs[0] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_HOURLY:
regs[1] |= ALARM_MASK_BIT;
[[fallthrough]];
case ALARM_MODE_DAILY:
regs[2] |= ALARM_MASK_BIT;
break;
default: // MONTHLY / WEEKLY: everything matches
break;
}
ESP_LOGD(TAG, "Setting alarm 2: mode %u, %02u:%02u, day of month %u, day of week %u, %s", mode, hour, minute,
day_of_month, day_of_week, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
this->write_alarm_(2, regs, sizeof(regs), enabled);
}
void DS3231Component::write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled) {
if (this->is_failed())
return;
if (!this->status_comm_result(this->write_bytes(alarm == 1 ? DS3231_REG_ALARM_1 : DS3231_REG_ALARM_2, regs, len)))
return;
// Changing the registers may have matched on the way: start from a clean flag. Enabling the
// alarm with a stale flag would assert INT/SQW right away.
if (!this->clear_alarm_flag_(alarm)) {
ESP_LOGW(TAG, "Alarm %u written, but its flag could not be cleared; not changing its enable.", alarm);
return;
}
this->set_alarm_enabled_(alarm, enabled);
}
void DS3231Component::set_alarm_enabled_(uint8_t alarm, bool enabled) {
if (alarm != 1 && alarm != 2) {
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
return;
}
if (this->is_failed())
return;
const uint8_t bit = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
if (!this->update_control_(bit, enabled ? bit : 0))
return;
if (enabled && (this->control_reg_ & CONTROL_INTCN) == 0) {
ESP_LOGW(TAG, "Alarm %u enabled, but INT/SQW is a square-wave output: only the flag will be set.", alarm);
}
ESP_LOGD(TAG, "Alarm %u %s.", alarm, enabled ? LOG_STR_LITERAL("enabled") : LOG_STR_LITERAL("disabled"));
}
void DS3231Component::enable_alarm(uint8_t alarm) {
// The chip sets the flag on every match even while the alarm is disabled; enabling with it set
// would fire the trigger (and INT/SQW) for that old match right away.
if (this->clear_alarm_flag_(alarm))
this->set_alarm_enabled_(alarm, true);
}
void DS3231Component::clear_alarm(uint8_t alarm) { this->clear_alarm_flag_(alarm); }
bool DS3231Component::clear_alarm_flag_(uint8_t alarm) {
if (alarm != 1 && alarm != 2) {
ESP_LOGE(TAG, "Invalid alarm number %u.", alarm);
return false;
}
if (this->is_failed() || !this->read_control_status_())
return false;
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
if ((this->status_reg_ & flag) != 0) {
// On failure the flag is still set on the chip: keep reporting the alarm as it is.
if (!this->write_status_(flag))
return false;
this->status_reg_ &= ~flag;
}
this->alarm_active_[alarm - 1] = false;
this->publish_alarm_state_(alarm, false);
return true;
}
bool DS3231Component::is_alarm_triggered(uint8_t alarm) {
if (this->is_failed() || !this->read_control_status_())
return false;
return (this->status_reg_ & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) != 0;
}
void DS3231Component::check_alarms_() {
if (this->is_failed() || !this->read_control_status_())
return;
this->publish_oscillator_stopped_();
uint8_t to_clear = 0;
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
const uint8_t flag = alarm == 1 ? STATUS_A1F : STATUS_A2F;
const uint8_t enable = alarm == 1 ? CONTROL_A1IE : CONTROL_A2IE;
const bool triggered = (this->status_reg_ & flag) != 0;
const bool enabled = (this->control_reg_ & enable) != 0;
bool &active = this->alarm_active_[alarm - 1];
if (!triggered) {
if (active && !this->auto_clear_alarms_)
this->publish_alarm_state_(alarm, false);
active = false;
continue;
}
// The flag is set on every match even when the alarm is disabled; only enabled alarms count.
if (enabled && !active) {
ESP_LOGI(TAG, "Alarm %u triggered.", alarm);
active = true;
this->publish_alarm_state_(alarm, true);
this->alarm_callback_.call(alarm);
}
if (this->auto_clear_alarms_)
to_clear |= flag;
}
if (to_clear != 0 && this->write_status_(to_clear)) {
this->status_reg_ &= ~to_clear;
for (uint8_t alarm = 1; alarm <= 2; alarm++) {
if ((to_clear & (alarm == 1 ? STATUS_A1F : STATUS_A2F)) == 0 || !this->alarm_active_[alarm - 1])
continue;
this->alarm_active_[alarm - 1] = false;
#ifdef USE_BINARY_SENSOR
// Keep the binary sensor on long enough for the state to be seen, then turn it off again.
this->set_timeout(ALARM_PULSE_TIMEOUT_ID + alarm - 1, ALARM_PULSE_MS,
[this, alarm]() { this->publish_alarm_state_(alarm, false); });
#endif
}
}
}
void DS3231Component::publish_alarm_state_(uint8_t alarm, bool state) {
#ifdef USE_BINARY_SENSOR
auto *sensor = alarm == 1 ? this->alarm_1_binary_sensor_ : this->alarm_2_binary_sensor_;
if (sensor != nullptr)
sensor->publish_state(state);
#endif
}
void DS3231Component::publish_oscillator_stopped_() {
#ifdef USE_BINARY_SENSOR
if (this->oscillator_stopped_binary_sensor_ != nullptr)
this->oscillator_stopped_binary_sensor_->publish_state((this->status_reg_ & STATUS_OSF) != 0);
#endif
}
// ---------------------------------------------------------------------------------------------
// Outputs and calibration
// ---------------------------------------------------------------------------------------------
void DS3231Component::enable_square_wave(SquareWaveFrequency frequency) {
if (this->interrupt_pin_ != nullptr) {
ESP_LOGE(TAG, "Can't enable the square wave: INT/SQW is used as the alarm interrupt pin.");
return;
}
if (this->is_failed())
return;
if (this->update_control_(CONTROL_INTCN | CONTROL_RS_MASK, frequency << CONTROL_RS_SHIFT)) {
ESP_LOGD(TAG, "Square wave enabled at %s.", LOG_STR_ARG(square_wave_to_string(frequency)));
}
}
void DS3231Component::disable_square_wave() {
if (this->is_failed())
return;
if (this->update_control_(CONTROL_INTCN, CONTROL_INTCN)) {
ESP_LOGD(TAG, "Square wave disabled, INT/SQW is the alarm interrupt.");
}
}
void DS3231Component::set_output_32khz(bool enabled) {
if (this->is_failed() || !this->read_control_status_())
return;
assign_bits(this->status_reg_, STATUS_EN32KHZ, enabled);
if (this->write_status_(0)) {
ESP_LOGD(TAG, "32 kHz output %s.", ONOFF(enabled));
}
}
void DS3231Component::set_aging_offset(int8_t offset) {
if (this->is_failed())
return;
if (!this->write_aging_offset_(offset))
return;
ESP_LOGD(TAG, "Aging offset set to %d.", offset);
// The new offset takes effect with the next temperature conversion.
this->force_temperature_conversion();
}
// ---------------------------------------------------------------------------------------------
// Register access
// ---------------------------------------------------------------------------------------------
bool DS3231Component::read_control_status_() {
uint8_t raw[2];
if (!this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))) {
ESP_LOGW(TAG, "Can't read control/status registers.");
if (!this->status_comm_result(this->read_bytes(DS3231_REG_CONTROL, raw, sizeof(raw))))
return false;
}
this->control_reg_ = raw[0];
this->control_reg_ = raw[0] & ~CONTROL_CONV;
this->status_reg_ = raw[1];
return true;
}
bool DS3231Component::write_control_() {
if (!this->write_byte(DS3231_REG_CONTROL, this->control_reg_)) {
ESP_LOGW(TAG, "Can't write control register.");
bool DS3231Component::update_control_(uint8_t mask, uint8_t value) {
// CONV is a one-shot command bit: always write it back as 0 so a control change never starts
// a temperature conversion.
if (!this->status_comm_result(this->update_register(DS3231_REG_CONTROL, mask | CONTROL_CONV, value)))
return false;
}
this->control_reg_ = (this->control_reg_ & ~mask) | (value & mask);
return true;
}
bool DS3231Component::write_status_(uint8_t clear) {
// The flags can only be written to 0; writing 1 leaves them unchanged, so a flag that was set
// between the read and this write is never lost.
const uint8_t value = ((this->status_reg_ & STATUS_EN32KHZ) | STATUS_FLAGS) & ~clear;
return this->status_comm_result(this->write_byte(DS3231_REG_STATUS, value));
}
bool DS3231Component::write_aging_offset_(int8_t offset) {
if (!this->status_comm_result(this->write_byte(DS3231_REG_AGING_OFFSET, static_cast<uint8_t>(offset))))
return false;
this->aging_offset_ = offset;
return true;
}
+132 -1
View File
@@ -1,32 +1,163 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#ifdef USE_BINARY_SENSOR
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
namespace esphome::ds3231 {
/// When an alarm fires. Alarm 1 supports every mode, alarm 2 has no seconds register and so no EVERY_SECOND.
enum AlarmMode : uint8_t {
ALARM_MODE_EVERY_SECOND = 0, ///< Alarm 1 only.
ALARM_MODE_EVERY_MINUTE, ///< Alarm 1: when the seconds match. Alarm 2: at second 00 of every minute.
ALARM_MODE_HOURLY, ///< When minutes (and seconds) match.
ALARM_MODE_DAILY, ///< When hours, minutes (and seconds) match.
ALARM_MODE_MONTHLY, ///< When date, hours, minutes (and seconds) match.
ALARM_MODE_WEEKLY, ///< When day of week, hours, minutes (and seconds) match.
};
/// Frequency of the INT/SQW pin when it is a square-wave output (RS2:RS1 in the control register).
enum SquareWaveFrequency : uint8_t {
SQUARE_WAVE_1HZ = 0,
SQUARE_WAVE_1024HZ = 1,
SQUARE_WAVE_4096HZ = 2,
SQUARE_WAVE_8192HZ = 3,
};
/// Hub for a single DS3231 real-time clock. Owns all register access; the platforms talk to the
/// chip through this class.
class DS3231Component final : public Component, public i2c::I2CDevice {
public:
/// Seconds through year, the 7-byte BCD calendar the time platform decodes with time::DS_BCD_CALENDAR.
static constexpr size_t CALENDAR_LEN = 7;
void setup() override;
void loop() override;
void dump_config() override;
// ---- Configuration (set from codegen) ----
void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
void set_square_wave_frequency(SquareWaveFrequency frequency) {
this->square_wave_frequency_ = frequency;
this->square_wave_configured_ = true;
}
void set_battery_backed_square_wave(bool enabled) { this->battery_backed_square_wave_ = enabled ? 1 : 0; }
void set_output_32khz_config(bool enabled) { this->output_32khz_ = enabled ? 1 : 0; }
void set_aging_offset_config(int8_t offset) {
this->aging_offset_ = offset;
this->aging_offset_configured_ = true;
}
void set_auto_clear_alarms(bool auto_clear) { this->auto_clear_alarms_ = auto_clear; }
void set_alarm_check_interval(uint32_t interval) { this->alarm_check_interval_ = interval; }
void set_monitor_alarms(bool monitor) { this->monitor_alarms_ = monitor; }
/// Register a void(uint8_t alarm) callback that runs when an enabled alarm (1 or 2) matches.
template<typename F> void add_on_alarm_callback(F &&callback) {
this->alarm_callback_.add(std::forward<F>(callback));
}
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(alarm_1)
SUB_BINARY_SENSOR(alarm_2)
SUB_BINARY_SENSOR(oscillator_stopped)
#endif
// ---- Clock registers (used by the time platform) ----
/// Read the calendar registers into @p raw. Fails if the oscillator was stopped since the time was last set.
bool read_calendar(uint8_t *raw);
/// Write the calendar registers from @p raw and clear the oscillator-stop flag.
bool write_calendar(const uint8_t *raw);
// ---- Temperature ----
bool read_temperature(float &out);
void force_temperature_conversion();
// ---- Alarms ----
/// Program alarm 1. @p day_of_month is used in MONTHLY mode, @p day_of_week (1 = Sunday) in WEEKLY mode.
void set_alarm_1(AlarmMode mode, uint8_t second, uint8_t minute, uint8_t hour, uint8_t day_of_month,
uint8_t day_of_week, bool enabled);
/// Program alarm 2 (minute resolution, fires at second 00).
void set_alarm_2(AlarmMode mode, uint8_t minute, uint8_t hour, uint8_t day_of_month, uint8_t day_of_week,
bool enabled);
/// Enable @p alarm. A match from while it was disabled is cleared first, so only the next one fires.
void enable_alarm(uint8_t alarm);
void disable_alarm(uint8_t alarm) { this->set_alarm_enabled_(alarm, false); }
/// Clear the alarm flag, which also releases the INT/SQW line.
void clear_alarm(uint8_t alarm);
/// True while the alarm flag of @p alarm (1 or 2) is set on the chip.
bool is_alarm_triggered(uint8_t alarm);
// ---- Outputs and calibration ----
/// Switch INT/SQW to a square-wave output at @p frequency. Alarms then no longer drive the pin.
void enable_square_wave(SquareWaveFrequency frequency);
/// Switch INT/SQW back to the (active-low) alarm interrupt output.
void disable_square_wave();
void set_output_32khz(bool enabled);
/// Aging offset in LSB (about 0.1 ppm each at 25 degC); positive values slow the clock down.
void set_aging_offset(int8_t offset);
protected:
static constexpr uint8_t CONTROL_A1IE = 0x01;
static constexpr uint8_t CONTROL_A2IE = 0x02;
static constexpr uint8_t CONTROL_INTCN = 0x04;
static constexpr uint8_t CONTROL_RS_MASK = 0x18;
static constexpr uint8_t CONTROL_RS_SHIFT = 3;
static constexpr uint8_t CONTROL_CONV = 0x20;
static constexpr uint8_t CONTROL_BBSQW = 0x40;
static constexpr uint8_t CONTROL_EOSC = 0x80;
static constexpr uint8_t STATUS_A1F = 0x01;
static constexpr uint8_t STATUS_A2F = 0x02;
static constexpr uint8_t STATUS_BSY = 0x04;
static constexpr uint8_t STATUS_EN32KHZ = 0x08;
static constexpr uint8_t STATUS_OSF = 0x80;
static constexpr uint8_t STATUS_FLAGS = STATUS_A1F | STATUS_A2F | STATUS_OSF;
/// Scheduler ids: one pulse timer per alarm (id + alarm - 1).
static constexpr uint32_t ALARM_PULSE_TIMEOUT_ID = 0;
static void interrupt_handler(DS3231Component *arg);
/// Read the control and status registers into the cached copies. Sets the warning flag on failure.
bool read_control_status_();
bool write_control_();
/// Replace the bits in @p mask of the control register with @p value, on the chip and in the cache.
bool update_control_(uint8_t mask, uint8_t value);
/// Write the status register, clearing the flags in @p clear (writing 1 to a flag leaves it unchanged).
bool write_status_(uint8_t clear);
bool write_aging_offset_(int8_t offset);
/// Clear the flag of @p alarm and report it off. Returns false (state unchanged) if that fails.
bool clear_alarm_flag_(uint8_t alarm);
void set_alarm_enabled_(uint8_t alarm, bool enabled);
/// Write the alarm registers, clear the stale flag and set the interrupt enable.
void write_alarm_(uint8_t alarm, const uint8_t *regs, size_t len, bool enabled);
/// Read the flags, fire triggers and update the binary sensors.
void check_alarms_();
void publish_alarm_state_(uint8_t alarm, bool state);
void publish_oscillator_stopped_();
InternalGPIOPin *interrupt_pin_{nullptr};
LazyCallbackManager<void(uint8_t)> alarm_callback_;
uint32_t alarm_check_interval_{1000};
uint8_t control_reg_{0};
uint8_t status_reg_{0};
int8_t aging_offset_{0};
SquareWaveFrequency square_wave_frequency_{SQUARE_WAVE_1HZ};
/// -1: leave the chip as it is, 0: off, 1: on.
int8_t battery_backed_square_wave_{-1};
int8_t output_32khz_{-1};
bool square_wave_configured_{false};
bool aging_offset_configured_{false};
bool auto_clear_alarms_{true};
bool monitor_alarms_{false};
/// Set while an alarm flag has been reported, so a latched flag fires its trigger only once.
bool alarm_active_[2]{false, false};
};
} // namespace esphome::ds3231
@@ -0,0 +1,40 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import time
import esphome.config_validation as cv
from esphome.const import CONF_ID
from esphome.types import ConfigType
from .. import CONF_DS3231_ID, DS3231Component, ds3231_ns
DEPENDENCIES = ["ds3231"]
DS3231Time = ds3231_ns.class_(
"DS3231Time", time.RealTimeClock, cg.Parented.template(DS3231Component)
)
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(DS3231Time),
cv.GenerateID(CONF_DS3231_ID): cv.use_id(DS3231Component),
}
)
DS3231_TIME_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(DS3231Time)}
)
automation.register_apply_action(
"ds3231.write_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("write_time()")
)
automation.register_apply_action(
"ds3231.read_time", DS3231_TIME_ACTION_SCHEMA, automation.ApplyCall("read_time()")
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_DS3231_ID])
await time.register_time(var, config)
@@ -0,0 +1,48 @@
#include "ds3231_time.h"
#include "esphome/core/log.h"
namespace esphome::ds3231 {
ESPHOME_LOG_TAG(TAG, "ds3231.time");
void DS3231Time::update() { this->read_time(); }
void DS3231Time::dump_config() {
ESP_LOGCONFIG(TAG, "DS3231 Time:");
RealTimeClock::dump_config();
}
void DS3231Time::read_time() {
uint8_t raw[DS3231Component::CALENDAR_LEN];
if (!this->status_comm_result(this->parent_->read_calendar(raw), LOG_STR("No valid time from the RTC")))
return;
const ESPTime rtc_time = time::decode_bcd_calendar<time::DS_BCD_CALENDAR>(raw);
ESP_LOGD(TAG, "Read UTC time: %04u-%02u-%02u %02u:%02u:%02u (day of week %u)", rtc_time.year, rtc_time.month,
rtc_time.day_of_month, rtc_time.hour, rtc_time.minute, rtc_time.second, rtc_time.day_of_week);
// The day of week is a free-running counter on the DS3231 and may have been set with a different
// numbering by other software, so it is not used to validate the time.
if (!this->status_comm_result(rtc_time.is_valid(/*check_day_of_week=*/false, /*check_day_of_year=*/false),
LOG_STR("Invalid RTC time")))
return;
this->synchronize_epoch_(rtc_time.timestamp);
}
void DS3231Time::write_time() {
auto now = this->utcnow();
if (!now.is_valid()) {
ESP_LOGE(TAG, "Invalid system time, not syncing to RTC.");
return;
}
if (now.year > 2199) {
ESP_LOGE(TAG, "Year %u is out of the DS3231 range (2000-2199).", now.year);
return;
}
uint8_t raw[DS3231Component::CALENDAR_LEN];
time::encode_bcd_calendar<time::DS_BCD_CALENDAR>(now, raw);
if (!this->status_comm_result(this->parent_->write_calendar(raw)))
return;
ESP_LOGD(TAG, "Wrote UTC time: %04u-%02u-%02u %02u:%02u:%02u", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
} // namespace esphome::ds3231
@@ -0,0 +1,22 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esphome/components/time/real_time_clock.h"
#include "../ds3231.h"
namespace esphome::ds3231 {
class DS3231Time final : public time::RealTimeClock, public Parented<DS3231Component> {
public:
void update() override;
void dump_config() override;
/// Set the system clock from the RTC.
void read_time();
/// Store the system clock in the RTC.
void write_time();
};
} // namespace esphome::ds3231
+16
View File
@@ -2213,6 +2213,22 @@ def idf_version() -> cv.Version:
return CORE.data[KEY_ESP32][KEY_IDF_VERSION]
def require_idf_version(
minimum: cv.Version, feature: str = "This feature"
) -> Callable[[Any], Any]:
"""Validator for an option that needs the underlying ESP-IDF at ``minimum`` or newer."""
def validator(value: Any) -> Any:
cv.only_on_esp32(value)
if (version := idf_version()) < minimum:
raise cv.Invalid(
f"{feature} requires ESP-IDF {minimum} or newer, got {version}"
)
return value
return validator
def require_fatfs() -> None:
"""Mark that FATFS support is required by a component.
@@ -2,6 +2,7 @@ import hashlib
import logging
from pathlib import Path
from tempfile import TemporaryDirectory
import time
from typing import Any
import voluptuous as vol
@@ -83,6 +84,11 @@ CONFIG_SCHEMA = cv.All(
)
# The other build's rename finishes within milliseconds; a few short checks cover it
CACHE_RACE_RETRIES = 5
CACHE_RACE_RETRY_S = 0.05
def _check_firmware_hash(data: bytes, expected: str, source: str | Path) -> None:
calculated = hashlib.sha256(data).hexdigest()
if calculated != expected:
@@ -91,6 +97,14 @@ def _check_firmware_hash(data: bytes, expected: str, source: str | Path) -> None
)
def _cached_image_digest(target: Path) -> str | None:
"""The SHA256 of the cached image, or None while it is missing or another build still writes it."""
try:
return hashlib.sha256(target.read_bytes()).hexdigest()
except OSError:
return None
def _firmware_path(config: ConfigType) -> Path:
"""The configured image file, or the cache slot of the pinned digest for a downloaded one."""
if CONF_URL in config:
@@ -109,10 +123,9 @@ def _validate_firmware(config: ConfigType) -> None:
_check_firmware_hash(target.read_bytes(), expected, target)
return
# A pinned digest needs no remote freshness check.
if target.is_file():
actual = hashlib.sha256(target.read_bytes()).hexdigest()
if actual == expected:
return
if (actual := _cached_image_digest(target)) == expected:
return
if actual is not None:
# Named after its digest, so a mismatch means the file was damaged or edited
_LOGGER.warning(
"Cached firmware %s hashes to %s, downloading it again", target, actual
@@ -123,10 +136,20 @@ def _validate_firmware(config: ConfigType) -> None:
candidate = Path(temporary) / "firmware.bin"
data = external_files.download_content(url, candidate, allow_stale=False)
_check_firmware_hash(data, expected, url)
try:
candidate.replace(target)
except OSError as err:
raise cv.Invalid(f"Could not cache the firmware from {url}: {err}") from err
for attempt in range(CACHE_RACE_RETRIES + 1):
try:
candidate.replace(target)
return
except OSError as err:
# Windows refuses to replace a file another build holds at that moment; retry, or
# take that build's copy: named after its digest, a verified one is the same image
if _cached_image_digest(target) == expected:
return
if attempt == CACHE_RACE_RETRIES:
raise cv.Invalid(
f"Could not cache the firmware from {url}: {err}"
) from err
time.sleep(CACHE_RACE_RETRY_S)
FINAL_VALIDATE_SCHEMA = _validate_firmware
+4 -5
View File
@@ -362,7 +362,7 @@ def _validate(config: ConfigType) -> ConfigType:
from esphome.components.esp32 import (
VARIANT_ESP32S31,
get_esp32_variant,
idf_version,
require_idf_version,
)
eth_type = config[CONF_TYPE]
@@ -372,10 +372,9 @@ def _validate(config: ConfigType) -> ConfigType:
f"The '{eth_type}' (RGMII) PHY is only supported on gigabit-capable "
f"variants (ESP32-S31), not {variant}"
)
if idf_version() < cv.Version(6, 0, 0):
raise cv.Invalid(
f"The '{eth_type}' (RGMII) PHY requires ESP-IDF 6.0 or newer."
)
require_idf_version(cv.Version(6, 0, 0), f"The '{eth_type}' (RGMII) PHY")(
config
)
elif config[CONF_TYPE] != "OPENETH":
from esphome.components.esp32 import (
VARIANT_ESP32,
@@ -0,0 +1 @@
DOMAIN = "islandaire"
+27
View File
@@ -0,0 +1,27 @@
import esphome.codegen as cg
from esphome.components import climate_ir
from esphome.components.climate_ir import CONF_SUPPORTS_HEAT_COOL
import esphome.config_validation as cv
from esphome.const import CONF_SENSOR
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
CODEOWNERS = ["@ipopov"]
islandaire_ns = cg.esphome_ns.namespace("islandaire")
IslandaireClimate = islandaire_ns.class_("IslandaireClimate", climate_ir.ClimateIR)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(IslandaireClimate).extend(
{
# The unit has no auto (heat/cool) mode
cv.Optional(CONF_SUPPORTS_HEAT_COOL, default=False): cv.boolean_false,
# The unit works in Fahrenheit, so a Celsius temperature sensor cannot feed it
cv.Optional(CONF_SENSOR): cv.invalid(
"'sensor' is not supported by the Islandaire climate component"
),
}
)
async def to_code(config: ConfigType) -> None:
await climate_ir.new_climate_ir(config)
@@ -0,0 +1,199 @@
#include "islandaire.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::islandaire {
ESPHOME_LOG_TAG(TAG, "islandaire.climate");
// 14-byte frame, LSB first:
// Bytes 0-4: 0x22 preamble
// Byte 5: power (0x20 off, 0x24 on); bit 3 (0x08) set while the auto-off timer is active
// Byte 6: mode (0x01 heat, 0x03 cool, 0x04 fan only)
// Byte 7: target temperature in °F (61-88)
// Byte 8: fan speed (0x00 auto, 0x02 low, 0x04 high, 0x01 read as low too); bit 6 (0x40) set while the
// timer is active
// Byte 9: 0x00
// Byte 10: auto-off timer in hours (1-12, 0 = off)
// Bytes 11-12: 0x00
// Byte 13: sum of bytes 0-12
// The auto-off timer is not implemented: the unit switches itself off with no feedback, which
// would leave the climate state stale.
static constexpr uint8_t STATE_LENGTH = 14;
static constexpr uint8_t PREAMBLE_LENGTH = 5;
static constexpr uint8_t PREAMBLE = 0x22;
static constexpr uint8_t POWER_OFF = 0x20;
static constexpr uint8_t POWER_MASK = 0x04;
static constexpr uint8_t POWER_ON = POWER_OFF | POWER_MASK;
static constexpr uint8_t MODE_HEAT = 0x01;
static constexpr uint8_t MODE_COOL = 0x03;
static constexpr uint8_t MODE_FAN = 0x04;
static constexpr uint8_t FAN_AUTO = 0x00;
static constexpr uint8_t FAN_LOW_ALT = 0x01;
static constexpr uint8_t FAN_LOW = 0x02;
static constexpr uint8_t FAN_HIGH = 0x04;
static constexpr uint8_t FAN_TIMER_FLAG = 0x40;
static constexpr uint8_t TEMP_DEFAULT = 72;
static constexpr uint16_t HEADER_MARK = 3608;
static constexpr uint16_t HEADER_SPACE = 1520;
static constexpr uint16_t BIT_MARK = 560;
static constexpr uint16_t ONE_SPACE = 1208;
static constexpr uint16_t ZERO_SPACE = 408;
static constexpr uint32_t CARRIER_FREQUENCY = 38000;
static constexpr remote_base::BitTimings BIT_TIMINGS{
.bit_mark = BIT_MARK, .one_space = ONE_SPACE, .zero_space = ZERO_SPACE};
void IslandaireClimate::setup() {
ClimateIR::setup();
// The base class default assumes Celsius
if (std::isnan(this->target_temperature) || this->target_temperature < TEMP_MIN)
this->target_temperature = TEMP_DEFAULT;
this->target_temperature = std::min<float>(this->target_temperature, TEMP_MAX);
}
void IslandaireClimate::dump_config() {
// ClimateIR::dump_config() is not called: it prints the temperature range in Celsius
LOG_CLIMATE("", "Islandaire Climate", this);
ESP_LOGCONFIG(TAG, " Temperature range: %u to %u °F", TEMP_MIN, TEMP_MAX);
}
climate::ClimateTraits IslandaireClimate::traits() {
auto traits = ClimateIR::traits();
traits.set_temperature_unit(TemperatureUnit::FAHRENHEIT);
return traits;
}
void IslandaireClimate::transmit_state() {
uint8_t remote_state[STATE_LENGTH] = {0};
std::fill_n(remote_state, PREAMBLE_LENGTH, PREAMBLE);
remote_state[5] = this->mode == climate::CLIMATE_MODE_OFF ? POWER_OFF : POWER_ON;
switch (this->mode) {
case climate::CLIMATE_MODE_HEAT:
remote_state[6] = MODE_HEAT;
break;
case climate::CLIMATE_MODE_FAN_ONLY:
remote_state[6] = MODE_FAN;
break;
case climate::CLIMATE_MODE_COOL:
default:
remote_state[6] = MODE_COOL;
break;
}
// Whole degrees within the unit's range, stored as well so the published state matches what was sent
const auto temperature =
static_cast<uint8_t>(std::clamp<float>(std::round(this->target_temperature), TEMP_MIN, TEMP_MAX));
this->target_temperature = temperature;
remote_state[7] = temperature;
switch (this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO)) {
case climate::CLIMATE_FAN_LOW:
remote_state[8] = FAN_LOW;
break;
case climate::CLIMATE_FAN_HIGH:
remote_state[8] = FAN_HIGH;
break;
case climate::CLIMATE_FAN_AUTO:
default:
remote_state[8] = FAN_AUTO;
break;
}
remote_state[STATE_LENGTH - 1] = sum_bytes(remote_state, STATE_LENGTH - 1);
ESP_LOGV_HEX(TAG, remote_state, STATE_LENGTH, STATE_LENGTH, "Sending: %s");
auto transmit = this->transmitter_->transmit();
auto *data = transmit.get_data();
data->set_carrier_frequency(CARRIER_FREQUENCY);
data->reserve(2 + STATE_LENGTH * 16 + 1);
data->item(HEADER_MARK, HEADER_SPACE);
data->item_bytes<BIT_TIMINGS>(remote_state, STATE_LENGTH);
data->mark(BIT_MARK);
transmit.perform();
}
bool IslandaireClimate::on_receive(remote_base::RemoteReceiveData data) {
if (!data.expect_item(HEADER_MARK, HEADER_SPACE)) {
ESP_LOGVV(TAG, "Header fail");
return false;
}
uint8_t remote_state[STATE_LENGTH];
if (!data.expect_bytes<BIT_TIMINGS>(remote_state, STATE_LENGTH)) {
ESP_LOGVV(TAG, "Data fail");
return false;
}
if (!data.expect_mark(BIT_MARK)) {
ESP_LOGVV(TAG, "Footer fail");
return false;
}
if (sum_bytes(remote_state, STATE_LENGTH - 1) != remote_state[STATE_LENGTH - 1]) {
ESP_LOGVV(TAG, "Checksum fail");
return false;
}
for (uint8_t i = 0; i < PREAMBLE_LENGTH; i++) {
if (remote_state[i] != PREAMBLE)
return false;
}
ESP_LOGV_HEX(TAG, remote_state, STATE_LENGTH, STATE_LENGTH, "Received: %s");
if ((remote_state[5] & POWER_MASK) == 0) {
this->mode = climate::CLIMATE_MODE_OFF;
} else {
switch (remote_state[6]) {
case MODE_HEAT:
this->mode = climate::CLIMATE_MODE_HEAT;
break;
case MODE_COOL:
this->mode = climate::CLIMATE_MODE_COOL;
break;
case MODE_FAN:
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
break;
default:
ESP_LOGV(TAG, "Unknown mode 0x%02X", remote_state[6]);
return false;
}
}
const uint8_t temp_f = remote_state[7];
if (temp_f >= TEMP_MIN && temp_f <= TEMP_MAX)
this->target_temperature = temp_f;
switch (remote_state[8] & ~FAN_TIMER_FLAG) {
case FAN_HIGH:
this->fan_mode = climate::CLIMATE_FAN_HIGH;
break;
case FAN_LOW:
case FAN_LOW_ALT:
this->fan_mode = climate::CLIMATE_FAN_LOW;
break;
case FAN_AUTO:
default:
this->fan_mode = climate::CLIMATE_FAN_AUTO;
break;
}
this->publish_state();
return true;
}
} // namespace esphome::islandaire
@@ -0,0 +1,25 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
namespace esphome::islandaire {
class IslandaireClimate : public climate_ir::ClimateIR {
public:
// The unit works in whole degrees Fahrenheit (see traits())
static constexpr uint8_t TEMP_MIN = 61;
static constexpr uint8_t TEMP_MAX = 88;
IslandaireClimate()
: ClimateIR(TEMP_MIN, TEMP_MAX, 1.0f, false, true,
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_HIGH}, {}) {}
protected:
void setup() override;
void dump_config() override;
climate::ClimateTraits traits() override;
void transmit_state() override;
bool on_receive(remote_base::RemoteReceiveData data) override;
};
} // namespace esphome::islandaire
+6 -15
View File
@@ -285,12 +285,8 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
if (this->target_display_switch_ != nullptr) {
target_display_controlled = true;
// Nothing reports this switch back, so its restored state is the only state
// there is. Restoring through the switch keeps its inversion in the path:
// the restored value is logical, and driving the switch is what turns it
// into the raw command, the published state and the stream flag.
const bool state = this->target_display_switch_->get_initial_state_with_restore_mode().value_or(true);
this->target_display_switch_->control(state);
// Nothing reports this switch back, so its restored state is the only state there is.
this->target_display_switch_->restore(true);
}
#endif
if (!target_display_controlled) {
@@ -307,10 +303,7 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
if (this->point_cloud_switch_ != nullptr) {
point_cloud_controlled = true;
// The switch owns the stream, so it is also what applies the restored state:
// driving it rather than the module keeps the entity's inversion in the path.
const bool state = this->point_cloud_switch_->get_initial_state_with_restore_mode().value_or(false);
this->point_cloud_switch_->control(state);
this->point_cloud_switch_->restore(false);
}
#endif
if (!point_cloud_controlled) {
@@ -349,11 +342,9 @@ void LD6002BComponent::setup() {
#ifdef USE_SWITCH
bool want_low_power = this->low_power_switch_ != nullptr;
if (want_low_power) {
// The module reports this one back, so the query below confirms what it took.
// Driving the switch applies its inversion; it also marks the restored value
// as reported, so the work mode fallback runs on that until the query lands.
const bool state = this->low_power_switch_->get_initial_state_with_restore_mode().value_or(false);
this->low_power_switch_->control(state);
// The query below confirms what the module took; until it lands, the work mode
// fallback runs on the restored value.
this->low_power_switch_->restore(false);
}
#else
bool want_low_power = false;
+81 -8
View File
@@ -13,6 +13,7 @@ from esphome.const import (
CONF_COLD_WHITE_COLOR_TEMPERATURE,
CONF_COLOR_CORRECT,
CONF_DEFAULT_TRANSITION_LENGTH,
CONF_EFFECT,
CONF_EFFECTS,
CONF_ENTITY_CATEGORY,
CONF_FLASH_TRANSITION_LENGTH,
@@ -31,12 +32,21 @@ from esphome.const import (
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
CONF_RGB_ORDER,
CONF_STATE,
CONF_TRIGGER_ID,
CONF_WARM_WHITE_COLOR_TEMPERATURE,
CONF_WEB_SERVER,
)
from esphome.core import CORE, ID, CoroPriority, HexInt, coroutine_with_priority
from esphome.core import (
CORE,
ID,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
from esphome.core.entity_helpers import (
RESTORE_STATE_NONE,
entity_duplicate_validator,
queue_entity_register,
setup_entity,
@@ -56,7 +66,6 @@ from .effects import (
)
from .restore_state import (
LEGACY_RESTORE_MODES,
RESTORE_STATE_NONE,
RESTORE_STATE_SCHEMA,
_build_state_lambda,
_initial_state_overridden_by_legacy_mode,
@@ -191,9 +200,11 @@ def _get_or_create_gamma_table(gamma_correct: float, table_id: ID) -> cg.RawExpr
def find_effect_index(effects: list, effect_name: str) -> int | None:
"""Find the 1-based index of an effect by name (case-insensitive).
Returns the 1-based index if found, or None if not found.
Returns 0 for "None" (no effect), the 1-based index if found, or None if not found.
"""
effect_name_lower = effect_name.lower()
if effect_name_lower == "none":
return 0
for i, effect_conf in enumerate(effects):
key = next(iter(effect_conf))
if effect_conf[key][CONF_NAME].lower() == effect_name_lower:
@@ -209,6 +220,14 @@ def available_effects_str(effects: list) -> str:
return ", ".join(f"'{name}'" for name in available) if available else "none"
def effect_not_found_message(effect_name: str, effects: list, light: str) -> str:
"""Return the error for an effect name that `light` (e.g. "this light") lacks."""
return (
f"Effect '{effect_name}' not found for {light}. "
f"Available effects: {available_effects_str(effects)}"
)
# Accepted values of the deprecated `rgb_order` key.
RGB_ORDERS = ("RGB", "RBG", "GRB", "GBR", "BGR", "BRG")
@@ -316,9 +335,12 @@ def _final_validate(config: ConfigType) -> None:
platform entries across the file, not a single light's own config.
"""
for light_config in config:
initial_state = light_config.get(CONF_INITIAL_STATE) or {}
cold_boot_state = initial_state.get(CONF_STATE, False)
restore_mode = light_config.get(CONF_RESTORE_MODE)
if restore_mode is not None:
legacy = LEGACY_RESTORE_MODES[restore_mode]
cold_boot_state = legacy.cold_boot_state
if _initial_state_overridden_by_legacy_mode(
legacy, light_config.get(CONF_INITIAL_STATE)
):
@@ -330,6 +352,19 @@ def _final_validate(config: ConfigType) -> None:
restore_mode,
"ON" if legacy.cold_boot_state else "OFF",
)
effect_name = initial_state.get(CONF_EFFECT, "none")
if (
effect_name.lower() != "none"
and not cold_boot_state
and not light_config.get(CONF_RESUME_EFFECT)
):
_LOGGER.warning(
"[%s] 'initial_state: effect: %s' does not start at boot because the "
"light boots OFF; add 'resume_effect: true' to start it at the first "
"turn-on",
light_config.get(CONF_NAME) or light_config[CONF_ID],
effect_name,
)
data = _get_data()
if not data.effect_refs and not data.effect_cycle_refs:
@@ -355,9 +390,9 @@ def _final_validate(config: ConfigType) -> None:
if find_effect_index(effects, ref.effect_name) is None:
raise cv.FinalExternalInvalid(
f"Effect '{ref.effect_name}' not found for light "
f"'{ref.light_id}'. "
f"Available effects: {available_effects_str(effects)}",
effect_not_found_message(
ref.effect_name, effects, f"light '{ref.light_id}'"
),
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
@@ -385,6 +420,13 @@ FINAL_VALIDATE_SCHEMA = _final_validate
DEFAULT_FLASH_TRANSITION_LENGTH = "0s"
CONF_TRANSITION_STATE_PUBLISH_INTERVAL = "transition_state_publish_interval"
# `effect` stays out of LIGHT_STATE_SCHEMA: light.control defines it templatable and transformer-exclusive
INITIAL_STATE_SCHEMA = LIGHT_STATE_SCHEMA.extend(
{
cv.Optional(CONF_EFFECT): cv.string,
}
)
LIGHT_SCHEMA = (
cv.ENTITY_BASE_SCHEMA.extend(web_server.WEBSERVER_SORTING_SCHEMA)
.extend(cv.MQTT_COMMAND_COMPONENT_SCHEMA)
@@ -414,7 +456,7 @@ LIGHT_SCHEMA = (
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(LightStateTrigger),
}
),
cv.Optional(CONF_INITIAL_STATE): LIGHT_STATE_SCHEMA,
cv.Optional(CONF_INITIAL_STATE): INITIAL_STATE_SCHEMA,
cv.Optional(CONF_RESUME_EFFECT, default=False): cv.boolean,
}
)
@@ -422,6 +464,35 @@ LIGHT_SCHEMA = (
LIGHT_SCHEMA.add_extra(entity_duplicate_validator("light"))
def _validate_initial_state_effect(config: ConfigType) -> ConfigType:
"""Check that `initial_state: effect` names an effect of this light."""
initial_state = config.get(CONF_INITIAL_STATE) or {}
if (effect_name := initial_state.get(CONF_EFFECT)) is not None:
effects = config.get(CONF_EFFECTS, [])
if find_effect_index(effects, effect_name) is None:
raise cv.Invalid(
effect_not_found_message(effect_name, effects, "this light"),
path=[CONF_INITIAL_STATE, CONF_EFFECT],
)
return config
LIGHT_SCHEMA.add_extra(_validate_initial_state_effect)
def _initial_effect_index(config: ConfigType) -> int:
"""Resolve `initial_state: effect` to its effect index, 0 when not set."""
effect_name = (config.get(CONF_INITIAL_STATE) or {}).get(CONF_EFFECT, "none")
effects = config.get(CONF_EFFECTS, [])
if (index := find_effect_index(effects, effect_name)) is not None:
return index
# Safety net for a light whose schema skipped _validate_initial_state_effect
raise EsphomeError(
effect_not_found_message(effect_name, effects, f"light '{config[CONF_ID]}'")
)
BINARY_LIGHT_SCHEMA = LIGHT_SCHEMA.extend(
{
cv.Optional(CONF_EFFECTS): validate_effects(BINARY_EFFECTS),
@@ -557,7 +628,9 @@ async def setup_light_core_(light_var, config, output_var):
# ways to build the same state callback and save_enabled flag that LightState's
# runtime actually understands.
initial_state_config = config.get(CONF_INITIAL_STATE)
initial_statements = await _initial_state_statements(initial_state_config)
initial_statements = await _initial_state_statements(
initial_state_config, _initial_effect_index(config)
)
if config[CONF_RESUME_EFFECT]:
cg.add_define("USE_LIGHT_RESUME_EFFECT")
cg.add(light_var.set_resume_effect(True))
+2 -5
View File
@@ -250,10 +250,8 @@ def _resolve_effect_index(config: ConfigType, original_name: str) -> int:
Effect index 0 means "None" (no effect). Effects are 1-indexed matching
the C++ convention in LightState.
"""
from . import available_effects_str, find_effect_index
from . import effect_not_found_message, find_effect_index
if original_name.lower() == "none":
return 0
light_id = config[CONF_ID]
light_path = CORE.config.get_path_for_id(light_id)[:-1]
light_config = CORE.config.get_config_for_path(light_path)
@@ -264,8 +262,7 @@ def _resolve_effect_index(config: ConfigType, original_name: str) -> int:
# Should never reach here — effect names are validated during config
# validation in FINAL_VALIDATE_SCHEMA. This is a safety net.
raise EsphomeError(
f"Effect '{original_name}' not found for light '{light_id}'. "
f"Available effects: {available_effects_str(effects)}"
effect_not_found_message(original_name, effects, f"light '{light_id}'")
)
+5 -1
View File
@@ -83,9 +83,13 @@ void LightState::setup() {
call.set_color_temperature_if_supported(recovered.color_temp);
call.set_cold_white_if_supported(recovered.cold_white);
call.set_warm_white_if_supported(recovered.warm_white);
if (recovered.effect != 0) {
if (recovered.state && recovered.effect != 0) {
call.set_effect(recovered.effect);
} else {
#ifdef USE_LIGHT_RESUME_EFFECT
// An off light cannot start its effect now; keep it for the first turn-on instead
this->previous_effect_index_ = recovered.effect;
#endif // USE_LIGHT_RESUME_EFFECT
call.set_transition_length_if_supported(0);
}
call.perform();
+34 -33
View File
@@ -11,6 +11,14 @@ import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.const import CONF_COLOR_MODE, CONF_STATE
from esphome.core import Lambda
from esphome.core.entity_helpers import (
RESTORE_STATE_INITIAL,
RESTORE_STATE_INVERT,
RESTORE_STATE_KEEP,
RESTORE_STATE_NONE,
restore_state_keyword,
restore_state_schema,
)
from esphome.cpp_generator import call_lambda
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
@@ -18,12 +26,6 @@ from esphome.types import ConfigType
from .automation import LIGHT_STATE_FIELDS, validate_light_state
from .types import ColorMode, LightStateRTCState
RESTORE_STATE_KEEP = "KEEP"
RESTORE_STATE_INVERT = "INVERT"
RESTORE_STATE_INITIAL = "INITIAL"
RESTORE_STATE_ALL = "ALL"
RESTORE_STATE_NONE = "NONE"
@dataclass(frozen=True)
class LegacyRestoreMode:
@@ -56,7 +58,11 @@ _STATE_STRUCT_FIELDS: tuple[tuple[str, str], ...] = tuple(
)
# Canonical struct-member order, used only to make generated code deterministic --
# these are independent field assignments, so the actual order never affects behavior.
_MEMBER_ORDER: tuple[str, ...] = tuple(member for _, member in _ALL_STATE_FIELDS)
_MEMBER_ORDER: tuple[str, ...] = (
*(member for _, member in _ALL_STATE_FIELDS),
# Not a LightStateField: its value is a name looked up in this light's effects: list
"effect",
)
# A pending `s.<member> = <value>;` statement, tagged with the member it writes.
StateStatement = tuple[str, str]
@@ -170,12 +176,13 @@ async def _process_value(value: Any, member: str) -> str:
async def _initial_state_statements(
initial_state_config: ConfigType | None,
initial_state_config: ConfigType | None, effect_index: int = 0
) -> list[StateStatement]:
"""
Create assignments for every field the user set in `initial_state:`, in canonical
struct-member order. A field given as `!lambda` is resolved and called immediately,
the same way `light.control`'s own field lambdas are.
the same way `light.control`'s own field lambdas are. `effect_index` is the resolved
`initial_state: effect`; 0 (no effect) is the struct default and emits nothing.
"""
if not initial_state_config:
return []
@@ -188,6 +195,8 @@ async def _initial_state_statements(
)
if (inferred := _inferred_color_mode(initial_state_config)) is not None:
statements.append(("color_mode", f"s.color_mode = {inferred};"))
if effect_index:
statements.append(("effect", f"s.effect = {effect_index};"))
return statements
@@ -290,10 +299,12 @@ def _keep_or(validator: Callable[[Any], Any]) -> Callable[[Any], Any]:
except cv.Invalid:
inner = ()
return (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL, *inner)
if isinstance(value, str):
upper = value.strip().upper()
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL):
return upper
if (
keyword := restore_state_keyword(
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INITIAL)
)
) is not None:
return keyword
return validator(value)
return validate
@@ -308,10 +319,12 @@ def _validate_restore_state_state(value: Any) -> str | bool:
RESTORE_STATE_INITIAL,
*validate_light_state(SCHEMA_EXTRACT),
)
if isinstance(value, str):
upper = value.strip().upper()
if upper in (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL):
return upper
if (
keyword := restore_state_keyword(
value, (RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL)
)
) is not None:
return keyword
return validate_light_state(value)
@@ -327,19 +340,7 @@ _RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
)
@schema_extractor("schema")
def RESTORE_STATE_SCHEMA(value: Any) -> ConfigType | str:
"""
The restore_state: config key can be a mapping of per-field overrides, `all`, or `none`.
"""
if value == SCHEMA_EXTRACT:
# The `all`/`none` string shorthands have no representation in the extracted
# docs schema; only the per-field mapping form is walked here.
return _RESTORE_STATE_FIELDS_SCHEMA
if isinstance(value, str):
upper = value.strip().upper()
if upper == RESTORE_STATE_ALL:
value = {}
elif upper == RESTORE_STATE_NONE:
return RESTORE_STATE_NONE
return _RESTORE_STATE_FIELDS_SCHEMA(value)
# A mapping of per-field overrides, `all`, or `none`, which stays the keyword for to_code
RESTORE_STATE_SCHEMA = restore_state_schema(
_RESTORE_STATE_FIELDS_SCHEMA, RESTORE_STATE_NONE
)
@@ -0,0 +1 @@
DOMAIN = "ln882x_spi_led_strip"
@@ -0,0 +1,210 @@
#include "led_strip.h"
#ifdef USE_LN882X
#include <cinttypes>
#include <cstring>
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
extern "C" {
// Skip the SDK register headers: their unused *_unpack inline functions copy volatile unions, which C++ rejects.
// Same as LibreTiny's sdk_private.h; the HAL headers below use nothing from them.
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
#define __REG_DMA_H__
#define __REG_GPIO_H__
#define __REG_SPI_H__
#define __REG_SYSC_AWO_H__
#define __REG_SYSC_CMP_H__
// NOLINTEND(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp)
#include "hal/hal_clock.h"
#include "hal/hal_dma.h"
#include "hal/hal_gpio.h"
#include "hal/hal_spi.h"
}
namespace esphome::ln882x_spi_led_strip {
ESPHOME_LOG_TAG(TAG, "ln882x_spi_led_strip");
// light.py derives the SPI encoding from this APB0 clock, which the SDK's SystemInit() sets.
static constexpr uint32_t APB0_HZ = 40000000;
// Each LED data bit is sent as one SPI byte, MSB first: the leading 1-bits are the high time.
static constexpr uint8_t SPI_BITS_PER_LED_BIT = 8;
// SPI0 TX is hard-wired to DMA channel 4 (see hal_dma.h).
static constexpr uint32_t LED_STRIP_DMA_CH = DMA_CH_4;
static uint8_t spi_pattern(uint8_t high_bits) { return uint8_t(0xFF00 >> high_bits); }
static void spi_dma_init(uint8_t pin, uint8_t prescaler) {
// LibreTiny numbers pins as (port << 4) | index. Every pin the LN882x boards expose
// (PA0-PA12, PB3-PB9) can be muxed to SPI0 MOSI.
const uint32_t gpio_base = (pin >> 4) ? GPIOB_BASE : GPIOA_BASE;
const auto gpio_pin = static_cast<gpio_pin_t>(1U << (pin & 0xF));
hal_gpio_pin_afio_select(gpio_base, gpio_pin, SPI0_MOSI);
hal_gpio_pin_afio_en(gpio_base, gpio_pin, HAL_ENABLE);
// Only MOSI is routed to a pin; NSS is left unrouted with its output disabled.
spi_init_type_def spi_init{};
spi_init.spi_direction = SPI_DIRECTION_2LINES_FULLDUPLEX;
spi_init.spi_mode = SPI_MODE_MASTER;
spi_init.spi_data_size = SPI_DATASIZE_8B;
spi_init.spi_cpol = SPI_CPOL_LOW;
spi_init.spi_cpha = SPI_CPHA_1EDGE;
spi_init.spi_nss_mode = SPI_NSS_HARD;
spi_init.spi_baud_rate_prescaler = static_cast<spi_baud_rate_precaler_t>(prescaler);
spi_init.spi_first_bit = SPI_FIRST_BIT_MSB;
hal_spi_init(SPI0_BASE, &spi_init);
hal_spi_en(SPI0_BASE, HAL_ENABLE);
hal_spi_ssoe_en(SPI0_BASE, HAL_DISABLE);
dma_init_t_def dma_init{};
dma_init.dma_mem_to_mem_en = DMA_MEM_TO_MEM_DIS;
dma_init.dma_pri_lev = DMA_PRI_LEV_HIGH;
dma_init.dma_mem_size = DMA_MEM_SIZE_8_BIT;
dma_init.dma_p_size = DMA_P_SIZE_8_BIT;
dma_init.dma_mem_inc_en = DMA_MEM_INC_EN;
dma_init.dma_p_inc_en = DMA_P_INC_DIS;
dma_init.dma_circ_mode_en = DMA_CIRC_MODE_DIS;
dma_init.dma_dir = DMA_READ_FORM_MEM;
dma_init.dma_p_addr = SPI0_DATA_REG;
hal_dma_init(LED_STRIP_DMA_CH, &dma_init);
hal_dma_en(LED_STRIP_DMA_CH, HAL_DISABLE);
}
static void spi_dma_transmit(const uint8_t *data, uint16_t len) {
hal_dma_en(LED_STRIP_DMA_CH, HAL_DISABLE);
hal_spi_dma_en(SPI0_BASE, SPI_DMA_TX_EN, HAL_DISABLE);
hal_dma_set_mem_addr(LED_STRIP_DMA_CH, reinterpret_cast<uint32_t>(data));
hal_dma_set_data_num(LED_STRIP_DMA_CH, len);
hal_spi_dma_en(SPI0_BASE, SPI_DMA_TX_EN, HAL_ENABLE);
hal_dma_en(LED_STRIP_DMA_CH, HAL_ENABLE);
}
// The count reaches zero once the last byte is in the SPI data register. The tail of every
// transfer is the reset gap, so starting the next one while that byte shifts out is harmless.
static bool spi_dma_busy() { return hal_dma_get_data_num(LED_STRIP_DMA_CH) != 0; }
LN882xSPILEDStripLightOutput::LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t num_leds,
light::ChannelColors channel_colors, uint8_t prescaler,
uint8_t bit0_high_bits, uint8_t bit1_high_bits,
uint16_t reset_bytes)
: color_data_bytes_(num_leds * channel_colors.bytes_per_led()),
// The tail of the DMA buffer stays zero: it is the reset gap.
dma_data_bytes_(color_data_bytes_ * SPI_BITS_PER_LED_BIT + reset_bytes),
channel_colors_(channel_colors),
num_leds_(num_leds),
pin_(pin),
prescaler_(prescaler),
bit0_high_bits_(bit0_high_bits),
bit1_high_bits_(bit1_high_bits) {}
void LN882xSPILEDStripLightOutput::setup() {
const uint32_t apb0_hz = hal_clock_get_apb0_clk();
if (apb0_hz != APB0_HZ) {
ESP_LOGE(TAG, "APB0 clock is %" PRIu32 " Hz", apb0_hz);
this->mark_failed(LOG_STR("APB0 clock is not 40 MHz"));
return;
}
// One block for the color, effect and DMA data.
RAMAllocator<uint8_t> allocator;
uint8_t *buffer = allocator.allocate(this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_);
if (buffer == nullptr) {
this->mark_failed(LOG_STR("Allocation error"));
return;
}
memset(buffer, 0, this->color_data_bytes_ + this->num_leds_ + this->dma_data_bytes_);
this->color_data_ = buffer;
this->effect_data_ = buffer + this->color_data_bytes_;
this->dma_data_ = this->effect_data_ + this->num_leds_;
spi_dma_init(this->pin_, this->prescaler_);
}
void LN882xSPILEDStripLightOutput::dump_config() {
const uint32_t spi_bit_ns = (1000000000 / APB0_HZ) << this->prescaler_;
char channel_colors[5];
ESP_LOGCONFIG(TAG,
"LN882x SPI LED Strip:\n"
" Pin: %u\n"
" Channel colors: %s\n"
" Number of LEDs: %" PRIu16 "\n"
" Max refresh rate: %" PRIu32 " us\n"
" SPI frequency: %" PRIu32 " Hz\n"
" Bit 0 / bit 1 high: %" PRIu32 " / %" PRIu32 " ns of %" PRIu32 " ns",
this->pin_, this->channel_colors_.to_string(channel_colors), this->num_leds_, this->max_refresh_rate_,
APB0_HZ >> this->prescaler_, this->bit0_high_bits_ * spi_bit_ns, this->bit1_high_bits_ * spi_bit_ns,
SPI_BITS_PER_LED_BIT * spi_bit_ns);
}
float LN882xSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
light::LightTraits LN882xSPILEDStripLightOutput::get_traits() {
auto traits = light::LightTraits();
if (this->channel_colors_.has_white()) {
traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE});
} else {
traits.set_supported_color_modes({light::ColorMode::RGB});
}
return traits;
}
void LN882xSPILEDStripLightOutput::clear_effect_data() {
if (this->effect_data_ != nullptr)
memset(this->effect_data_, 0, this->num_leds_);
}
void LN882xSPILEDStripLightOutput::write_state(light::LightState *state) {
if (this->is_failed())
return;
// protect from refreshing too often
const uint32_t now = micros();
if (this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < this->max_refresh_rate_) {
// try again next loop iteration, so that this change won't get lost
this->schedule_show();
return;
}
if (spi_dma_busy()) {
// Busy sending the last frame, try again later.
this->schedule_show();
return;
}
this->last_refresh_ = now;
this->mark_shown_();
const uint8_t bit0 = spi_pattern(this->bit0_high_bits_);
const uint8_t bit1 = spi_pattern(this->bit1_high_bits_);
const uint8_t *color_data = this->color_data_;
uint8_t *dma_data = this->dma_data_;
for (size_t i = 0; i < this->color_data_bytes_; i++) {
const uint8_t color_byte = *(color_data++);
for (uint8_t mask = 0x80; mask != 0; mask >>= 1) {
*(dma_data++) = (color_byte & mask) ? bit1 : bit0;
}
}
spi_dma_transmit(this->dma_data_, this->dma_data_bytes_);
}
light::ESPColorView LN882xSPILEDStripLightOutput::get_view_internal(int32_t index) const {
const light::ChannelColors &colors = this->channel_colors_;
// After a failed setup() effects and partitions still write pixels, so they get a scratch pixel.
static uint8_t scratch[5];
const bool ready = this->color_data_ != nullptr;
uint8_t *led = ready ? this->color_data_ + (index * colors.bytes_per_led()) : scratch;
return {led + colors.r,
led + colors.g,
led + colors.b,
colors.has_white() ? led + colors.w : nullptr,
ready ? &this->effect_data_[index] : scratch + 4,
&this->correction_};
}
} // namespace esphome::ln882x_spi_led_strip
#endif // USE_LN882X
@@ -0,0 +1,55 @@
#pragma once
#ifdef USE_LN882X
#include <cstddef>
#include <cstdint>
#include "esphome/components/light/addressable_light.h"
#include "esphome/components/light/channel_colors.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
namespace esphome::ln882x_spi_led_strip {
class LN882xSPILEDStripLightOutput final : public light::AddressableLight {
public:
/// The SPI encoding comes from light.py: SPI clock APB0 / 2^prescaler, the number of high SPI bits for a
/// 0 and a 1 bit, and the zero bytes of the reset gap.
LN882xSPILEDStripLightOutput(uint8_t pin, uint16_t num_leds, light::ChannelColors channel_colors, uint8_t prescaler,
uint8_t bit0_high_bits, uint8_t bit1_high_bits, uint16_t reset_bytes);
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
light::LightTraits get_traits() override;
int32_t size() const override { return this->num_leds_; }
void clear_effect_data() override;
void write_state(light::LightState *state) override;
/// Set a maximum refresh rate in µs as some lights do not like being updated too often.
void set_max_refresh_rate(uint32_t interval_us) { this->max_refresh_rate_ = interval_us; }
protected:
light::ESPColorView get_view_internal(int32_t index) const override;
const size_t color_data_bytes_;
const size_t dma_data_bytes_;
uint8_t *color_data_{nullptr};
uint8_t *effect_data_{nullptr};
uint8_t *dma_data_{nullptr};
uint32_t last_refresh_{0};
uint32_t max_refresh_rate_{0};
const light::ChannelColors channel_colors_;
const uint16_t num_leds_;
const uint8_t pin_;
const uint8_t prescaler_;
const uint8_t bit0_high_bits_;
const uint8_t bit1_high_bits_;
};
} // namespace esphome::ln882x_spi_led_strip
#endif // USE_LN882X
@@ -0,0 +1,156 @@
from dataclasses import dataclass
from esphome import pins
import esphome.codegen as cg
from esphome.components import light
from esphome.components.const import CONF_CHANNEL_COLORS
import esphome.config_validation as cv
from esphome.const import (
CONF_CHIPSET,
CONF_MAX_REFRESH_RATE,
CONF_NUM_LEDS,
CONF_OUTPUT_ID,
CONF_PIN,
CONF_PLATFORM,
)
import esphome.final_validate as fv
from esphome.types import ConfigType
from . import DOMAIN
CODEOWNERS = ["@sebo-b"]
DEPENDENCIES = ["ln882x"]
ln882x_spi_led_strip_ns = cg.esphome_ns.namespace("ln882x_spi_led_strip")
LN882xSPILEDStripLightOutput = ln882x_spi_led_strip_ns.class_(
"LN882xSPILEDStripLightOutput", light.AddressableLight
)
@dataclass(frozen=True)
class LEDStripTimings:
bit0_high: int # ns
bit0_low: int
bit1_high: int
bit1_low: int
# As esp32_rmt_led_strip.
CHIPSETS = {
"WS2811": LEDStripTimings(300, 1090, 1090, 320),
"WS2812": LEDStripTimings(400, 1000, 1000, 400),
"SK6812": LEDStripTimings(300, 900, 600, 600),
"APA106": LEDStripTimings(350, 1360, 1360, 350),
"SM16703": LEDStripTimings(300, 900, 900, 300),
}
# SystemInit() in the SDK sets APB0 to 40 MHz; led_strip.cpp checks it at boot.
APB0_HZ = 40_000_000
# SPI0 runs at APB0 / 2^prescaler, prescaler 1 (/2) to 8 (/256).
SPI_PRESCALER_MIN = 1
SPI_PRESCALER_MAX = 8
# Each LED data bit is sent as one SPI byte, MSB first: the leading 1-bits are the high time.
SPI_BITS_PER_LED_BIT = 8
# At most this many high bits, so every LED bit ends with at least two low SPI bits.
SPI_HIGH_BITS_MAX = SPI_BITS_PER_LED_BIT - 2
# Low time after the data that latches it. Minimum 50 us for WS2811, 80 us for SK6812, 280 us for WS2812B-V5.
RESET_DURATION_NS = 300_000
# One DMA transfer moves at most this many bytes: the whole frame plus the reset gap.
DMA_MAX_TRANSFER = 0xFFFF
@dataclass(frozen=True)
class SPIEncoding:
prescaler: int
bit0_high_bits: int
bit1_high_bits: int
reset_bytes: int
def _spi_bit_ns(prescaler: int) -> int:
return (1_000_000_000 << prescaler) // APB0_HZ
def spi_encoding(timings: LEDStripTimings) -> SPIEncoding:
"""SPI clock and bit patterns that reproduce the chipset timings."""
# The prescaler whose 8-bit frame is closest to the bit period. Each step doubles the frame,
# so step on while the period is at least 1.5 frames (a tie takes the longer frame).
bit_period_ns = max(
timings.bit0_high + timings.bit0_low, timings.bit1_high + timings.bit1_low
)
prescaler = SPI_PRESCALER_MIN
while (
prescaler < SPI_PRESCALER_MAX
and 3 * SPI_BITS_PER_LED_BIT * _spi_bit_ns(prescaler) <= 2 * bit_period_ns
):
prescaler += 1
bit_ns = _spi_bit_ns(prescaler)
def high_bits(duration_ns: int, max_bits: int) -> int:
# Rounded half up, from 1 to max_bits.
return max(1, min(max_bits, (2 * duration_ns + bit_ns) // (2 * bit_ns)))
# Bit 0 stops one below the maximum, so bit 1 can always be longer.
bit0_high_bits = high_bits(timings.bit0_high, SPI_HIGH_BITS_MAX - 1)
bit1_high_bits = max(
high_bits(timings.bit1_high, SPI_HIGH_BITS_MAX), bit0_high_bits + 1
)
reset_bytes = -(-RESET_DURATION_NS // (SPI_BITS_PER_LED_BIT * bit_ns))
return SPIEncoding(prescaler, bit0_high_bits, bit1_high_bits, reset_bytes)
def _validate_num_leds(config: ConfigType) -> ConfigType:
reset_bytes = spi_encoding(CHIPSETS[config[CONF_CHIPSET]]).reset_bytes
bytes_per_led = len(config[CONF_CHANNEL_COLORS])
max_num_leds = (
(DMA_MAX_TRANSFER - reset_bytes) // SPI_BITS_PER_LED_BIT // bytes_per_led
)
if config[CONF_NUM_LEDS] > max_num_leds:
raise cv.Invalid(
f"The maximum number of LEDs for this configuration is {max_num_leds}.",
path=[CONF_NUM_LEDS],
)
return config
CONFIG_SCHEMA = cv.All(
light.ADDRESSABLE_LIGHT_SCHEMA.extend(
{
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(LN882xSPILEDStripLightOutput),
cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number,
cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int,
cv.Required(CONF_CHANNEL_COLORS): light.validate_channel_colors,
cv.Required(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True),
cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds,
}
),
_validate_num_leds,
)
def _final_validate(config: ConfigType) -> None:
# The strip owns SPI0 and its DMA channel, so only one can exist.
lights = fv.full_config.get().get(light.DOMAIN, [])
if sum(entry.get(CONF_PLATFORM) == DOMAIN for entry in lights) > 1:
raise cv.Invalid(f"Only one {DOMAIN} is supported (it uses SPI0)")
FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
encoding = spi_encoding(CHIPSETS[config[CONF_CHIPSET]])
var = cg.new_Pvariable(
config[CONF_OUTPUT_ID],
config[CONF_PIN],
config[CONF_NUM_LEDS],
light.channel_colors_struct(config[CONF_CHANNEL_COLORS]),
encoding.prescaler,
encoding.bit0_high_bits,
encoding.bit1_high_bits,
encoding.reset_bytes,
)
await light.register_light(var, config)
await cg.register_component(var, config)
cg.add_optional(var.set_max_refresh_rate, config.get(CONF_MAX_REFRESH_RATE))
+14 -10
View File
@@ -35,12 +35,12 @@ class LockTraits {
public:
LockTraits() = default;
bool get_supports_open() const { return this->supports_open_; }
void set_supports_open(bool supports_open) { this->supports_open_ = supports_open; }
bool get_requires_code() const { return this->requires_code_; }
void set_requires_code(bool requires_code) { this->requires_code_ = requires_code; }
bool get_assumed_state() const { return this->assumed_state_; }
void set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; }
bool get_supports_open() const { return this->flags_.supports_open; }
void set_supports_open(bool supports_open) { this->flags_.supports_open = supports_open; }
bool get_requires_code() const { return this->flags_.requires_code; }
void set_requires_code(bool requires_code) { this->flags_.requires_code = requires_code; }
bool get_assumed_state() const { return this->flags_.assumed_state; }
void set_assumed_state(bool assumed_state) { this->flags_.assumed_state = assumed_state; }
bool supports_state(LockState state) const { return supported_states_mask_ & (1 << state); }
void set_supported_states(std::initializer_list<LockState> states) {
@@ -54,9 +54,13 @@ class LockTraits {
void add_supported_state(LockState state) { supported_states_mask_ |= (1 << state); }
protected:
bool supports_open_{false};
bool requires_code_{false};
bool assumed_state_{false};
// Bit-packed flags to save memory (1 byte instead of 3)
struct LockTraitFlags {
uint8_t supports_open : 1;
uint8_t requires_code : 1;
uint8_t assumed_state : 1;
uint8_t reserved : 5;
} flags_{};
uint8_t supported_states_mask_{(1 << LOCK_STATE_NONE) | (1 << LOCK_STATE_LOCKED) | (1 << LOCK_STATE_UNLOCKED)};
};
@@ -176,8 +180,8 @@ class Lock : public EntityBase {
*/
virtual void control(const LockCall &call) = 0;
Deduplicator<LockState> publish_dedup_; // packs with state and traits ahead of the pointer members
LazyCallbackManager<void(LockState)> state_callback_{};
Deduplicator<LockState> publish_dedup_;
ESPPreferenceObject rtc_;
};
@@ -0,0 +1,29 @@
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.types import ConfigType
CODEOWNERS = ["@KoenBreeman"]
DEPENDENCIES = ["i2c"]
DOMAIN = "m5stack_4relay"
MULTI_CONF = True
CONF_M5STACK_4RELAY_ID = "m5stack_4relay_id"
m5stack_4relay_ns = cg.esphome_ns.namespace("m5stack_4relay")
M5Stack4Relay = m5stack_4relay_ns.class_("M5Stack4Relay", cg.Component, i2c.I2CDevice)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(M5Stack4Relay),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x26))
)
async def to_code(config: ConfigType) -> None:
await i2c.new_i2c_component(config)
@@ -0,0 +1,50 @@
#include "m5stack_4relay.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::m5stack_4relay {
ESPHOME_LOG_TAG(TAG, "m5stack_4relay");
static constexpr uint8_t REG_MODE = 0x10;
static constexpr uint8_t REG_RELAY = 0x11;
static constexpr uint8_t MODE_SYNC = 1;
void M5Stack4Relay::setup() {
if (!this->write_byte(REG_MODE, MODE_SYNC) || !this->read_byte(REG_RELAY, &this->relays_)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
#ifdef USE_SWITCH
for (size_t channel = 0; channel < this->switches_.size(); channel++) {
switch_::Switch *sw = this->switches_[channel];
if (sw == nullptr)
continue;
// The chip keeps the relays across an ESP reboot, so a switch with restore mode DISABLED keeps what it
// holds. Every mode goes through control(), so inverted: applies and the first publish is the saved one.
sw->restore(((this->relays_ >> channel) & 1) != sw->is_inverted());
}
#endif
}
void M5Stack4Relay::dump_config() {
ESP_LOGCONFIG(TAG, "M5Stack 4 Relay:");
LOG_I2C_DEVICE(this);
}
bool M5Stack4Relay::set_relay(uint8_t channel, bool on) {
if (this->is_failed())
return false;
uint8_t value = this->relays_;
assign_bits(value, 1 << channel, on);
if (!this->status_comm_result(this->write_byte(REG_RELAY, value)))
return false;
this->relays_ = value;
#ifdef USE_SWITCH
if (this->switches_[channel] != nullptr)
this->switches_[channel]->publish_state(on);
#endif
return true;
}
} // namespace esphome::m5stack_4relay
@@ -0,0 +1,34 @@
#pragma once
#include <array>
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
namespace esphome::m5stack_4relay {
class M5Stack4Relay : public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
/// Switch relay `channel` (0-3) on or off; returns false when the write failed.
bool set_relay(uint8_t channel, bool on);
#ifdef USE_SWITCH
void register_switch(uint8_t channel, switch_::Switch *sw) { this->switches_[channel] = sw; }
#endif
protected:
uint8_t relays_{0};
#ifdef USE_SWITCH
std::array<switch_::Switch *, 4> switches_{};
#endif
};
} // namespace esphome::m5stack_4relay
@@ -0,0 +1,50 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_PLATFORM
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import CONF_M5STACK_4RELAY_ID, DOMAIN, M5Stack4Relay
DEPENDENCIES = ["m5stack_4relay"]
CONFIG_SCHEMA = switch.switch_schema(
switch.ApplySwitch, default_restore_mode="DISABLED"
).extend(
{
cv.GenerateID(CONF_M5STACK_4RELAY_ID): cv.use_id(M5Stack4Relay),
cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4),
}
)
def _validate_unique_channel(config: ConfigType) -> ConfigType:
# The hub has one slot per channel, so a second switch on it would take over the first
hub, channel = config[CONF_M5STACK_4RELAY_ID], config[CONF_CHANNEL]
users = [
other
for other in fv.full_config.get().get(switch.DOMAIN, [])
if other.get(CONF_PLATFORM) == DOMAIN
and other[CONF_M5STACK_4RELAY_ID] == hub
and other[CONF_CHANNEL] == channel
]
if len(users) > 1:
raise cv.Invalid(
f"Channel {channel} of {hub} is used by more than one switch",
path=[CONF_CHANNEL],
)
return config
FINAL_VALIDATE_SCHEMA = _validate_unique_channel
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_M5STACK_4RELAY_ID])
channel = config[CONF_CHANNEL] - 1
await switch.sub_switches(config, parent=hub).create(
config,
lambda sw: hub.register_switch(channel, sw),
apply=f"set_relay({channel}, state)",
)
@@ -12,13 +12,7 @@ ESPHOME_LOG_TAG(TAG, "modbus_controller.switch");
// Maximum bytes to log in verbose hex output
static constexpr size_t MODBUS_SWITCH_MAX_LOG_BYTES = 64;
void ModbusSwitch::setup() {
optional<bool> initial_state = Switch::get_initial_state_with_restore_mode();
if (initial_state.has_value()) {
// if it has a value, restore_mode is not "DISABLED", therefore act on the switch:
this->control(initial_state.value());
}
}
void ModbusSwitch::setup() { this->restore(); }
void ModbusSwitch::dump_config() { LOG_SWITCH(TAG, "Modbus Controller Switch", this); }
bool ModbusSwitch::assumed_state() { return this->assumed_state_; }
+4
View File
@@ -50,6 +50,10 @@ void MQTTCoverComponent::setup() {
}
if (traits.get_supports_tilt()) {
this->subscribe(this->get_tilt_command_topic(), [this](const std::string &topic, const std::string &payload) {
if (ESPHOME_strcasecmp_P(payload.c_str(), ESPHOME_PSTR("STOP")) == 0) {
this->cover_->make_call().set_command_stop_tilt().perform();
return;
}
auto value = parse_number<float>(payload);
if (!value.has_value()) {
ESP_LOGW(TAG, "Invalid tilt value: '%s'", payload.c_str());
@@ -1,5 +1,6 @@
#include "download_buffer.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
namespace esphome::online_image {
@@ -34,6 +35,46 @@ size_t DownloadBuffer::read(size_t len) {
return this->unread_;
}
void DownloadSource::compact() {
if (this->keep_read_ || this->pos_ == this->start_)
return;
this->buffer_.read(this->pos_ - this->start_);
this->start_ = this->pos_;
}
int32_t DownloadSource::read(uint8_t *buf, uint32_t len) {
const size_t offset = this->pos_ - this->start_;
const size_t n = std::min<size_t>(len, this->buffer_.unread() - offset);
memcpy(buf, this->buffer_.data(offset), n);
this->pos_ += n;
return static_cast<int32_t>(n);
}
bool DownloadSource::seek(uint32_t position) {
// Only what is still in the buffer can be read again; a decoder never skips ahead of the download
if (position < this->start_ || position > this->start_ + this->buffer_.unread())
return false;
this->pos_ = position;
return true;
}
bool DownloadBuffer::grow(size_t size) {
if (this->size_ >= size)
return true;
RAMAllocator<uint8_t> allocator;
uint8_t *buffer = allocator.allocate(size);
if (buffer == nullptr) {
ESP_LOGE(TAG, "allocation of %zu bytes failed. Biggest block in heap: %zu Bytes", size,
allocator.get_max_free_block_size());
return false;
}
memcpy(buffer, this->buffer_, this->unread_);
allocator.deallocate(this->buffer_, this->size_);
this->buffer_ = buffer;
this->size_ = size;
return true;
}
size_t DownloadBuffer::resize(size_t size) {
if (this->size_ >= size) {
// Avoid useless reallocations; if the buffer is big enough, don't reallocate.
@@ -1,5 +1,6 @@
#pragma once
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/helpers.h"
#include <cstddef>
#include <cstdint>
@@ -35,6 +36,8 @@ class DownloadBuffer {
void reset() { this->unread_ = 0; }
size_t resize(size_t size);
/// Make the buffer at least size bytes, keeping the unread data. Returns false if the allocation failed.
bool grow(size_t size);
protected:
uint8_t *buffer_;
@@ -43,4 +46,35 @@ class DownloadBuffer {
size_t unread_;
};
/**
* @brief Lets a decoder read a download as it arrives, from the download buffer.
*
* The decoder reads at its own position in the buffer. Bytes it has read stay there until compact() drops
* them, and compact() keeps everything while set_keep_read(true) is in effect, so the decoder can seek back
* while it reads the headers.
*/
class DownloadSource : public runtime_image::DecodeSource {
public:
explicit DownloadSource(DownloadBuffer &buffer) : buffer_(buffer) {}
/// Start again at the beginning of a new download, keeping what is read
void reset() {
this->start_ = 0;
this->pos_ = 0;
this->keep_read_ = true;
}
void set_keep_read(bool keep) { this->keep_read_ = keep; }
/// Drop the bytes the decoder has read, to make room for more of the download
void compact();
int32_t read(uint8_t *buf, uint32_t len) override;
bool seek(uint32_t position) override;
protected:
DownloadBuffer &buffer_;
size_t start_{0}; // Position in the download of the first byte in the buffer
size_t pos_{0}; // Position in the download the decoder reads next
bool keep_read_{true};
};
} // namespace esphome::online_image
+12
View File
@@ -22,6 +22,8 @@ DEPENDENCIES = ["http_request"]
CODEOWNERS = ["@guillempages", "@clydebarrow"]
CONF_ON_DOWNLOAD_FINISHED = "on_download_finished"
CONF_ON_DOWNLOAD_STARTED = "on_download_started"
CONF_ON_DOWNLOAD_PROGRESS = "on_download_progress"
CONF_UPDATE = "update"
online_image_ns = cg.esphome_ns.namespace("online_image")
@@ -43,7 +45,9 @@ ONLINE_IMAGE_SCHEMA = (
cv.Optional(CONF_REQUEST_HEADERS): cv.All(
cv.Schema({cv.string: cv.templatable(cv.string)})
),
cv.Optional(CONF_ON_DOWNLOAD_STARTED): automation.validate_automation({}),
cv.Optional(CONF_ON_DOWNLOAD_FINISHED): automation.validate_automation({}),
cv.Optional(CONF_ON_DOWNLOAD_PROGRESS): automation.validate_automation({}),
cv.Optional(CONF_ON_ERROR): automation.validate_automation({}),
}
)
@@ -96,9 +100,17 @@ automation.register_apply_action(
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_STARTED, "add_on_download_started_callback"
),
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_FINISHED, "add_on_finished_callback", [(bool, "cached")]
),
automation.CallbackAutomation(
CONF_ON_DOWNLOAD_PROGRESS,
"add_on_progress_callback",
[(float, "progress"), (cg.size_t, "bytes_read")],
),
automation.CallbackAutomation(CONF_ON_ERROR, "add_on_error_callback"),
)
+144 -10
View File
@@ -4,6 +4,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
ESPHOME_LOG_TAG(TAG, "online_image");
@@ -15,6 +16,11 @@ static const char *const IF_MODIFIED_SINCE_HEADER_NAME = "if-modified-since";
namespace esphome::online_image {
// Time one loop pass may spend reading, and decoding a JPEG as it downloads
static constexpr uint32_t PASS_BUDGET_MS = 20;
// Shortest time between two on_download_progress calls
static constexpr uint32_t PROGRESS_INTERVAL_MS = 100;
OnlineImage::OnlineImage(const std::string &url, int width, int height, runtime_image::ImageFormat format,
image::ImageType type, image::Transparency transparency, image::Image *placeholder,
uint32_t buffer_size, bool is_big_endian)
@@ -140,8 +146,12 @@ void OnlineImage::update() {
return;
}
// JPEG requires the complete image in the download buffer before decoding
if (format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
// A JPEG of known size is decoded as it arrives, so the download buffer only has to hold its headers.
// Otherwise the decoder needs the complete image in the buffer.
this->streaming_ = total_size > 0 && total_size <= INT32_MAX && this->decoder_supports_source();
this->stream_started_ = false;
this->source_.reset();
if (!this->streaming_ && format == runtime_image::JPEG && total_size > this->download_buffer_.size() &&
this->download_buffer_.resize(total_size) == 0) {
// resize() logged the failed allocation; the next update() allocates the configured buffer again
this->end_connection_();
@@ -152,6 +162,8 @@ void OnlineImage::update() {
ESP_LOGI(TAG, "Downloading image (Size: %zu)", total_size);
this->start_time_ = millis();
this->last_data_time_ = this->start_time_;
this->download_started_callback_.call();
this->report_progress_(true);
this->enable_loop();
}
@@ -169,6 +181,11 @@ void OnlineImage::loop() {
return;
}
if (this->streaming_) {
this->loop_stream_();
return;
}
// Check if download is complete — use decoder's format-specific completion check
// to handle both known content-length and chunked transfer encoding
if (this->is_decode_finished() || (this->downloader_->content_length > 0 &&
@@ -179,15 +196,22 @@ void OnlineImage::loop() {
}
// Download and decode more data
size_t available = this->download_buffer_.free_capacity();
if (available > 0) {
// Download in chunks to avoid blocking
available = std::min(available, this->download_buffer_initial_size_);
auto len = this->downloader_->read(this->download_buffer_.append(), available);
if (len > 0) {
this->last_data_time_ = App.get_loop_component_start_time();
if (this->download_buffer_.free_capacity() > 0) {
const uint32_t now = App.get_loop_component_start_time();
size_t received = 0;
int len;
do {
len = this->downloader_->read(this->download_buffer_.append(),
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
if (len <= 0)
break;
this->download_buffer_.write(len);
received += len;
} while (this->download_buffer_.free_capacity() != 0 && millis() - now < PASS_BUDGET_MS);
if (received > 0) {
this->last_data_time_ = now;
this->report_progress_(false);
// Feed data to decoder
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
@@ -255,6 +279,114 @@ void OnlineImage::loop() {
}
}
void OnlineImage::loop_stream_() {
const uint32_t now = App.get_loop_component_start_time();
const size_t total = this->downloader_->content_length;
if (this->stream_started_)
this->source_.compact();
if (this->downloader_->get_bytes_read() < total) {
if (this->download_buffer_.free_capacity() == 0) {
// compact() already ran, so a full buffer means the decoder read nothing in the last pass
if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
ESP_LOGE(TAG, "Image does not fit in the %zu byte download buffer; increase buffer_size",
this->download_buffer_.size());
this->fail_download_();
return;
}
} else {
int len;
do {
len = this->downloader_->read(this->download_buffer_.append(),
std::min(this->download_buffer_.free_capacity(), READ_CHUNK));
if (len <= 0)
break;
this->last_data_time_ = now;
this->download_buffer_.write(len);
this->report_progress_(false);
} while (this->downloader_->get_bytes_read() < total && this->download_buffer_.free_capacity() != 0 &&
millis() - now < PASS_BUDGET_MS);
if (len < 0) {
ESP_LOGE(TAG, "Error downloading image: %d", len);
this->fail_download_();
return;
} else if (now - this->last_data_time_ >= this->parent_->get_timeout()) {
ESP_LOGE(TAG, "No data received for %" PRIu32 " ms", this->parent_->get_timeout());
this->fail_download_();
return;
}
}
}
const bool arrived = this->downloader_->get_bytes_read() >= total;
if (!this->stream_started_) {
// The decoder reads the headers when it opens the image, so wait until they are in the buffer
if (!arrived && this->download_buffer_.free_capacity() != 0)
return;
const int err = this->begin_source(&this->source_);
if (err != 0) {
// Headers cut short by a full buffer fail to parse, or make the decoder seek past the buffer. Such an image
// still decodes once it is all in memory, as it did before JPEG images were decoded as they download.
if (!arrived && (err == runtime_image::DECODE_ERROR_INVALID_TYPE || err == runtime_image::DECODE_ERROR_READ)) {
ESP_LOGW(TAG,
"The image headers do not fit the %zu byte download buffer, so the whole image is downloaded "
"before it is decoded; increase buffer_size to decode it as it downloads",
this->download_buffer_.size());
// grow() logs a failed allocation
if (this->download_buffer_.grow(total)) {
this->streaming_ = false;
} else {
this->fail_download_();
}
return;
}
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(err));
this->fail_download_();
return;
}
this->source_.set_keep_read(false);
this->stream_started_ = true;
// Opening the image and allocating its buffer is enough for one pass
return;
}
do {
const int result = this->decode_step();
if (result == runtime_image::DECODE_STEP_MORE)
continue;
if (result == runtime_image::DECODE_STEP_DONE) {
this->finish_download_();
} else if (result == runtime_image::DECODE_STEP_NEED_DATA) {
if (!arrived)
return;
// JPEGDEC only waits for bytes short of the size it was given, so this guards against a hang
ESP_LOGE(TAG, "Download ended before the image was complete");
this->fail_download_();
} else {
ESP_LOGE(TAG, "Error decoding image: %s", runtime_image::decode_error_to_string(result));
this->fail_download_();
}
return;
} while (millis() - now < PASS_BUDGET_MS);
}
void OnlineImage::report_progress_(bool force) {
const uint32_t now = App.get_loop_component_start_time();
const size_t bytes = this->downloader_->get_bytes_read();
const size_t total = this->downloader_->content_length;
// The last bytes are always reported, so a progress bar reaches 100%
if (!force && now - this->last_progress_time_ < PROGRESS_INTERVAL_MS && (total == 0 || bytes < total))
return;
this->last_progress_time_ = now;
const float progress = total > 0 ? std::min(100.0f, bytes * 100.0f / total) : NAN;
this->download_progress_callback_.call(progress, bytes);
}
void OnlineImage::fail_download_() {
this->end_connection_();
this->download_error_callback_.call();
}
void OnlineImage::finish_download_() {
// Must be read before end_decode() ends the decoding session
bool decoder_finished = this->is_decode_finished();
@@ -288,6 +420,8 @@ void OnlineImage::end_connection_() {
this->downloader_ = nullptr;
}
this->download_buffer_.reset();
this->streaming_ = false;
this->stream_started_ = false;
this->disable_loop();
}
@@ -16,6 +16,10 @@ using t_http_codes = enum {
HTTP_CODE_NOT_FOUND = 404,
};
/// Largest download read(). A read() blocks until it has every byte it asked for, so this bounds how long one loop
/// pass waits on a slow link.
static constexpr size_t READ_CHUNK = 1460;
/**
* @brief Download an image from a given URL, and decode it using the specified decoder.
* The image will then be stored in a buffer, so that it can be re-displayed without the
@@ -70,9 +74,17 @@ class OnlineImage final : public PollingComponent,
*/
void release();
/// Called when the server has accepted the request and the image starts to download
template<typename F> void add_on_download_started_callback(F &&callback) {
this->download_started_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_finished_callback(F &&callback) {
this->download_finished_callback_.add(std::forward<F>(callback));
}
/// Called with the percentage downloaded (NAN when the server did not send the size) and the bytes read so far
template<typename F> void add_on_progress_callback(F &&callback) {
this->download_progress_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_error_callback(F &&callback) {
this->download_error_callback_.add(std::forward<F>(callback));
}
@@ -81,12 +93,20 @@ class OnlineImage final : public PollingComponent,
bool validate_url_(const std::string &url);
void end_connection_();
void finish_download_();
/// loop() for a JPEG that is decoded as it downloads
void loop_stream_();
void fail_download_();
/// Reports download progress, at most every PROGRESS_INTERVAL_MS unless force is set
void report_progress_(bool force);
CallbackManager<void(bool)> download_finished_callback_{};
CallbackManager<void()> download_error_callback_{};
LazyCallbackManager<void()> download_started_callback_{};
LazyCallbackManager<void(float, size_t)> download_progress_callback_{};
std::shared_ptr<http_request::HttpContainer> downloader_{nullptr};
DownloadBuffer download_buffer_;
DownloadSource source_{download_buffer_};
/**
* This is the *initial* size of the download buffer, not the current size.
* The download buffer can be resized at runtime; the download_buffer_initial_size_
@@ -109,6 +129,9 @@ class OnlineImage final : public PollingComponent,
uint32_t start_time_{0};
uint32_t last_data_time_{0};
uint32_t last_progress_time_{0};
bool streaming_{false}; // The decoder reads the download itself, as it arrives
bool stream_started_{false}; // The decoder has read the headers
};
} // namespace esphome::online_image
@@ -6,7 +6,7 @@ namespace esphome::output {
ESPHOME_LOG_TAG(TAG, "output.switch");
void OutputSwitch::dump_config() { LOG_SWITCH("", "Output Switch", this); }
void OutputSwitch::setup() { this->control(this->get_initial_state_with_restore_mode().value_or(false)); }
void OutputSwitch::setup() { this->restore(false); }
void OutputSwitch::write_state(bool state) {
this->output_->set_state(state);
this->publish_state(state);
+11 -1
View File
@@ -92,7 +92,17 @@ class JPEGFormat(Format):
def actions(self) -> None:
cg.add_define("USE_RUNTIME_IMAGE_JPEG")
cg.add_library("JPEGDEC", "1.8.4", "https://github.com/bitbank2/JPEGDEC#1.8.4")
# 1.8.4 plus decodeBegin()/decodeRows(), so an image can be decoded a few rows at a time and
# can wait for input that has not arrived yet, and fixes for: input that arrives in short
# reads, garbled RGB8888 output of grayscale images, and two ARM NEON faults seen on arm64 hosts
# (swapped red and blue in the 4:2:0 RGB8888 path, writes past the row on clipped MCUs). It also
# uses the ESP32-S3 SIMD colour conversion in ESP-IDF builds, not only Arduino ones, and is built
# with -O2.
cg.add_library(
"JPEGDEC",
"1.8.4",
"https://github.com/esphome-libs/JPEGDEC#1.8.4-esphome.3",
)
if CORE.is_host:
# JPEGDEC's host detection checks __MACH__/__LINUX__, but gcc only
# predefines the lowercase __linux__; without this a Linux host
@@ -2,7 +2,6 @@
#include "runtime_image.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cmath>
namespace esphome::runtime_image {
@@ -11,8 +10,8 @@ ESPHOME_LOG_TAG(TAG, "image_decoder");
bool ImageDecoder::set_size(int width, int height) {
bool success = this->image_->resize(width, height) > 0;
this->size_valid_ = success;
this->x_scale_ = static_cast<double>(this->image_->get_buffer_width()) / width;
this->y_scale_ = static_cast<double>(this->image_->get_buffer_height()) / height;
this->draw_width_ = width;
this->draw_height_ = height;
return success;
}
@@ -20,10 +19,24 @@ void ImageDecoder::draw(int x, int y, int w, int h, const Color &color) {
if (!this->size_valid_) {
return;
}
auto width = std::min(this->image_->get_buffer_width(), static_cast<int>(std::ceil((x + w) * this->x_scale_)));
auto height = std::min(this->image_->get_buffer_height(), static_cast<int>(std::ceil((y + h) * this->y_scale_)));
for (int i = x * this->x_scale_; i < width; i++) {
for (int j = y * this->y_scale_; j < height; j++) {
// When shrinking, each target pixel is painted once, by the first source pixel that reaches it, rather than by every
// pixel that lands on it. Integer maths: the S3 has no double FPU.
const int buffer_width = this->image_->get_buffer_width();
const int buffer_height = this->image_->get_buffer_height();
int x1 = x;
int y1 = y;
int x2 = x + w;
int y2 = y + h;
if (buffer_width != this->draw_width_ || buffer_height != this->draw_height_) {
x1 = scale_boundary(x, this->draw_width_, buffer_width);
y1 = scale_boundary(y, this->draw_height_, buffer_height);
x2 = scale_boundary(x + w, this->draw_width_, buffer_width);
y2 = scale_boundary(y + h, this->draw_height_, buffer_height);
}
x2 = std::min(buffer_width, x2);
y2 = std::min(buffer_height, y2);
for (int i = x1; i < x2; i++) {
for (int j = y1; j < y2; j++) {
this->image_->draw_pixel(i, j, color);
}
}
@@ -1,14 +1,33 @@
#pragma once
#include <cstdint>
#include "esphome/core/color.h"
#include "image_format.h"
namespace esphome::runtime_image {
/// The first of `target` pixels that pixel n of `source` paints, rounded up: ceil(n * target / source). The spans of
/// neighbouring pixels meet without overlapping, so a shrunk image paints each target pixel once.
inline int scale_boundary(int n, int source, int target) {
if (source <= 0)
return 0;
return static_cast<int>((static_cast<uint32_t>(n) * static_cast<uint32_t>(target) + source - 1) /
static_cast<uint32_t>(source));
}
enum DecodeError : int {
DECODE_ERROR_INVALID_TYPE = -1,
DECODE_ERROR_UNSUPPORTED_FORMAT = -2,
DECODE_ERROR_OUT_OF_MEMORY = -3,
DECODE_ERROR_INTERNAL_DECODER_ERROR = -4,
DECODE_ERROR_READ = -5,
};
/// Results of ImageDecoder::decode_step() other than a DecodeError
enum DecodeStep : int {
DECODE_STEP_DONE = 0,
DECODE_STEP_MORE = 1,
DECODE_STEP_NEED_DATA = 2, // The source has nothing more yet; call again once more has arrived
};
constexpr const char *decode_error_to_string(int error) {
@@ -21,6 +40,8 @@ constexpr const char *decode_error_to_string(int error) {
return "Out of memory";
case DECODE_ERROR_INTERNAL_DECODER_ERROR:
return "Internal decoder error";
case DECODE_ERROR_READ:
return "Read error";
default:
return "Unknown error";
}
@@ -28,6 +49,19 @@ constexpr const char *decode_error_to_string(int error) {
class RuntimeImage;
/// Input that a decoder reads itself as it goes, so the whole image never has to be in memory at once
class DecodeSource {
public:
virtual ~DecodeSource() = default;
/**
* Read up to len bytes at the current position. Returns the number read, which is less than len, even 0, when
* no more has arrived yet or at the end of the input, or negative on error.
*/
virtual int32_t read(uint8_t *buf, uint32_t len) = 0;
/// Move to a position counted from the start. Returns false on error.
virtual bool seek(uint32_t position) = 0;
};
/**
* @brief Class to abstract decoding different image formats.
*/
@@ -58,8 +92,8 @@ class ImageDecoder {
this->expected_size_ = 0;
this->decoded_bytes_ = 0;
this->size_valid_ = true;
this->x_scale_ = 1.0;
this->y_scale_ = 1.0;
this->draw_width_ = 0;
this->draw_height_ = 0;
}
/**
@@ -88,6 +122,28 @@ class ImageDecoder {
*/
virtual int decode(uint8_t *buffer, size_t size) = 0;
/// Whether this decoder can read a DecodeSource itself, a step at a time, instead of being fed data.
virtual bool supports_source() const { return false; }
/**
* @brief Start decoding from a source the decoder reads itself. Call after prepare(), in place of decode().
*
* The image headers (everything before the image data) must be readable from the source when this is called;
* only decode_step() can wait for more to arrive. The source must hold expected_size bytes in the end, and stay
* valid until the session ends.
* @return int 0 on success, or a {@see DecodeError} value.
*/
virtual int begin_source(DecodeSource *source) { return DECODE_ERROR_UNSUPPORTED_FORMAT; }
/**
* @brief Decode a little more of an image started with begin_source(), keeping each call short.
*
* @return int DECODE_STEP_MORE while there is more to do, DECODE_STEP_NEED_DATA when the source has not
* delivered enough to go on, DECODE_STEP_DONE when the image is complete, or a {@see DecodeError}
* value.
*/
virtual int decode_step() { return DECODE_ERROR_UNSUPPORTED_FORMAT; }
/**
* @brief Request the image to be resized once the actual dimensions are known.
* Called by the callback functions, to be able to access the parent Image class.
@@ -112,6 +168,9 @@ class ImageDecoder {
*/
void draw(int x, int y, int w, int h, const Color &color);
/// Bytes of the image the decoder has used so far
size_t get_decoded_bytes() const { return this->decoded_bytes_; }
/**
* @brief Check if the decoder has finished processing.
*
@@ -134,8 +193,9 @@ class ImageDecoder {
uint8_t *image_buffer_() const;
bool image_is_big_endian_() const;
double x_scale_ = 1.0;
double y_scale_ = 1.0;
// Size of the image as the decoder draws it, which draw() spreads over the buffer
int draw_width_ = 0;
int draw_height_ = 0;
RuntimeImage *image_;
size_t expected_size_ = 0; // Expected data size (0 if unknown)
size_t decoded_bytes_ = 0; // Bytes processed so far
+141 -31
View File
@@ -28,16 +28,14 @@ bool JpegDecoder::prepare_rgb565_scaling_(int width, int height) {
this->source_height_ = height;
this->x_boundaries_.clear();
this->y_boundaries_.clear();
const uint32_t target_width = this->image_->get_buffer_width();
const uint32_t target_height = this->image_->get_buffer_height();
// Boundary n is the first target pixel source pixel n covers, rounded up. When shrinking, the source
// pixel whose span lands on a target pixel paints it, so an 8 to 4 scale keeps columns 0, 2, 4 and 6;
// the generic draw() path rounds down and keeps the odd ones instead
for (uint32_t x = 0; x <= static_cast<uint32_t>(width); x++) {
this->x_boundaries_.push_back(static_cast<uint16_t>((x * target_width + width - 1) / width));
const int target_width = this->image_->get_buffer_width();
const int target_height = this->image_->get_buffer_height();
// The same boundaries as the generic draw() path, so an 8 to 4 scale keeps columns 0, 2, 4 and 6 in both
for (int x = 0; x <= width; x++) {
this->x_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(x, width, target_width)));
}
for (uint32_t y = 0; y <= static_cast<uint32_t>(height); y++) {
this->y_boundaries_.push_back(static_cast<uint16_t>((y * target_height + height - 1) / height));
for (int y = 0; y <= height; y++) {
this->y_boundaries_.push_back(static_cast<uint16_t>(scale_boundary(y, height, target_height)));
}
return true;
}
@@ -135,13 +133,24 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
ESP_LOGE(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
return DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0)
return err;
if (!this->jpeg_.decode(0, 0, this->scale_option_()))
return this->fail_();
this->decoded_bytes_ = size;
this->jpeg_.close();
return size;
}
int JpegDecoder::start_() {
auto jpeg_type = this->jpeg_.getJPEGType();
if (jpeg_type == JPEG_MODE_INVALID) {
ESP_LOGE(TAG, "Unsupported JPEG image");
return DECODE_ERROR_INVALID_TYPE;
} else if (jpeg_type == JPEG_MODE_PROGRESSIVE) {
ESP_LOGE(TAG, "Progressive JPEG images not supported");
return DECODE_ERROR_INVALID_TYPE;
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
ESP_LOGD(TAG, "Image size: %d x %d, bpp: %d", this->jpeg_.getWidth(), this->jpeg_.getHeight(), this->jpeg_.getBpp());
@@ -154,32 +163,133 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
} else {
this->jpeg_.setPixelType(RGB8888);
}
if (!this->set_size(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
const int width = this->jpeg_.getWidth();
const int height = this->jpeg_.getHeight();
if (!this->set_size(width, height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(this->jpeg_.getWidth(), this->jpeg_.getHeight())) {
// JPEGDEC can decode at 1/2, 1/4 or 1/8 size for much less work. Use the smallest size that still has at least as
// many pixels as the buffer, so a large image shown small is not decoded in full only to be thrown away.
const int buffer_width = this->image_->get_buffer_width();
const int buffer_height = this->image_->get_buffer_height();
this->scale_ = 1;
while (this->scale_ < JPEG_SCALE_EIGHTH && width / (this->scale_ * 2) >= buffer_width &&
height / (this->scale_ * 2) >= buffer_height) {
this->scale_ *= 2;
}
const int decoded_width = (width + this->scale_ - 1) / this->scale_;
const int decoded_height = (height + this->scale_ - 1) / this->scale_;
this->draw_width_ = decoded_width;
this->draw_height_ = decoded_height;
if (this->scale_ != 1) {
ESP_LOGD(TAG, "Decoding at 1/%d size, %d x %d", this->scale_, decoded_width, decoded_height);
}
if (this->direct_rgb565_ && !this->prepare_rgb565_scaling_(decoded_width, decoded_height)) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (!this->jpeg_.decode(0, 0, 0)) {
auto error = this->jpeg_.getLastError();
ESP_LOGE(TAG, "Error while decoding: %d", error);
this->jpeg_.close();
switch (error) {
case JPEG_ERROR_MEMORY:
return DECODE_ERROR_OUT_OF_MEMORY;
case JPEG_UNSUPPORTED_FEATURE:
return DECODE_ERROR_UNSUPPORTED_FORMAT;
case JPEG_INVALID_FILE:
case JPEG_INVALID_PARAMETER:
return DECODE_ERROR_INVALID_TYPE;
case JPEG_DECODE_ERROR:
default:
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
}
}
this->decoded_bytes_ = size;
return 0;
}
int JpegDecoder::fail_() {
const bool read_failed = this->source_ != nullptr && this->source_failed_;
const int error = this->jpeg_.getLastError();
this->jpeg_.close();
return size;
this->source_ = nullptr;
if (read_failed) {
ESP_LOGE(TAG, "Reading the image failed");
return DECODE_ERROR_READ;
}
ESP_LOGE(TAG, "Error while decoding: %d", error);
switch (error) {
case JPEG_ERROR_MEMORY:
return DECODE_ERROR_OUT_OF_MEMORY;
case JPEG_UNSUPPORTED_FEATURE:
return DECODE_ERROR_UNSUPPORTED_FORMAT;
case JPEG_INVALID_FILE:
case JPEG_INVALID_PARAMETER:
return DECODE_ERROR_INVALID_TYPE;
case JPEG_DECODE_ERROR:
default:
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
}
}
void JpegDecoder::reset() {
// A decode from a source that was given up part way still has the image open
if (this->source_ != nullptr) {
this->jpeg_.close();
this->source_ = nullptr;
}
ImageDecoder::reset();
}
int32_t JpegDecoder::source_read(JPEGFILE *file, uint8_t *buf, int32_t len) {
auto *decoder = static_cast<JpegDecoder *>(file->fHandle);
const int32_t n = decoder->source_->read(buf, len);
if (n < 0) {
// JPEGDEC has no way to report a failed read, so remember it and give it no data
decoder->source_failed_ = true;
return 0;
}
file->iPos += n;
return n;
}
int32_t JpegDecoder::source_seek(JPEGFILE *file, int32_t position) {
auto *decoder = static_cast<JpegDecoder *>(file->fHandle);
if (position < 0 || !decoder->source_->seek(position)) {
decoder->source_failed_ = true;
return file->iPos;
}
file->iPos = position;
return position;
}
int JpegDecoder::begin_source(DecodeSource *source) {
if (this->expected_size_ == 0 || this->expected_size_ > INT32_MAX) {
ESP_LOGE(TAG, "Image size %zu cannot be read from a source", this->expected_size_);
return DECODE_ERROR_INVALID_TYPE;
}
if (!source->seek(0)) {
ESP_LOGE(TAG, "Could not read the image");
return DECODE_ERROR_READ;
}
this->source_ = source;
this->source_failed_ = false;
if (!this->jpeg_.open(this, this->expected_size_, source_close, source_read, source_seek, draw_callback)) {
// The caller may recover, for example by reading the whole image first, so it reports the failure
ESP_LOGD(TAG, "Could not open image for decoding: %d", this->jpeg_.getLastError());
this->source_ = nullptr;
return this->source_failed_ ? DECODE_ERROR_READ : DECODE_ERROR_INVALID_TYPE;
}
const int err = this->start_();
if (err != 0) {
this->jpeg_.close();
this->source_ = nullptr;
return err;
}
if (!this->jpeg_.decodeBegin(0, 0, this->scale_option_()))
return this->fail_();
return 0;
}
int HOT JpegDecoder::decode_step() {
if (this->source_ == nullptr)
return DECODE_ERROR_INVALID_TYPE;
const int result = this->jpeg_.decodeRows(1);
// JPEGDEC may decode on with the missing data, so check the read before its result
if (this->source_failed_)
return this->fail_();
if (result == JPEG_DECODE_MORE)
return DECODE_STEP_MORE;
if (result == JPEG_DECODE_NEED_DATA)
return DECODE_STEP_NEED_DATA;
if (result == 0)
return this->fail_();
this->decoded_bytes_ = this->expected_size_;
this->jpeg_.close();
this->source_ = nullptr;
return DECODE_STEP_DONE;
}
} // namespace esphome::runtime_image
@@ -22,19 +22,41 @@ class JpegDecoder : public ImageDecoder {
~JpegDecoder() override {}
int HOT decode(uint8_t *buffer, size_t size) override;
void reset() override;
/// The source is read as the decode needs it, through JPEGDEC's own 2 KB buffer
bool supports_source() const override { return true; }
int begin_source(DecodeSource *source) override;
/// Decodes one row of MCUs (8 or 16 pixel rows), or waits part way through one for more of the source
int HOT decode_step() override;
bool uses_direct_rgb565() const { return this->direct_rgb565_; }
/// JPEGDEC decodes the image at 1/get_scale() of its size: 1, 2, 4 or 8
int get_scale() const { return this->scale_; }
void draw_rgb565(int x, int y, int w, int h, const uint8_t *pixels);
protected:
bool prepare_rgb565_scaling_(int width, int height);
/// Set up an opened image for decoding. Returns 0, or a DecodeError.
int start_();
/// The JPEGDEC option for scale_: JPEG_SCALE_HALF, _QUARTER and _EIGHTH are the divisors themselves
int scale_option_() const { return this->scale_ == 1 ? 0 : this->scale_; }
/// The DecodeError for a failed read of the source or JPEGDEC's last error, closing the image
int fail_();
static int32_t source_read(JPEGFILE *file, uint8_t *buf, int32_t len);
static int32_t source_seek(JPEGFILE *file, int32_t position);
static void source_close(void *handle) {}
JPEGDEC jpeg_{};
FixedVector<uint16_t> x_boundaries_{};
FixedVector<uint16_t> y_boundaries_{};
int source_width_{0};
int source_height_{0};
int scale_{1}; // JPEGDEC decodes at 1/scale_ of the image size
DecodeSource *source_{nullptr}; // Set while a begin_source() decode is in progress
bool direct_rgb565_{false};
bool source_failed_{false};
};
} // namespace esphome::runtime_image
@@ -116,7 +116,9 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
}
case image::IMAGE_TYPE_GRAYSCALE: {
uint32_t pos = this->get_position_(x, y);
auto gray = static_cast<uint8_t>(0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b);
// 0.2125, 0.7154 and 0.0721 in 1/256ths, summing to 256 so white stays 255. Integer maths: the S3 has no
// double FPU
auto gray = static_cast<uint8_t>((54 * color.r + 183 * color.g + 19 * color.b) >> 8);
if (this->transparency_ == image::TRANSPARENCY_CHROMA_KEY) {
if (gray == 1) {
gray = 0;
@@ -239,6 +241,25 @@ int RuntimeImage::feed_data(uint8_t *data, size_t len) {
return consumed;
}
int RuntimeImage::begin_source(DecodeSource *source) {
if (!this->decoder_supports_source()) {
ESP_LOGE(TAG, "Decoder cannot read a source");
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
return this->decoder_->begin_source(source);
}
int RuntimeImage::decode_step() {
if (!this->is_decoding()) {
ESP_LOGE(TAG, "No decoder initialized");
return DECODE_ERROR_INVALID_TYPE;
}
const int result = this->decoder_->decode_step();
if (result == DECODE_STEP_DONE)
this->decoded_bytes_ = this->decoder_->get_decoded_bytes();
return result;
}
bool RuntimeImage::end_decode() {
if (!this->is_decoding()) {
return false;
@@ -76,6 +76,22 @@ class RuntimeImage : public image::Image {
*/
int feed_data(uint8_t *data, size_t len);
/// Whether the active decoder can read a DecodeSource itself (see begin_source()).
bool decoder_supports_source() const { return this->is_decoding() && this->decoder_->supports_source(); }
/**
* @brief Decode from a source the decoder reads itself, in place of feed_data().
*
* Call after begin_decode() when decoder_supports_source() is true, once the image headers can be read from the
* source, then call decode_step() until it
* returns DECODE_STEP_DONE, and finish with end_decode().
* @return 0 on success, or a DecodeError value.
*/
int begin_source(DecodeSource *source);
/// Decode a little more from the source; see ImageDecoder::decode_step().
int decode_step();
/**
* @brief Complete the decoding process.
*
+394 -39
View File
@@ -1,79 +1,226 @@
#include "rx8111.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
namespace esphome::rx8111 {
ESPHOME_LOG_TAG(TAG, "rx8111");
// Indexed by the TSEL value stored in timer_clock_
PROGMEM_STRING_TABLE(TimerClockNames, "4096 Hz", "64 Hz", "1 Hz", "1/60 Hz");
static constexpr uint8_t RX8111_REG_SEC = 0x10;
static constexpr uint8_t RX8111_REG_MIN_ALARM = 0x17;
static constexpr uint8_t RX8111_REG_HOUR_ALARM = 0x18;
static constexpr uint8_t RX8111_REG_DAY_ALARM = 0x19;
static constexpr uint8_t RX8111_REG_EXTENSION = 0x1D;
static constexpr uint8_t RX8111_REG_FLAG = 0x1E;
static constexpr uint8_t RX8111_REG_TIMER0 = 0x1A;
static constexpr uint8_t RX8111_REG_CONTROL = 0x1F;
static constexpr uint8_t RX8111_REG_SEC_ALARM = 0x2C;
static constexpr uint8_t RX8111_ALARM_DISABLED = 0x80;
static constexpr uint8_t RX8111_EXTENSION_WADA = 0x08;
static constexpr uint8_t RX8111_CONTROL_AIE = 0x08;
static constexpr uint8_t RX8111_CONTROL_TIE = 0x10;
static constexpr uint8_t RX8111_EXTENSION_TE = 0x10;
static constexpr uint8_t RX8111_EXTENSION_TSEL = 0x03;
static constexpr uint8_t RX8111_FLAG_TF = 0x10;
static constexpr uint8_t RX8111_FLAG_AF = 0x08;
static constexpr uint8_t RX8111_FLAG_POR = 0x80;
static constexpr uint8_t RX8111_FLAG_VLF = 0x02;
static constexpr uint8_t RX8111_FLAG_XST = 0x01;
static constexpr uint8_t RX8111_CTRL_STOP = 0x01;
// FLAG bits are write-zero-to-clear; keep UF/TF/AF/EVF even if they latch during the write.
static constexpr uint8_t RX8111_KEEP_EVENT_FLAGS = 0x3C;
static constexpr uint32_t RX8111_EVENT_INTERVAL_ID = 0;
static constexpr uint32_t RX8111_BOOT_ALARM_RETRY_ID = 1;
static constexpr uint32_t RX8111_BOOT_TIMER_RETRY_ID = 2;
static constexpr uint8_t RX8111_BOOT_MAX_ATTEMPTS = 3;
void RX8111Component::setup() { this->read_time(); }
void RX8111Component::setup() {
this->configure_boot_alarm_();
this->configure_boot_timer_();
this->read_time();
if (this->alarm_enabled_ || this->watching_()) {
this->start_event_polling_();
}
}
void RX8111Component::update() { this->read_time(); }
void RX8111Component::configure_boot_alarm_() {
bool success = true;
if (this->alarm_enabled_) {
if (this->alarm_second_ != ALARM_FIELD_UNSET || this->alarm_minute_ != ALARM_FIELD_UNSET ||
this->alarm_hour_ != ALARM_FIELD_UNSET || this->alarm_weekday_ != ALARM_FIELD_UNSET ||
this->alarm_day_ != ALARM_FIELD_UNSET) {
success = this->program_alarm_(this->alarm_second_, this->alarm_minute_, this->alarm_hour_, this->alarm_weekday_,
this->alarm_day_, true);
if (success) {
// Only the first successful boot program can introduce a boot wake-up edge.
if (!this->boot_alarm_event_seen_)
this->alarm_flag_seen_ = false;
this->start_event_polling_();
}
} else {
// Preserve an alarm programmed by an action before a reboot or deep sleep.
uint8_t control;
success = this->read_byte(RX8111_REG_CONTROL, &control);
if (success) {
this->alarm_armed_ = (control & RX8111_CONTROL_AIE) != 0;
if (this->alarm_armed_)
this->start_event_polling_();
}
}
} else if (this->alarm_explicitly_configured_) {
success = this->set_alarm_interrupt_(false);
}
this->boot_config_result_(true, success);
}
void RX8111Component::configure_boot_timer_() {
bool success = true;
// An omitted timer setting leaves battery-backed state alone; explicit false stops it.
if (this->timer_configured_) {
const bool start_timer = this->timer_enabled_;
if (start_timer) {
success = this->configure_timer_();
} else {
uint8_t extension, control;
success = this->disable_timer_(extension, control);
}
if (success && !start_timer && this->timer_count_ != 0) {
this->timer_retained_event_pending_ = true;
this->start_event_polling_();
}
}
this->boot_config_result_(false, success);
}
void RX8111Component::boot_config_result_(bool alarm, bool success) {
auto &attempts = alarm ? this->boot_alarm_attempts_ : this->boot_timer_attempts_;
const uint8_t warning = alarm ? WARN_ALARM : WARN_TIMER;
++attempts;
if (success) {
this->clear_warning_(warning);
return;
}
this->set_warning_(warning, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
if (attempts >= RX8111_BOOT_MAX_ATTEMPTS)
return;
this->set_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID, this->boot_retry_delay_ms_,
[this, alarm]() {
if (alarm) {
this->configure_boot_alarm_();
} else {
this->configure_boot_timer_();
}
});
}
// A runtime action supersedes pending boot programming of the same feature
void RX8111Component::cancel_boot_retry_(bool alarm) {
this->cancel_timeout(alarm ? RX8111_BOOT_ALARM_RETRY_ID : RX8111_BOOT_TIMER_RETRY_ID);
}
void RX8111Component::check_event_flags_() {
if (this->watching_() && !this->poll_event_flags_())
this->set_warning_(WARN_EVENT_POLL, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
// A callback or a runtime action may have stopped the last watched feature
if (!this->watching_() && this->event_polling_started_) {
this->cancel_interval(RX8111_EVENT_INTERVAL_ID);
this->event_polling_started_ = false;
}
}
void RX8111Component::start_event_polling_() {
if (this->event_polling_started_)
return;
this->set_interval(RX8111_EVENT_INTERVAL_ID, this->event_poll_interval_, [this]() { this->check_event_flags_(); });
this->event_polling_started_ = true;
}
void RX8111Component::update() {
this->read_time();
this->check_event_flags_();
}
void RX8111Component::set_warning_(uint8_t bit, const LogString *message) {
this->warnings_ |= bit;
this->status_set_warning(message);
}
void RX8111Component::clear_warning_(uint8_t bit) {
if ((this->warnings_ & bit) == 0)
return;
this->warnings_ &= ~bit;
if (this->warnings_ == 0)
this->status_clear_warning();
}
void RX8111Component::dump_config() {
ESP_LOGCONFIG(TAG, "RX8111:");
LOG_I2C_DEVICE(this);
ESP_LOGCONFIG(TAG,
" Alarm interrupt: %s\n"
" Wake-up timer: %s",
ONOFF(this->alarm_enabled_), ONOFF(this->timer_enabled_));
if (this->timer_enabled_) {
ESP_LOGCONFIG(TAG, " Count: %" PRIu32 ", clock: %s", this->timer_count_,
LOG_STR_ARG(TimerClockNames::get_log_str(this->timer_clock_, TimerClockNames::LAST_INDEX)));
}
RealTimeClock::dump_config();
}
void RX8111Component::read_time() {
ESPTime rtc_time;
if (!this->read_calendar_(rtc_time))
return;
ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month,
rtc_time.hour, rtc_time.minute, rtc_time.second);
time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp);
}
bool RX8111Component::read_calendar_(ESPTime &rtc_time) {
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags)) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
if (flags & (RX8111_FLAG_VLF | RX8111_FLAG_XST | RX8111_FLAG_POR)) {
ESP_LOGW(TAG, "RTC time invalidated by power loss or oscillator stop; write trusted time");
this->status_set_warning(LOG_STR("RTC time invalidated; write trusted time"));
return;
this->set_warning_(WARN_CALENDAR, LOG_STR("RTC time invalidated; write trusted time"));
return false;
}
uint8_t buf[7];
// I2C access freezes the RX8111 counter, yielding a coherent calendar snapshot.
if (!this->read_bytes(RX8111_REG_SEC, buf, sizeof(buf))) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
this->set_warning_(WARN_CALENDAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
// decode_bcd_calendar() masks the same bits; the check here catches a corrupted register
static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF};
bool valid = true;
for (size_t i = 0; i < sizeof(buf); i++) {
if (i == 3)
continue; // Weekday uses one-hot encoding, not BCD.
buf[i] &= CALENDAR_MASKS[i];
if ((buf[i] & 0x0F) > 9 || (buf[i] >> 4) > 9) {
ESP_LOGW(TAG, "Invalid BCD in RTC time");
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
}
valid = valid && (buf[i] & 0x0F) <= 9 && (buf[i] >> 4) <= 9;
}
const uint8_t onehot = buf[3];
if (onehot == 0 || onehot > 0x40 || (onehot & (onehot - 1)) != 0) {
ESP_LOGW(TAG, "Invalid RTC weekday");
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
valid = valid && onehot != 0 && onehot <= 0x40 && (onehot & (onehot - 1)) == 0;
if (valid) {
rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
valid = rtc_time.is_valid(true, false) && rtc_time.second < 60 &&
ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week;
}
ESPTime rtc_time = time::decode_bcd_calendar<time::EPSON_BCD_CALENDAR>(buf);
if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) {
if (!valid) {
ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock");
this->status_set_warning(LOG_STR("Invalid RTC time"));
return;
this->set_warning_(WARN_CALENDAR, LOG_STR("Invalid RTC time"));
return false;
}
if (!this->status_comm_result(ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week == rtc_time.day_of_week,
LOG_STR("Invalid RTC time"))) {
ESP_LOGW(TAG, "RTC weekday does not match calendar date");
return;
}
ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month,
rtc_time.hour, rtc_time.minute, rtc_time.second);
time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp);
this->clear_warning_(WARN_CALENDAR);
return true;
}
void RX8111Component::write_time() {
@@ -83,7 +230,7 @@ void RX8111Component::write_time() {
return;
}
if (!this->set_stop_bit_(true)) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
@@ -93,25 +240,233 @@ void RX8111Component::write_time() {
// Always try to restart the counter, even after a failed calendar write.
const bool restarted = this->set_stop_bit_(false);
if (!written || !restarted) {
this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if (!this->status_comm_result(this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)))
if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) {
this->set_warning_(WARN_WRITE_TIME, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
this->clear_warning_(WARN_WRITE_TIME);
this->clear_warning_(WARN_CALENDAR);
ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour,
now.minute, now.second);
}
bool RX8111Component::set_stop_bit_(bool stop) {
uint8_t control;
if (!this->read_byte(RX8111_REG_CONTROL, &control))
return this->update_register(RX8111_REG_CONTROL, RX8111_CTRL_STOP, stop ? RX8111_CTRL_STOP : 0);
}
bool RX8111Component::set_alarm_interrupt_(bool enabled) {
return this->update_register(RX8111_REG_CONTROL, RX8111_CONTROL_AIE, enabled ? RX8111_CONTROL_AIE : 0);
}
bool RX8111Component::program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day,
bool boot) {
uint8_t extension;
if (!this->read_byte(RX8111_REG_EXTENSION, &extension))
return false;
if (stop) {
control |= RX8111_CTRL_STOP;
} else {
control &= ~RX8111_CTRL_STOP;
if (weekday != ALARM_FIELD_UNSET) {
extension &= ~RX8111_EXTENSION_WADA;
} else if (day != ALARM_FIELD_UNSET) {
extension |= RX8111_EXTENSION_WADA;
}
return this->write_byte(RX8111_REG_CONTROL, control);
const uint8_t fields[] = {
second == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(second),
minute == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(minute),
hour == ALARM_FIELD_UNSET ? RX8111_ALARM_DISABLED : uint8_to_bcd(hour),
weekday != ALARM_FIELD_UNSET ? static_cast<uint8_t>(1U << (weekday - 1))
: day != ALARM_FIELD_UNSET ? uint8_to_bcd(day)
: RX8111_ALARM_DISABLED,
};
// Disable the interrupt during partial programming; keep AF (past events) latched.
if (!this->set_alarm_interrupt_(false))
return false;
this->alarm_armed_ = false;
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags))
return false;
if (!boot)
this->alarm_flag_seen_ = (flags & RX8111_FLAG_AF) != 0;
if (!this->write_byte(RX8111_REG_SEC_ALARM, fields[0]) || !this->write_bytes(RX8111_REG_MIN_ALARM, &fields[1], 3) ||
!this->write_byte(RX8111_REG_EXTENSION, extension))
return false;
if (!this->set_alarm_interrupt_(this->alarm_enabled_))
return false;
this->alarm_armed_ = true;
return true;
}
bool RX8111Component::set_alarm(uint16_t second, uint16_t minute, uint16_t hour, uint16_t weekday, uint16_t day) {
if (!this->alarm_enabled_) {
ESP_LOGE(TAG, "Alarm interrupt disabled; set alarm_enabled: true");
return false;
}
constexpr uint16_t unset = ALARM_FIELD_UNSET;
if ((second != unset && second > 59) || (minute != unset && minute > 59) || (hour != unset && hour > 23) ||
(weekday != unset && (weekday < 1 || weekday > 7)) || (day != unset && (day < 1 || day > 31)) ||
(weekday != unset && day != unset) ||
(second == unset && minute == unset && hour == unset && weekday == unset && day == unset)) {
ESP_LOGE(TAG, "Invalid alarm fields");
return false;
}
this->cancel_boot_retry_(true);
if (!this->program_alarm_(second, minute, hour, weekday, day)) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_ALARM);
this->boot_alarm_event_seen_ = true;
this->start_event_polling_();
return true;
}
bool RX8111Component::schedule_alarm_in(uint32_t seconds) {
if (!this->alarm_enabled_ || seconds == 0 || seconds >= 28U * 86400U) {
ESP_LOGE(TAG, "Relative alarm requires alarm_enabled and a duration of 1 second to less than 28 days");
return false;
}
ESPTime now;
if (!this->read_calendar_(now)) {
ESP_LOGE(TAG, "Cannot read valid RTC time for relative alarm");
return false;
}
auto target = ESPTime::from_epoch_utc(now.timestamp + seconds);
if (!target.is_valid() || target.year > 2099) {
ESP_LOGE(TAG, "Relative alarm target is outside RTC year range");
return false;
}
return this->set_alarm(target.second, target.minute, target.hour, ALARM_FIELD_UNSET, target.day_of_month);
}
bool RX8111Component::unset_alarm() {
this->cancel_boot_retry_(true);
if (!this->set_alarm_interrupt_(false)) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->alarm_armed_ = false;
// Minute, hour and day alarm registers are contiguous
static constexpr uint8_t DISABLED_FIELDS[3] = {RX8111_ALARM_DISABLED, RX8111_ALARM_DISABLED, RX8111_ALARM_DISABLED};
if (!this->write_byte(RX8111_REG_SEC_ALARM, RX8111_ALARM_DISABLED) ||
!this->write_bytes(RX8111_REG_MIN_ALARM, DISABLED_FIELDS, sizeof(DISABLED_FIELDS))) {
this->set_warning_(WARN_ALARM, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_ALARM);
return true;
}
bool RX8111Component::clear_alarm_flag() {
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_AF))) {
this->set_warning_(WARN_ALARM_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->alarm_flag_seen_ = false;
this->clear_warning_(WARN_ALARM_FLAG_CLEAR);
return true;
}
// Clears TE and TIE; returns the register values read so callers can build on them
bool RX8111Component::disable_timer_(uint8_t &extension, uint8_t &control) {
return this->read_byte(RX8111_REG_EXTENSION, &extension) &&
this->write_byte(RX8111_REG_EXTENSION, extension & ~RX8111_EXTENSION_TE) &&
this->read_byte(RX8111_REG_CONTROL, &control) &&
this->write_byte(RX8111_REG_CONTROL, control & ~RX8111_CONTROL_TIE);
}
bool RX8111Component::configure_timer_(bool clear_stale_flag) {
// Stop counting before updating the 24-bit reload value; preserve the boot flag unless explicitly starting.
uint8_t extension;
uint8_t control;
if (!this->disable_timer_(extension, control))
return false;
const uint8_t count[] = {static_cast<uint8_t>(this->timer_count_), static_cast<uint8_t>(this->timer_count_ >> 8),
static_cast<uint8_t>(this->timer_count_ >> 16)};
if (!this->write_bytes(RX8111_REG_TIMER0, count, sizeof(count)) ||
!this->write_byte(RX8111_REG_EXTENSION,
(extension & ~(RX8111_EXTENSION_TE | RX8111_EXTENSION_TSEL)) | this->timer_clock_) ||
(clear_stale_flag && !this->clear_timer_flag()) ||
!this->write_byte(RX8111_REG_CONTROL, control | RX8111_CONTROL_TIE))
return false;
return this->write_byte(RX8111_REG_EXTENSION,
(extension & ~RX8111_EXTENSION_TSEL) | this->timer_clock_ | RX8111_EXTENSION_TE);
}
bool RX8111Component::start_timer() {
if (this->timer_count_ == 0 || this->timer_count_ > 0xFFFFFF) {
ESP_LOGW(TAG, "Cannot start timer without a valid timer_count");
return false;
}
this->cancel_boot_retry_(false);
if (!this->configure_timer_(true)) {
// A partial configuration can leave either enable bit set. Disable both if the bus permits it;
// otherwise keep watching a timer that may still be running.
uint8_t extension, control;
if (this->disable_timer_(extension, control))
this->timer_enabled_ = false;
this->set_warning_(WARN_TIMER, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->clear_warning_(WARN_TIMER);
this->timer_enabled_ = true;
this->timer_retained_event_pending_ = false;
this->start_event_polling_();
return true;
}
bool RX8111Component::clear_timer_flag() {
if (!this->write_byte(RX8111_REG_FLAG, static_cast<uint8_t>(~RX8111_FLAG_TF))) {
this->set_warning_(WARN_TIMER_FLAG_CLEAR, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->timer_flag_seen_ = false;
this->timer_retained_event_pending_ = false;
this->clear_warning_(WARN_TIMER_FLAG_CLEAR);
return true;
}
bool RX8111Component::stop_timer() {
this->cancel_boot_retry_(false);
uint8_t extension, control;
if (!this->disable_timer_(extension, control)) {
this->set_warning_(WARN_TIMER, LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return false;
}
this->timer_enabled_ = false;
this->clear_warning_(WARN_TIMER);
return true;
}
bool RX8111Component::poll_event_flags_() {
uint8_t flags;
if (!this->read_byte(RX8111_REG_FLAG, &flags))
return false;
this->clear_warning_(WARN_EVENT_POLL);
const bool alarm_active = (flags & RX8111_FLAG_AF) != 0;
const bool timer_active = (flags & RX8111_FLAG_TF) != 0;
// alarm_armed_ is only ever set while the alarm is enabled
const bool timer_watched = this->timer_enabled_ || this->timer_retained_event_pending_;
if (alarm_active && this->alarm_armed_)
this->boot_alarm_event_seen_ = true;
const bool alarm_rising = alarm_active && !this->alarm_flag_seen_ && this->alarm_armed_;
const bool timer_rising = timer_active && !this->timer_flag_seen_ && timer_watched;
const bool deliver_stopped_event = timer_rising && this->timer_retained_event_pending_;
// Record eligible edges before callbacks, which may clear flags synchronously.
// Leave flags unseen while boot configuration is still waiting to enable delivery.
if (this->alarm_armed_)
this->alarm_flag_seen_ = alarm_active;
if (timer_watched)
this->timer_flag_seen_ = timer_active;
this->timer_retained_event_pending_ = false;
if (alarm_rising)
this->alarm_callbacks_.call();
// An alarm callback may stop the timer before its callback is delivered.
if (timer_rising && this->timer_flag_seen_ && (this->timer_enabled_ || deliver_stopped_event))
this->timer_callbacks_.call();
return true;
}
} // namespace esphome::rx8111
+87
View File
@@ -1,10 +1,15 @@
#pragma once
#include "esphome/core/helpers.h"
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/time/real_time_clock.h"
namespace esphome::rx8111 {
namespace testing {
class RX8111Test;
} // namespace testing
class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice {
public:
void setup() override;
@@ -12,9 +17,91 @@ class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice
void dump_config() override;
void read_time();
void write_time();
void set_alarm_enabled(bool enabled) { this->alarm_enabled_ = enabled; }
void set_alarm_explicitly_configured(bool configured) { this->alarm_explicitly_configured_ = configured; }
void set_alarm_second(uint8_t value) { this->alarm_second_ = value; }
void set_alarm_minute(uint8_t value) { this->alarm_minute_ = value; }
void set_alarm_hour(uint8_t value) { this->alarm_hour_ = value; }
void set_alarm_weekday(uint8_t value) { this->alarm_weekday_ = value; }
void set_alarm_day(uint8_t value) { this->alarm_day_ = value; }
void set_timer_count(uint32_t count) { this->timer_count_ = count; }
void set_timer_clock(uint8_t clock) { this->timer_clock_ = clock; }
void set_timer_enabled(bool enabled) { this->timer_enabled_ = enabled; }
void set_timer_configured(bool configured) { this->timer_configured_ = configured; }
void set_event_poll_interval(uint32_t interval) { this->event_poll_interval_ = interval; }
template<typename F> void add_on_alarm_callback(F &&callback) {
this->alarm_callbacks_.add(std::forward<F>(callback));
}
bool set_alarm(uint16_t second, uint16_t minute, uint16_t hour, uint16_t weekday, uint16_t day);
bool schedule_alarm_in(uint32_t seconds);
bool unset_alarm();
bool clear_alarm_flag();
bool clear_timer_flag();
bool start_timer();
bool stop_timer();
template<typename F> void add_on_timer_callback(F &&callback) {
this->timer_callbacks_.add(std::forward<F>(callback));
}
protected:
friend class testing::RX8111Test;
static constexpr uint32_t BOOT_RETRY_DELAY_MS = 1000;
// set_alarm() and the boot alarm ignore a field set to this value
static constexpr uint8_t ALARM_FIELD_UNSET = 0xFF;
// One bit per fault source, so a recovery in one path cannot hide another path's fault
enum Warning : uint8_t {
WARN_CALENDAR = 1 << 0,
WARN_EVENT_POLL = 1 << 1,
WARN_ALARM = 1 << 2,
WARN_TIMER = 1 << 3,
WARN_WRITE_TIME = 1 << 4,
WARN_ALARM_FLAG_CLEAR = 1 << 5,
WARN_TIMER_FLAG_CLEAR = 1 << 6,
};
bool set_stop_bit_(bool stop);
bool read_calendar_(ESPTime &rtc_time);
void set_warning_(uint8_t bit, const LogString *message);
void clear_warning_(uint8_t bit);
bool watching_() const { return this->alarm_armed_ || this->timer_enabled_ || this->timer_retained_event_pending_; }
void check_event_flags_();
bool program_alarm_(uint8_t second, uint8_t minute, uint8_t hour, uint8_t weekday, uint8_t day, bool boot = false);
bool set_alarm_interrupt_(bool enabled);
bool disable_timer_(uint8_t &extension, uint8_t &control);
bool configure_timer_(bool clear_stale_flag = false);
void start_event_polling_();
bool poll_event_flags_();
void configure_boot_alarm_();
void configure_boot_timer_();
void boot_config_result_(bool alarm, bool success);
void cancel_boot_retry_(bool alarm);
LazyCallbackManager<void()> alarm_callbacks_;
LazyCallbackManager<void()> timer_callbacks_;
uint32_t timer_count_{0};
uint32_t event_poll_interval_{1000};
uint32_t boot_retry_delay_ms_{BOOT_RETRY_DELAY_MS};
uint8_t warnings_{0};
uint8_t timer_clock_{2}; // 1 Hz
uint8_t boot_alarm_attempts_{0};
uint8_t boot_timer_attempts_{0};
uint8_t alarm_second_{ALARM_FIELD_UNSET};
uint8_t alarm_minute_{ALARM_FIELD_UNSET};
uint8_t alarm_hour_{ALARM_FIELD_UNSET};
uint8_t alarm_weekday_{ALARM_FIELD_UNSET};
uint8_t alarm_day_{ALARM_FIELD_UNSET};
bool timer_enabled_{false};
bool timer_configured_{false};
bool timer_flag_seen_{false};
bool timer_retained_event_pending_{false};
bool event_polling_started_{false};
bool boot_alarm_event_seen_{false};
bool alarm_enabled_{false};
bool alarm_explicitly_configured_{false};
bool alarm_flag_seen_{false};
bool alarm_armed_{false};
};
} // namespace esphome::rx8111
+177 -6
View File
@@ -4,6 +4,14 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c, time
import esphome.config_validation as cv
from esphome.const import (
CONF_DAY,
CONF_DURATION,
CONF_HOUR,
CONF_ID,
CONF_MINUTE,
CONF_SECOND,
)
from esphome.types import ConfigType
CODEOWNERS = ["@techdregs"]
@@ -11,23 +19,186 @@ DEPENDENCIES = ["i2c"]
rx8111_ns = cg.esphome_ns.namespace("rx8111")
RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice)
TIMER_CLOCKS = {"4096hz": 0, "64hz": 1, "1hz": 2, "1_60hz": 3}
# set_alarm() ignores a field set to this value
ALARM_FIELD_UNSET = 0xFF
CONFIG_SCHEMA = time.TIME_SCHEMA.extend(
{cv.GenerateID(): cv.declare_id(RX8111Component)}
).extend(i2c.i2c_device_schema(0x32))
CONF_ALARM_SECOND = "alarm_second"
CONF_ALARM_MINUTE = "alarm_minute"
CONF_ALARM_HOUR = "alarm_hour"
CONF_ALARM_WEEKDAY = "alarm_weekday"
CONF_ALARM_DAY = "alarm_day"
CONF_ALARM_ENABLED = "alarm_enabled"
CONF_ON_ALARM = "on_alarm"
CONF_TIMER_COUNT = "timer_count"
CONF_TIMER_CLOCK = "timer_clock"
CONF_TIMER_ENABLED = "timer_enabled"
CONF_ON_TIMER = "on_timer"
CONF_WEEKDAY = "weekday"
CONF_EVENT_POLL_INTERVAL = "event_poll_interval"
_ALARM_FIELDS = (
CONF_ALARM_SECOND,
CONF_ALARM_MINUTE,
CONF_ALARM_HOUR,
CONF_ALARM_WEEKDAY,
CONF_ALARM_DAY,
)
_CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(CONF_ON_ALARM, "add_on_alarm_callback"),
automation.CallbackAutomation(CONF_ON_TIMER, "add_on_timer_callback"),
)
def _validate_alarm_config(config: ConfigType) -> ConfigType:
"""Rules that depend on the derived alarm_enabled and timer_enabled defaults."""
if CONF_ON_ALARM in config and not config.get(
CONF_ALARM_ENABLED, any(f in config for f in _ALARM_FIELDS)
):
raise cv.Invalid(
"on_alarm requires alarm_enabled: true or alarm matching fields"
)
if CONF_TIMER_COUNT not in config and (
config.get(CONF_TIMER_ENABLED) or CONF_ON_TIMER in config
):
raise cv.Invalid("timer_enabled or on_timer requires timer_count")
return config
_validate_alarm_options = cv.All(
cv.has_at_most_one_key(CONF_ALARM_DAY, CONF_ALARM_WEEKDAY), _validate_alarm_config
)
CONFIG_SCHEMA = cv.All(
time.TIME_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(RX8111Component),
cv.Optional(CONF_ALARM_SECOND): cv.int_range(min=0, max=59),
cv.Optional(CONF_ALARM_MINUTE): cv.int_range(min=0, max=59),
cv.Optional(CONF_ALARM_HOUR): cv.int_range(min=0, max=23),
cv.Optional(CONF_ALARM_WEEKDAY): cv.int_range(min=1, max=7),
cv.Optional(CONF_ALARM_DAY): cv.int_range(min=1, max=31),
cv.Optional(CONF_ALARM_ENABLED): cv.boolean,
cv.Optional(CONF_ON_ALARM): automation.validate_automation({}),
cv.Optional(CONF_TIMER_COUNT): cv.int_range(min=1, max=0xFFFFFF),
cv.Optional(CONF_TIMER_CLOCK, default="1hz"): cv.enum(
TIMER_CLOCKS, lower=True
),
cv.Optional(CONF_TIMER_ENABLED): cv.boolean,
cv.Optional(CONF_ON_TIMER): automation.validate_automation({}),
# Below 500 ms a scheduler interval costs more than it saves
cv.Optional(CONF_EVENT_POLL_INTERVAL, default="1s"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(min=cv.TimePeriod(milliseconds=500)),
),
}
).extend(i2c.i2c_device_schema(0x32)),
_validate_alarm_options,
)
RX8111_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(RX8111Component)}
)
automation.register_apply_action(
"rx8111.write_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("write_time()")
# Actions that call one argument-less method on the component
for _method in (
"write_time",
"read_time",
"clear_alarm_flag",
"clear_timer_flag",
"unset_alarm",
"stop_timer",
"start_timer",
):
automation.register_apply_action(
f"rx8111.{_method}", RX8111_ACTION_SCHEMA, automation.ApplyCall(f"{_method}()")
)
_SET_ALARM_FIELDS = (CONF_SECOND, CONF_MINUTE, CONF_HOUR, CONF_WEEKDAY, CONF_DAY)
def _fill_unset_alarm_fields(config: ConfigType) -> ConfigType:
"""Marks the fields not given as unset, so set_alarm() always gets all five."""
for field in _SET_ALARM_FIELDS:
config.setdefault(field, ALARM_FIELD_UNSET)
return config
_validate_set_alarm = cv.All(
cv.has_at_least_one_key(*_SET_ALARM_FIELDS),
cv.has_at_most_one_key(CONF_WEEKDAY, CONF_DAY),
_fill_unset_alarm_fields,
)
automation.register_apply_action(
"rx8111.read_time", RX8111_ACTION_SCHEMA, automation.ApplyCall("read_time()")
"rx8111.set_alarm",
cv.All(
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(RX8111Component),
cv.Optional(CONF_SECOND): cv.templatable(cv.int_range(min=0, max=59)),
cv.Optional(CONF_MINUTE): cv.templatable(cv.int_range(min=0, max=59)),
cv.Optional(CONF_HOUR): cv.templatable(cv.int_range(min=0, max=23)),
cv.Optional(CONF_WEEKDAY): cv.templatable(cv.int_range(min=1, max=7)),
cv.Optional(CONF_DAY): cv.templatable(cv.int_range(min=1, max=31)),
}
),
_validate_set_alarm,
),
automation.ApplyCall(
"set_alarm({}, {}, {}, {}, {})",
(
(CONF_SECOND, cg.uint16),
(CONF_MINUTE, cg.uint16),
(CONF_HOUR, cg.uint16),
(CONF_WEEKDAY, cg.uint16),
(CONF_DAY, cg.uint16),
),
),
)
# The alarm registers hold a day of month, so the target must stay inside the current month cycle
_ALARM_DURATION = cv.All(
cv.positive_time_period_seconds,
cv.Range(
min=cv.TimePeriod(seconds=1), max=cv.TimePeriod(days=28), max_included=False
),
)
automation.register_apply_action(
"rx8111.schedule_alarm_in",
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(RX8111Component),
cv.Required(CONF_DURATION): cv.templatable(_ALARM_DURATION),
}
),
automation.ApplyField(CONF_DURATION, "schedule_alarm_in", cg.uint32),
)
async def to_code(config: ConfigType) -> None:
var = await i2c.new_i2c_component(config)
await time.register_time(var, config)
cg.add(var.set_event_poll_interval(config[CONF_EVENT_POLL_INTERVAL]))
cg.add_optional(var.set_alarm_second, config.get(CONF_ALARM_SECOND))
cg.add_optional(var.set_alarm_minute, config.get(CONF_ALARM_MINUTE))
cg.add_optional(var.set_alarm_hour, config.get(CONF_ALARM_HOUR))
cg.add_optional(var.set_alarm_weekday, config.get(CONF_ALARM_WEEKDAY))
cg.add_optional(var.set_alarm_day, config.get(CONF_ALARM_DAY))
cg.add(
var.set_alarm_enabled(
config.get(CONF_ALARM_ENABLED, any(f in config for f in _ALARM_FIELDS))
)
)
cg.add(var.set_alarm_explicitly_configured(CONF_ALARM_ENABLED in config))
if (count := config.get(CONF_TIMER_COUNT)) is not None:
cg.add(var.set_timer_count(count))
cg.add(var.set_timer_clock(config[CONF_TIMER_CLOCK]))
# Validation guarantees on_timer comes with a timer_count
cg.add(var.set_timer_enabled(config.get(CONF_TIMER_ENABLED, count is not None)))
cg.add(var.set_timer_configured(CONF_TIMER_ENABLED in config or count is not None))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@@ -0,0 +1,48 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ADDRESS
from esphome.types import ConfigType
CODEOWNERS = ["@KoenBreeman"]
DEPENDENCIES = ["i2c"]
DOMAIN = "seeed_multi_channel_relay"
MULTI_CONF = True
CONF_SEEED_MULTI_CHANNEL_RELAY_ID = "seeed_multi_channel_relay_id"
seeed_multi_channel_relay_ns = cg.esphome_ns.namespace("seeed_multi_channel_relay")
SeeedMultiChannelRelay = seeed_multi_channel_relay_ns.class_(
"SeeedMultiChannelRelay", cg.Component, i2c.I2CDevice
)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(SeeedMultiChannelRelay),
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(i2c.i2c_device_schema(0x11))
)
async def to_code(config: ConfigType) -> None:
await i2c.new_i2c_component(config)
automation.register_apply_action(
"seeed_multi_channel_relay.change_address",
cv.maybe_simple_value(
{
cv.GenerateID(): cv.use_id(SeeedMultiChannelRelay),
cv.Required(CONF_ADDRESS): cv.templatable(
cv.hex_int_range(min=0x08, max=0x77)
),
},
key=CONF_ADDRESS,
),
automation.ApplyField(CONF_ADDRESS, "change_address", cg.uint8),
)
@@ -0,0 +1,78 @@
#include "seeed_multi_channel_relay.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::seeed_multi_channel_relay {
ESPHOME_LOG_TAG(TAG, "seeed_multi_channel_relay");
static constexpr uint8_t CMD_CHANNEL_CTRL = 0x10;
static constexpr uint8_t CMD_SAVE_I2C_ADDR = 0x11;
static constexpr uint8_t CMD_READ_FIRMWARE_VER = 0x13;
void SeeedMultiChannelRelay::setup() {
if (!this->read_byte(CMD_READ_FIRMWARE_VER, &this->firmware_version_)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
#ifdef USE_SWITCH
// channels_ owns all 8 relays and the board has no read-back: after an ESP-only reboot the board keeps its
// relays, so every channel is set from the switches' restore modes in one write, and a channel already on
// never drops out while the others are written; a power cycle starts with all off. A switch with restore_mode
// DISABLED is driven off too, so its state matches what the next write does
uint8_t value = 0;
for (uint8_t channel = 0; channel != this->switches_.size(); channel++) {
switch_::Switch *sw = this->switches_[channel];
if (sw == nullptr)
continue;
const bool on = sw->get_initial_state_with_restore_mode().value_or(sw->is_inverted()) != sw->is_inverted();
assign_bits(value, 1 << channel, on);
}
if (!this->status_comm_result(this->write_byte(CMD_CHANNEL_CTRL, value)))
return;
this->channels_ = value;
for (uint8_t channel = 0; channel != this->switches_.size(); channel++) {
if (this->switches_[channel] != nullptr)
this->switches_[channel]->publish_state((value >> channel) & 1);
}
#endif
}
void SeeedMultiChannelRelay::change_address(uint8_t address) {
if (this->is_failed())
return;
// A lambda bypasses the schema range; a reserved address would strand the board
if (address < 0x08 || address > 0x77) {
ESP_LOGW(TAG, "I2C address 0x%02X is reserved; not stored", address);
return;
}
if (!this->status_comm_result(this->write_byte(CMD_SAVE_I2C_ADDR, address)))
return;
this->set_i2c_address(address);
ESP_LOGI(TAG, "Board now answers at 0x%02X; update address: in the configuration", address);
}
void SeeedMultiChannelRelay::dump_config() {
ESP_LOGCONFIG(TAG,
"Seeed Multi Channel Relay:\n"
" Firmware version: %u",
this->firmware_version_);
LOG_I2C_DEVICE(this);
}
bool SeeedMultiChannelRelay::set_relay(uint8_t channel, bool on) {
if (this->is_failed())
return false;
uint8_t value = this->channels_;
assign_bits(value, 1 << channel, on);
if (!this->status_comm_result(this->write_byte(CMD_CHANNEL_CTRL, value)))
return false;
this->channels_ = value;
#ifdef USE_SWITCH
if (this->switches_[channel] != nullptr)
this->switches_[channel]->publish_state(on);
#endif
return true;
}
} // namespace esphome::seeed_multi_channel_relay
@@ -0,0 +1,38 @@
#pragma once
#include <array>
#include "esphome/components/i2c/i2c.h"
#include "esphome/core/component.h"
#include "esphome/core/defines.h"
#ifdef USE_SWITCH
#include "esphome/components/switch/switch.h"
#endif
namespace esphome::seeed_multi_channel_relay {
class SeeedMultiChannelRelay : public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
/// Store a new I2C address in the board's EEPROM and talk to it there from now on.
void change_address(uint8_t address);
/// Switch relay `channel` (0-7) on or off; returns false when the write failed.
bool set_relay(uint8_t channel, bool on);
#ifdef USE_SWITCH
void register_switch(uint8_t channel, switch_::Switch *sw) { this->switches_[channel] = sw; }
#endif
protected:
uint8_t channels_{0};
uint8_t firmware_version_{0};
#ifdef USE_SWITCH
std::array<switch_::Switch *, 8> switches_{};
#endif
};
} // namespace esphome::seeed_multi_channel_relay
@@ -0,0 +1,52 @@
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_PLATFORM
import esphome.final_validate as fv
from esphome.types import ConfigType
from .. import CONF_SEEED_MULTI_CHANNEL_RELAY_ID, DOMAIN, SeeedMultiChannelRelay
DEPENDENCIES = ["seeed_multi_channel_relay"]
CONFIG_SCHEMA = switch.switch_schema(
switch.ApplySwitch, default_restore_mode="RESTORE_DEFAULT_OFF"
).extend(
{
cv.GenerateID(CONF_SEEED_MULTI_CHANNEL_RELAY_ID): cv.use_id(
SeeedMultiChannelRelay
),
cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=8),
}
)
def _validate_unique_channel(config: ConfigType) -> ConfigType:
# The hub has one slot per channel, so a second switch on it would take over the first
hub, channel = config[CONF_SEEED_MULTI_CHANNEL_RELAY_ID], config[CONF_CHANNEL]
users = [
other
for other in fv.full_config.get().get(switch.DOMAIN, [])
if other.get(CONF_PLATFORM) == DOMAIN
and other[CONF_SEEED_MULTI_CHANNEL_RELAY_ID] == hub
and other[CONF_CHANNEL] == channel
]
if len(users) > 1:
raise cv.Invalid(
f"Channel {channel} of {hub} is used by more than one switch",
path=[CONF_CHANNEL],
)
return config
FINAL_VALIDATE_SCHEMA = _validate_unique_channel
async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_SEEED_MULTI_CHANNEL_RELAY_ID])
channel = config[CONF_CHANNEL] - 1
await switch.sub_switches(config, parent=hub).create(
config,
lambda sw: hub.register_switch(channel, sw),
apply=f"set_relay({channel}, state)",
)
@@ -13,7 +13,7 @@ ESPHOME_LOG_TAG(TAG, "sendspin.switch");
void SendspinEnabledSwitch::setup() {
// The hub waits for this request, so a restore mode without a state still has to answer.
this->control(this->get_initial_state_with_restore_mode().value_or(true));
this->restore(true);
}
void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); }
@@ -28,7 +28,7 @@ void SendspinEnabledSwitch::write_state(bool state) {
void SendspinUnpairedAccessSwitch::setup() {
// The hub holds the client's first start for this, so a restore mode without a state still has to answer.
this->control(this->get_initial_state_with_restore_mode().value_or(true));
this->restore(true);
}
void SendspinUnpairedAccessSwitch::dump_config() { LOG_SWITCH("", "Sendspin Unpaired Access Switch", this); }
@@ -220,10 +220,9 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
ESP_LOGW(TAG, "Invalid parity: %u (must be 0-2)", parity);
return SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT;
}
if (flow_control) {
ESP_LOGW(TAG, "Hardware flow control requested but is not yet supported");
return SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED;
}
// The API carries only RTS/CTS, as CRTSCTS does
const uart::UARTFlowControl uart_flow_control =
flow_control ? uart::UART_FLOW_CONTROL_RTS_CTS : uart::UART_FLOW_CONTROL_NONE;
// Skip a no-op reconfigure. Clients routinely re-send identical settings on every
// port open, and on a USB UART each apply is a CDC SET_LINE_CODING control transfer.
@@ -235,7 +234,8 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
uart::UART_CONFIG_PARITY_ODD,
};
if (uart_comp->get_baud_rate() == baudrate && uart_comp->get_stop_bits() == stop_bits &&
uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity]) {
uart_comp->get_data_bits() == data_size && uart_comp->get_parity() == PARITY_MAP[parity] &&
uart_comp->get_flow_control() == uart_flow_control) {
ESP_LOGV(TAG, "Settings unchanged, skipping reconfigure [%" PRIu32 "]", this->instance_index_);
return SerialProxyResult::SERIAL_PROXY_RESULT_OK;
}
@@ -246,6 +246,7 @@ SerialProxyResult SerialProxy::configure(api::APIConnection *api_connection, uin
uart_comp->set_data_bits(data_size);
uart_comp->set_parity(PARITY_MAP[parity]);
uart_comp->set_flow_control(uart_flow_control);
// load_settings() is available on ESP8266 and ESP32 platforms
#if defined(USE_ESP8266) || defined(USE_ESP32)
+106 -13
View File
@@ -18,8 +18,10 @@ from esphome.components.esp32 import (
VARIANT_ESP32S3,
VARIANT_ESP32S31,
only_on_variant,
require_idf_version,
)
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
from esphome.components.zephyr import zephyr_add_overlay, zephyr_add_prj_conf
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -38,6 +40,7 @@ from esphome.const import (
KEY_VARIANT,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_NRF52,
PLATFORM_RP2,
PlatformFramework,
)
@@ -58,9 +61,17 @@ SPIMode = spi_ns.enum("SPIMode")
PLATFORM_SPI_CLOCKS = {
PLATFORM_ESP8266: 40e6,
PLATFORM_ESP32: 80e6,
# nRF52 exposes spi2 (SPIM2), whose documented max data rate is 8 MHz;
# the 16/32 MHz "fast" rates are only available on the separate SPIM3
# peripheral, which isn't used here.
PLATFORM_NRF52: 8e6,
PLATFORM_RP2: 62.5e6,
}
# SPIM2 only implements these discrete clock steps - unlike the other platforms
# it does not derive an arbitrary rate from a divisor of its max clock.
PLATFORM_NRF52_SPI_RATES = (125e3, 250e3, 500e3, 1e6, 2e6, 4e6, 8e6)
MAX_DATA_RATE_ERROR = 0.05 # Max allowable actual data rate difference from requested
@@ -97,6 +108,9 @@ def _frequency_validator(value: Any) -> float:
)
if value < 1000:
raise cv.Invalid("The configured SPI data rate must be at least 1000Hz")
if platform == PLATFORM_NRF52:
# The step check needs the device's bus, which is only known in final validation
return value
divisor = round(frequency / value)
actual = frequency / divisor
error = abs(actual - value) / value
@@ -147,8 +161,7 @@ def _validate_psram_dma(value: Any) -> bool:
if not value:
return value
return cv.All(
cv.only_on_esp32,
cv.only_with_framework("esp-idf"),
require_idf_version(cv.Version(5, 5, 3), "psram_dma"),
only_on_variant(
supported=[
VARIANT_ESP32C5,
@@ -159,7 +172,6 @@ def _validate_psram_dma(value: Any) -> bool:
],
msg_prefix="PSRAM DMA",
),
cv.require_framework_version(esp_idf=cv.Version(5, 5, 3)),
cv.requires_component(PSRAM_DOMAIN),
)(value)
@@ -209,6 +221,8 @@ def get_hw_interface_list() -> list[list[str]]:
return [["spi", "spi2"], ["spi3"]]
if target_platform == PLATFORM_RP2:
return [["spi"], ["spi1"]]
if target_platform == PLATFORM_NRF52:
return [["spi", "spi2"]]
return []
@@ -275,6 +289,14 @@ def validate_hw_pins(spi: ConfigType, index: int = -1) -> bool:
if target_platform == PLATFORM_ESP32:
return clk_pin_no >= 0
if target_platform == PLATFORM_NRF52:
# Expander pin numbers would be mapped into NRF_PSEL and route the bus to the wrong pads
return all(
pins.PIN_SCHEMA_REGISTRY.get_key(spi[key]) == PLATFORM_NRF52
for key in (CONF_CLK_PIN, CONF_MOSI_PIN, CONF_MISO_PIN)
if key in spi
)
if target_platform == PLATFORM_RP2:
if index == -1:
matches = list(
@@ -353,6 +375,8 @@ def get_spi_interface(index: int) -> str:
# Arduino code follows
if platform == PLATFORM_RP2:
return ["&SPI", "&SPI1"][index]
if platform == PLATFORM_NRF52:
return "DEVICE_DT_GET(DT_NODELABEL(spi2))"
if index == 0:
return "&SPI"
return "new SPIClass(HSPI)"
@@ -377,7 +401,7 @@ SPI_SINGLE_SCHEMA = cv.All(
}
),
cv.has_at_least_one_key(CONF_MISO_PIN, CONF_MOSI_PIN),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2]),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_NRF52, PLATFORM_RP2]),
)
@@ -427,6 +451,45 @@ CONFIG_SCHEMA = cv.All(
)
def _add_nrf52_spi2_overlay(spi: ConfigType) -> None:
"""Route SPIM2 to the configured pins; a board file may leave spi2 on the legacy non-DMA driver."""
zephyr_add_prj_conf("SPI", True)
signals = [("SCK", spi[CONF_CLK_PIN])]
if (mosi := spi.get(CONF_MOSI_PIN)) is not None:
signals.append(("MOSI", mosi))
if (miso := spi.get(CONF_MISO_PIN)) is not None:
signals.append(("MISO", miso))
psels = ", ".join(
f"<NRF_PSEL(SPIM_{signal}, {pin[CONF_NUMBER] // 32}, {pin[CONF_NUMBER] % 32})>"
for signal, pin in signals
)
zephyr_add_overlay(
f"""
&pinctrl {{
spi2_default: spi2_default {{
group1 {{
psels = {psels};
}};
}};
spi2_sleep: spi2_sleep {{
group1 {{
psels = {psels};
low-power-enable;
}};
}};
}};
&spi2 {{
compatible = "nordic,nrf-spim";
status = "okay";
pinctrl-0 = <&spi2_default>;
pinctrl-1 = <&spi2_sleep>;
pinctrl-names = "default", "sleep";
overrun-character = <0>;
}};
"""
)
@coroutine_with_priority(CoroPriority.BUS)
async def to_code(configs: list[ConfigType]) -> None:
cg.add_define("USE_SPI")
@@ -444,11 +507,15 @@ async def to_code(configs: list[ConfigType]) -> None:
if (index := spi.get(CONF_INTERFACE_INDEX)) is not None:
interface = get_spi_interface(index)
cg.add(var.set_interface(cg.RawExpression(interface)))
cg.add(
var.set_interface_name(
re.sub(r"\W", "", interface.replace("new SPIClass", ""))
if CORE.using_zephyr:
_add_nrf52_spi2_overlay(spi)
cg.add(var.set_interface_name("spi2"))
else:
cg.add(
var.set_interface_name(
re.sub(r"\W", "", interface.replace("new SPIClass", ""))
)
)
)
def spi_device_schema(
@@ -545,20 +612,45 @@ def _walk_config(value: Any, path: tuple[Any, ...] = ()):
yield from _walk_config(child, (*path, index))
def _validate_nrf52_hw_data_rate(value: float) -> None:
lowest = PLATFORM_NRF52_SPI_RATES[0]
if value < lowest:
raise cv.Invalid(
f"The configured SPI data rate ({_render_hz(value)}) is below {_render_hz(lowest)}, "
"the lowest rate of the nRF52 hardware SPI interface"
)
# The SPIM driver runs at the highest step not above the request
actual = max(rate for rate in PLATFORM_NRF52_SPI_RATES if rate <= value)
if abs(actual - value) / value > MAX_DATA_RATE_ERROR:
raise cv.Invalid(
f"The configured SPI data rate ({_render_hz(value)}) is not available for this chip - closest is {_render_hz(actual)}"
)
def _final_validate(config: Any) -> Any:
buses = config if isinstance(config, list) else [config]
software_bus_ids = {
bus[CONF_ID] for bus in buses if CONF_INTERFACE_INDEX not in bus
}
if not software_bus_ids:
nrf52_hw_bus_ids = (
{bus[CONF_ID] for bus in buses if CONF_INTERFACE_INDEX in bus}
if get_target_platform() == PLATFORM_NRF52
else set()
)
if not software_bus_ids and not nrf52_hw_bus_ids:
return config
for candidate, path in _walk_config(fv.full_config.get()):
if (
candidate.get(CONF_PSRAM_DMA)
and candidate.get(CONF_SPI_ID) in software_bus_ids
):
if (spi_id := candidate.get(CONF_SPI_ID)) is None:
continue
if candidate.get(CONF_PSRAM_DMA) and spi_id in software_bus_ids:
with cv.prepend_path([cv.ROOT_CONFIG_PATH, *path, CONF_PSRAM_DMA]):
raise cv.Invalid("psram_dma requires a hardware SPI interface")
if (
spi_id in nrf52_hw_bus_ids
and (data_rate := candidate.get(CONF_DATA_RATE)) is not None
):
with cv.prepend_path([cv.ROOT_CONFIG_PATH, *path, CONF_DATA_RATE]):
_validate_nrf52_hw_data_rate(data_rate)
return config
@@ -578,5 +670,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
PlatformFramework.ESP32_ARDUINO,
PlatformFramework.ESP32_IDF,
},
"spi_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR},
}
)
+26 -2
View File
@@ -2,6 +2,10 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
#ifdef USE_ZEPHYR
#include "spi_zephyr.h"
#endif
namespace esphome::spi {
ESPHOME_LOG_TAG(TAG, "spi");
@@ -20,6 +24,9 @@ SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIB
ESP_LOGE(TAG, "Device already registered");
return this->devices_[device];
}
if (this->spi_bus_ == nullptr) {
return SPIDelegate::NULL_DELEGATE; // the bus failed in setup(); the device stays not ready
}
SPIDelegate *delegate =
this->spi_bus_->get_delegate(data_rate, bit_order, mode, cs_pin, release_device, write_only); // NOLINT
this->devices_[device] = delegate;
@@ -27,6 +34,9 @@ SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIB
}
void SPIComponent::unregister_device(SPIClient *device) {
if (this->spi_bus_ == nullptr) {
return; // nothing was registered on a failed bus
}
if (!this->devices_.contains(device)) {
esph_log_e(TAG, "Device not registered");
return;
@@ -70,6 +80,9 @@ void SPIComponent::dump_config() {
for (size_t i = 0; i != this->data_pins_.size(); i++) {
ESP_LOGCONFIG(TAG, " Data pin %zu: GPIO%d", i, this->data_pins_[i]);
}
if (this->spi_bus_ == nullptr) {
return;
}
if (this->spi_bus_->is_hw()) {
ESP_LOGCONFIG(TAG, " Using HW SPI: %s", this->interface_name_);
} else {
@@ -118,8 +131,19 @@ uint16_t SPIDelegateBitBash::transfer_(uint16_t data, size_t num_bits) {
return out_data;
}
#if !defined(USE_ESP32) && !defined(USE_ARDUINO)
// Stub for unsupported platforms (host, Zephyr, etc.) - hardware SPI is unavailable
#if defined(USE_ZEPHYR)
SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo, GPIOPin *sdi,
const std::vector<uint8_t> &data_pins) {
if (!device_is_ready(interface)) {
ESP_LOGE(TAG, "SPI device is not ready");
return nullptr;
}
return new ZephyrSPIBus(interface); // NOLINT
}
#elif !defined(USE_ESP32) && !defined(USE_ARDUINO)
// Stub for unsupported platforms (host, etc.) - hardware SPI is unavailable
SPIBus *SPIComponent::get_bus(SPIInterface interface, GPIOPin *clk, GPIOPin *sdo, GPIOPin *sdi,
const std::vector<uint8_t> &data_pins) {
return nullptr;
+7 -1
View File
@@ -24,9 +24,14 @@ using SPIInterface = SPIClassRP2040 *;
using SPIInterface = SPIClass *;
#endif
#elif defined(USE_ZEPHYR)
struct device;
using SPIInterface = const device *;
#elif defined(USE_HOST) || defined(CLANG_TIDY)
using SPIInterface = void *; // Stub for platforms without SPI (e.g., host, Zephyr)
using SPIInterface = void *; // Stub for platforms without SPI (e.g., host)
#endif // USE_ESP32 / USE_ARDUINO
@@ -178,6 +183,7 @@ class SPIDelegateDummy;
// a thin wrapper over SPIClass.
class SPIDelegate {
friend class SPIClient;
friend class SPIComponent; // hands out NULL_DELEGATE for a failed bus
public:
SPIDelegate() = default;
+114
View File
@@ -0,0 +1,114 @@
#ifdef USE_ZEPHYR
#include "spi_zephyr.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstring>
namespace esphome::spi {
ESPHOME_LOG_TAG(TAG, "spi.zephyr");
// Words swapped per transceive for MSB-first 16-bit writes
static constexpr size_t SWAP_BUFFER_WORDS = 64;
static spi_operation_t build_operation(SPIBitOrder bit_order, SPIMode mode) {
spi_operation_t op = SPI_OP_MODE_MASTER | SPI_WORD_SET(8);
op |= (bit_order == BIT_ORDER_LSB_FIRST) ? SPI_TRANSFER_LSB : SPI_TRANSFER_MSB;
if (Utility::get_polarity(mode) == CLOCK_POLARITY_HIGH)
op |= SPI_MODE_CPOL;
if (Utility::get_phase(mode) == CLOCK_PHASE_TRAILING)
op |= SPI_MODE_CPHA;
return op;
}
// release_device and write_only are not needed here: the Zephyr driver locks the bus per transfer,
// and a null rx buffer already skips the receive DMA
SPIDelegate *ZephyrSPIBus::get_delegate(uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin,
bool release_device, bool write_only) {
return new ZephyrSPIDelegate(this, data_rate, bit_order, mode, cs_pin); // NOLINT
}
ZephyrSPIDelegate::ZephyrSPIDelegate(ZephyrSPIBus *bus, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode,
GPIOPin *cs_pin)
: SPIDelegate(data_rate, bit_order, mode, cs_pin), bus_(bus) {
this->cfg_.frequency = data_rate;
this->cfg_.operation = build_operation(bit_order, mode);
}
// The driver skips a config at the address it applied last, so a delegate created later in this allocation
// would run with the old settings: hand it the bus's own config before this one goes away.
ZephyrSPIDelegate::~ZephyrSPIDelegate() {
if (this->bus_->active_ != this)
return;
this->bus_->active_ = nullptr;
spi_transceive(this->bus_->dev_, &this->bus_->idle_cfg_, nullptr, nullptr);
}
// The driver applies cfg_ (and the SCK idle level for CPOL) at the start of each transfer, so switch
// it over with an empty transfer before CS goes low; otherwise SCK changes level while CS is active.
void ZephyrSPIDelegate::begin_transaction() {
if (this->bus_->active_ != this) {
const int err = spi_transceive(this->bus_->dev_, &this->cfg_, nullptr, nullptr);
if (err == 0) {
this->bus_->active_ = this;
} else {
ESP_LOGE(TAG, "spi_transceive failed: %d", err);
}
}
SPIDelegate::begin_transaction();
}
// Buffers EasyDMA cannot reach (flash tables, for one) are copied by the nrfx SPIM driver itself,
// CONFIG_SPI_NRFX_RAM_BUFFER_SIZE bytes at a time
void ZephyrSPIDelegate::transceive_(const uint8_t *tx, uint8_t *rx, size_t length) {
if (length == 0)
return;
const spi_buf tx_buf{const_cast<uint8_t *>(tx), length}; // NOLINT(cppcoreguidelines-pro-type-const-cast)
const spi_buf rx_buf{rx, length};
const spi_buf_set tx_set{&tx_buf, 1};
const spi_buf_set rx_set{&rx_buf, 1};
const int err = spi_transceive(this->bus_->dev_, &this->cfg_, tx != nullptr ? &tx_set : nullptr,
rx != nullptr ? &rx_set : nullptr);
if (err != 0) {
ESP_LOGE(TAG, "spi_transceive failed: %d", err);
if (rx != nullptr)
memset(rx, 0, length);
}
}
uint8_t ZephyrSPIDelegate::transfer(uint8_t data) {
uint8_t rx = 0;
this->transceive_(&data, &rx, 1);
return rx;
}
void ZephyrSPIDelegate::transfer(const uint8_t *txbuf, uint8_t *rxbuf, size_t length) {
this->transceive_(txbuf, rxbuf, length);
}
void ZephyrSPIDelegate::write_array(const uint8_t *ptr, size_t length) { this->transceive_(ptr, nullptr, length); }
void ZephyrSPIDelegate::write_array16(const uint16_t *data, size_t length) {
if (this->bit_order_ == BIT_ORDER_LSB_FIRST) {
this->write_array(reinterpret_cast<const uint8_t *>(data), length * 2);
return;
}
uint16_t buffer[SWAP_BUFFER_WORDS];
while (length != 0) {
const size_t partial = std::min(length, SWAP_BUFFER_WORDS);
for (size_t i = 0; i != partial; i++)
buffer[i] = byteswap(data[i]);
this->write_array(reinterpret_cast<const uint8_t *>(buffer), partial * 2);
data += partial;
length -= partial;
}
}
void ZephyrSPIDelegate::read_array(uint8_t *ptr, size_t length) { this->transceive_(nullptr, ptr, length); }
} // namespace esphome::spi
#endif // USE_ZEPHYR
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#ifdef USE_ZEPHYR
#include "spi.h"
#include <zephyr/drivers/spi.h>
namespace esphome::spi {
class ZephyrSPIDelegate;
class ZephyrSPIBus : public SPIBus {
friend class ZephyrSPIDelegate;
public:
explicit ZephyrSPIBus(const device *dev) : dev_(dev) {
this->idle_cfg_.frequency = IDLE_FREQUENCY;
this->idle_cfg_.operation = SPI_OP_MODE_MASTER | SPI_WORD_SET(8);
}
bool is_hw() override { return true; }
SPIDelegate *get_delegate(uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin,
bool release_device, bool write_only) override;
protected:
// The lowest rate the SPIM driver accepts; only ever applied with no device selected
static constexpr uint32_t IDLE_FREQUENCY = 125000;
const device *dev_;
// Delegate whose mode and frequency the driver last applied
const ZephyrSPIDelegate *active_{nullptr};
// Applied when the active delegate is deleted, so the driver never keeps a pointer to a freed config
spi_config idle_cfg_{};
};
class ZephyrSPIDelegate : public SPIDelegate {
public:
ZephyrSPIDelegate(ZephyrSPIBus *bus, uint32_t data_rate, SPIBitOrder bit_order, SPIMode mode, GPIOPin *cs_pin);
~ZephyrSPIDelegate() override;
void begin_transaction() override;
uint8_t transfer(uint8_t data) override;
void transfer(const uint8_t *txbuf, uint8_t *rxbuf, size_t length) override;
void write_array(const uint8_t *ptr, size_t length) override;
void write_array16(const uint16_t *data, size_t length) override;
void read_array(uint8_t *ptr, size_t length) override;
protected:
/// One spi_transceive; rx (if given) is zeroed when the transfer fails
void transceive_(const uint8_t *tx, uint8_t *rx, size_t length);
ZephyrSPIBus *bus_;
// CS is driven by ESPHome via SPIDelegate::cs_pin_; cfg_.cs stays unset so
// the Zephyr driver never touches a devicetree cs-gpios pin itself.
spi_config cfg_{};
};
} // namespace esphome::spi
#endif // USE_ZEPHYR
+135 -13
View File
@@ -1,4 +1,6 @@
from esphome import automation
from collections.abc import Callable
from esphome import automation, preferences
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
@@ -8,13 +10,16 @@ from esphome.const import (
CONF_ENTITY_CATEGORY,
CONF_ICON,
CONF_ID,
CONF_INITIAL_STATE,
CONF_INVERTED,
CONF_MQTT_ID,
CONF_ON_STATE,
CONF_ON_TURN_OFF,
CONF_ON_TURN_ON,
CONF_RESTORE_MODE,
CONF_RESTORE_STATE,
CONF_STATE,
CONF_STORAGE,
CONF_WEB_SERVER,
DEVICE_CLASS_EMPTY,
DEVICE_CLASS_OUTLET,
@@ -22,10 +27,14 @@ from esphome.const import (
)
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
RESTORE_STATE_INITIAL,
RESTORE_STATE_INVERT,
RESTORE_STATE_KEEP,
ApplyEntity,
SubEntities,
entity_duplicate_validator,
queue_entity_register,
restore_state_schema,
setup_device_class,
setup_entity,
)
@@ -47,6 +56,8 @@ SwitchPtr = Switch.operator("ptr")
ApplySwitch = switch_ns.class_("ApplySwitch", Switch)
SwitchRestoreMode = switch_ns.enum("SwitchRestoreMode")
RESTORE_MODE_STORAGE_FLASH_MASK = switch_ns.RESTORE_MODE_STORAGE_FLASH_MASK
RESTORE_MODE_STORAGE_RTC_MASK = switch_ns.RESTORE_MODE_STORAGE_RTC_MASK
RESTORE_MODES = {
"RESTORE_DEFAULT_OFF": SwitchRestoreMode.SWITCH_RESTORE_DEFAULT_OFF,
@@ -60,6 +71,115 @@ RESTORE_MODES = {
validate_device_class = cv.one_of(*DEVICE_CLASSES, lower=True)
validate_restore_mode = cv.enum(RESTORE_MODES, upper=True, space="_")
def _validate_restore_state_storage(config: ConfigType) -> ConfigType:
if CONF_STORAGE in config and config[CONF_STATE] == RESTORE_STATE_INITIAL:
raise cv.Invalid(
"'storage' only applies when a saved state is restored (KEEP or INVERT)",
path=[CONF_STORAGE],
)
return config
def _validate_restore_state_state(value: object) -> str:
# light accepts ON/OFF here (restore, then force a state); a switch has no such mode, and
# YAML turns ON into True, so say what to use instead of asking for quotes
if isinstance(value, bool) or (
isinstance(value, str) and value.upper() in ("ON", "OFF")
):
raise cv.Invalid(
"a switch can't restore and then force ON or OFF; to always start in a fixed state, "
"use 'initial_state: ON' (or OFF) with 'restore_state: none'"
)
return cv.one_of(
RESTORE_STATE_KEEP, RESTORE_STATE_INVERT, RESTORE_STATE_INITIAL, upper=True
)(value)
_RESTORE_STATE_FIELDS_SCHEMA = cv.Schema(
{
cv.Optional(
CONF_STATE, default=RESTORE_STATE_KEEP
): _validate_restore_state_state,
# No default: when unset, the existing restore_mode storage applies (on ESP8266,
# restore_from_flash decides), as with wifi
cv.Optional(CONF_STORAGE): preferences.validate_storage,
}
)
_RESTORE_STATE_FIELDS_SCHEMA.add_extra(_validate_restore_state_storage)
# A mapping of `state` and `storage`, or the shorthands `all` (KEEP) and `none` (INITIAL)
RESTORE_STATE_SCHEMA = restore_state_schema(
_RESTORE_STATE_FIELDS_SCHEMA, {CONF_STATE: RESTORE_STATE_INITIAL}
)
def _validate_initial_state(config: ConfigType) -> ConfigType:
if CONF_INITIAL_STATE in config and CONF_RESTORE_MODE in config:
raise cv.Invalid(
"'initial_state' can't be combined with 'restore_mode', which already sets "
"the boot state; use 'restore_state' instead",
path=[CONF_INITIAL_STATE],
)
return config
# (restore_state state, initial_state) -> restore mode
_RESTORE_MODE_FOR_STATE = {
(RESTORE_STATE_KEEP, False): "RESTORE_DEFAULT_OFF",
(RESTORE_STATE_KEEP, True): "RESTORE_DEFAULT_ON",
(RESTORE_STATE_INVERT, False): "RESTORE_INVERTED_DEFAULT_OFF",
(RESTORE_STATE_INVERT, True): "RESTORE_INVERTED_DEFAULT_ON",
(RESTORE_STATE_INITIAL, False): "ALWAYS_OFF",
(RESTORE_STATE_INITIAL, True): "ALWAYS_ON",
}
_STATE_FOR_RESTORE_MODE = {
mode: state for (state, _), mode in _RESTORE_MODE_FOR_STATE.items()
}
def _apply_default_restore_mode(
default_restore_mode: str,
) -> Callable[[ConfigType], ConfigType]:
# cv.Exclusive has no default, so apply it here when no restore option is configured.
# With only initial_state set, a restoring default keeps restoring and initial_state
# supplies the state used when nothing is saved.
default = validate_restore_mode(default_restore_mode)
state = _STATE_FOR_RESTORE_MODE.get(str(default), RESTORE_STATE_INITIAL)
def validator(config: ConfigType) -> ConfigType:
if CONF_RESTORE_MODE in config or CONF_RESTORE_STATE in config:
return config
if CONF_INITIAL_STATE in config:
config[CONF_RESTORE_STATE] = {CONF_STATE: state}
else:
config[CONF_RESTORE_MODE] = default
return config
return validator
def _resolve_restore_mode(config: ConfigType) -> MockObj | str:
"""Translate `initial_state` / `restore_state` into one of the RESTORE_MODES, with the storage bit."""
if (restore_mode := config.get(CONF_RESTORE_MODE)) is not None:
return restore_mode
restore_state = config.get(CONF_RESTORE_STATE, {})
state = restore_state.get(CONF_STATE, RESTORE_STATE_INITIAL)
mode = RESTORE_MODES[
_RESTORE_MODE_FOR_STATE[state, config.get(CONF_INITIAL_STATE, False)]
]
if (storage := restore_state.get(CONF_STORAGE)) is None:
return mode
cg.add_define("USE_SWITCH_STORAGE")
mask = (
RESTORE_MODE_STORAGE_FLASH_MASK
if preferences.is_in_flash(storage)
else RESTORE_MODE_STORAGE_RTC_MASK
)
return cg.RawExpression(f"static_cast<{SwitchRestoreMode}>({mode} | {mask})")
_SWITCH_SCHEMA = (
@@ -70,9 +190,9 @@ _SWITCH_SCHEMA = (
{
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTSwitchComponent),
cv.Optional(CONF_INVERTED): cv.boolean,
cv.Optional(CONF_RESTORE_MODE, default="ALWAYS_OFF"): cv.enum(
RESTORE_MODES, upper=True, space="_"
),
cv.Exclusive(CONF_RESTORE_MODE, "restore"): validate_restore_mode,
cv.Exclusive(CONF_RESTORE_STATE, "restore"): RESTORE_STATE_SCHEMA,
cv.Optional(CONF_INITIAL_STATE): cv.boolean,
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
cv.Optional(CONF_ON_TURN_ON): automation.validate_automation({}),
cv.Optional(CONF_ON_TURN_OFF): automation.validate_automation({}),
@@ -86,6 +206,7 @@ _SWITCH_SCHEMA = (
_SWITCH_SCHEMA.add_extra(entity_duplicate_validator("switch"))
_SWITCH_SCHEMA.add_extra(zigbee.validate_switch)
_SWITCH_SCHEMA.add_extra(_validate_initial_state)
def switch_schema(
@@ -103,13 +224,6 @@ def switch_schema(
(CONF_DEVICE_CLASS, device_class, validate_device_class),
(CONF_ENTITY_CATEGORY, entity_category, cv.entity_category),
(CONF_ICON, icon, cv.icon),
(
CONF_RESTORE_MODE,
default_restore_mode,
cv.enum(RESTORE_MODES, upper=True, space="_")
if default_restore_mode is not cv.UNDEFINED
else cv.UNDEFINED,
),
]:
if default is not cv.UNDEFINED:
schema[cv.Optional(key, default=default)] = validator
@@ -119,7 +233,15 @@ def switch_schema(
"Inverted is not supported for this platform!"
)
return _SWITCH_SCHEMA.extend(schema)
result = _SWITCH_SCHEMA.extend(schema)
result.add_extra(
_apply_default_restore_mode(
"ALWAYS_OFF"
if default_restore_mode is cv.UNDEFINED
else default_restore_mode
)
)
return result
_CALLBACK_AUTOMATIONS = (
@@ -159,7 +281,7 @@ async def setup_switch_core_(var, config):
setup_device_class(config)
cg.add(var.set_restore_mode(config[CONF_RESTORE_MODE]))
cg.add(var.set_restore_mode(_resolve_restore_mode(config)))
await zigbee.setup_switch(var, config)
+15 -1
View File
@@ -33,7 +33,14 @@ optional<bool> Switch::get_initial_state() {
if (!(restore_mode & RESTORE_MODE_PERSISTENT_MASK))
return {};
this->rtc_ = this->make_entity_preference<bool>();
#ifdef USE_SWITCH_STORAGE
if (restore_mode & RESTORE_MODE_STORAGE_MASK) {
this->rtc_ = this->make_entity_preference<bool>(0, restore_mode & RESTORE_MODE_STORAGE_FLASH_MASK);
} else
#endif
{
this->rtc_ = this->make_entity_preference<bool>();
}
bool initial_state;
if (!this->rtc_.load(&initial_state))
return {};
@@ -53,6 +60,13 @@ optional<bool> Switch::get_initial_state_with_restore_mode() {
}
return initial_state;
}
void Switch::restore() {
if (auto initial = this->get_initial_state_with_restore_mode())
this->control(*initial);
}
void Switch::restore(bool when_disabled) {
this->control(this->get_initial_state_with_restore_mode().value_or(when_disabled));
}
void Switch::publish_state(bool state) {
if (!this->publish_dedup_.next(state))
return;
+13 -3
View File
@@ -14,11 +14,16 @@ namespace esphome::switch_ {
public: \
void set_##name##_switch(switch_::Switch *s) { this->name##_switch_ = s; }
// bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled
// bit0: on/off. bit1: persistent. bit2: inverted. bit3: disabled.
// bit4: restore from flash. bit5: restore from RTC memory (neither: platform default).
// Code generation ors the storage bit into the restore mode passed to set_restore_mode().
constexpr int RESTORE_MODE_ON_MASK = 0x01;
constexpr int RESTORE_MODE_PERSISTENT_MASK = 0x02;
constexpr int RESTORE_MODE_INVERTED_MASK = 0x04;
constexpr int RESTORE_MODE_DISABLED_MASK = 0x08;
constexpr int RESTORE_MODE_STORAGE_FLASH_MASK = 0x10;
constexpr int RESTORE_MODE_STORAGE_RTC_MASK = 0x20;
constexpr int RESTORE_MODE_STORAGE_MASK = RESTORE_MODE_STORAGE_FLASH_MASK | RESTORE_MODE_STORAGE_RTC_MASK;
enum SwitchRestoreMode : uint8_t {
SWITCH_ALWAYS_OFF = !RESTORE_MODE_ON_MASK,
@@ -110,6 +115,11 @@ class Switch : public EntityBase {
*/
optional<bool> get_initial_state_with_restore_mode();
/// Drive the switch to the state its restore mode yields, through control(), so inversion and
/// write_state() apply; a disabled restore mode drives \p when_disabled, or nothing without one.
void restore();
void restore(bool when_disabled);
/** Return whether this switch uses an assumed state - i.e. if both the ON/OFF actions should be displayed in Home
* Assistant because the real state is unknown.
*
@@ -139,9 +149,9 @@ class Switch : public EntityBase {
LazyCallbackManager<void(bool)> state_callback_{};
// Small types grouped together
Deduplicator<bool> publish_dedup_; // 2 bytes (bool has_value_ + bool last_value_)
Deduplicator<bool> publish_dedup_; // 3 bytes (bool has_value_ + bool value_unknown_ + bool last_value_)
bool inverted_{false}; // 1 byte
// Total: 3 bytes, 1 byte padding
// Total: 4 bytes, no padding
};
#define LOG_SWITCH(prefix, type, obj) log_switch((TAG), (prefix), LOG_STR_LITERAL(type), (obj))
+52 -3
View File
@@ -1,3 +1,5 @@
import logging
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
@@ -9,6 +11,7 @@ from esphome.const import (
CONF_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_APPARENT_POWER,
DEVICE_CLASS_CURRENT,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_ENERGY,
DEVICE_CLASS_POWER,
DEVICE_CLASS_REACTIVE_ENERGY,
@@ -17,12 +20,15 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
STATE_CLASS_TOTAL_INCREASING,
UNIT_AMPERE,
UNIT_HOUR,
UNIT_KILOVOLT_AMPS,
UNIT_KILOVOLT_AMPS_REACTIVE_HOURS,
UNIT_KILOWATT,
UNIT_KILOWATT_HOURS,
UNIT_MILLIAMP,
UNIT_MILLIVOLT,
UNIT_MINUTE,
UNIT_SECOND,
UNIT_VOLT,
UNIT_VOLT_AMPS,
UNIT_VOLT_AMPS_REACTIVE_HOURS,
@@ -33,6 +39,8 @@ from esphome.types import ConfigType
from .. import CONF_TAG_NAME, CONF_TELEINFO_ID, TELEINFO_LISTENER_SCHEMA, teleinfo_ns
_LOGGER = logging.getLogger(__name__)
TeleInfoSensor = teleinfo_ns.class_("TeleInfoSensor", sensor.Sensor, cg.Component)
@@ -59,6 +67,9 @@ _APPARENT_POWER_KVA = _preset(
UNIT_KILOVOLT_AMPS, DEVICE_CLASS_APPARENT_POWER, STATE_CLASS_MEASUREMENT
)
_POWER = _preset(UNIT_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT)
_DURATION_MIN = _preset(UNIT_MINUTE, DEVICE_CLASS_DURATION, STATE_CLASS_MEASUREMENT)
# Numeric values without a physical unit (indexes, bit fields, versions)
_NO_UNIT: dict[str, str] = {}
# Legacy defaults, kept for tags without a known preset
_LEGACY_DEFAULT = {**_ENERGY, CONF_ICON: ICON_FLASH}
@@ -75,6 +86,7 @@ _COMPATIBLE_UNITS = {
DEVICE_CLASS_VOLTAGE: {UNIT_VOLT, UNIT_MILLIVOLT},
DEVICE_CLASS_APPARENT_POWER: {UNIT_VOLT_AMPS, UNIT_KILOVOLT_AMPS},
DEVICE_CLASS_POWER: {UNIT_WATT, UNIT_KILOWATT},
DEVICE_CLASS_DURATION: {UNIT_SECOND, UNIT_MINUTE, UNIT_HOUR},
}
# Presets by tag prefix. No prefix is a prefix of another, so order is irrelevant.
@@ -90,6 +102,10 @@ TIC_TAG_CONFIGS = {
"CC": _POWER,
"PREF": _APPARENT_POWER_KVA,
"PCOUP": _APPARENT_POWER_KVA,
"VTIC": _NO_UNIT,
"NTARF": _NO_UNIT,
"NJOURF": _NO_UNIT,
"RELAIS": _NO_UNIT,
# Historical mode
"BASE": _ENERGY,
"HCH": _ENERGY,
@@ -101,15 +117,42 @@ TIC_TAG_CONFIGS = {
"ADPS": _CURRENT,
"ADIR": _CURRENT,
"PAPP": _APPARENT_POWER_VA,
"PMAX": _POWER,
"PMAX": _APPARENT_POWER_VA,
"PEJP": _DURATION_MIN,
}
# Prefixes of text tags, which a sensor cannot publish; a text_sensor should be used
# instead. They get no preset, and no prefix here is a prefix of a preset one either.
TIC_TEXT_TAGS = (
# Standard mode
"ADSC",
"DATE",
"NGTF",
"LTARF",
"STGE",
"DPM",
"FPM",
"MSG",
"PRM",
"PJOURF",
"PPOINTE",
# Historical mode
"ADCO",
"OPTARIF",
"PTEC",
"DEMAIN",
"HHPHC",
"MOTDETAT",
"PPOT",
)
def apply_tag_config(config: ConfigType) -> ConfigType:
"""Apply preset configurations based on the tag name.
Only keys not set by the user are filled in. For known tags, a preset unit or
device class is dropped if it is incompatible with the one set by the user.
Known text tags get no preset and log a warning, as a sensor cannot publish them.
Unknown tags get the legacy defaults, merged key by key as before.
"""
if CONF_TAG_NAME not in config:
@@ -117,6 +160,12 @@ def apply_tag_config(config: ConfigType) -> ConfigType:
# Runs before cv.string, so the raw value may not be a string yet
tag_name = str(config[CONF_TAG_NAME])
if tag_name.startswith(TIC_TEXT_TAGS):
_LOGGER.warning(
"TeleInfo tag '%s' holds a text value, use a text_sensor instead", tag_name
)
return config
preset = next(
(p for prefix, p in TIC_TAG_CONFIGS.items() if tag_name.startswith(prefix)),
None,
@@ -128,8 +177,8 @@ def apply_tag_config(config: ConfigType) -> ConfigType:
config.setdefault(key, value)
return config
unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset[CONF_UNIT_OF_MEASUREMENT])
device_class = config.get(CONF_DEVICE_CLASS, preset[CONF_DEVICE_CLASS])
unit = config.get(CONF_UNIT_OF_MEASUREMENT, preset.get(CONF_UNIT_OF_MEASUREMENT))
device_class = config.get(CONF_DEVICE_CLASS, preset.get(CONF_DEVICE_CLASS))
compatible = unit in _COMPATIBLE_UNITS.get(device_class, ())
for key, value in preset.items():
@@ -37,13 +37,7 @@ void TemplateSwitch::setup() {
if (!this->f_.has_value())
this->disable_loop();
optional<bool> initial_state = this->get_initial_state_with_restore_mode();
if (initial_state.has_value()) {
ESP_LOGD(TAG, " Restored state %s", ONOFF(initial_state.value()));
// if it has a value, restore_mode is not "DISABLED", therefore act on the switch:
this->control(initial_state.value());
}
this->restore();
}
void TemplateSwitch::dump_config() {
LOG_SWITCH("", "Template Switch", this);
+18
View File
@@ -23,6 +23,14 @@ enum UARTParityOptions {
UART_CONFIG_PARITY_SPACE,
};
// A runtime setting like CRTSCTS and IXON/IXOFF in termios. A backend without the lines
// keeps the value and ignores it, as a Linux driver does.
enum UARTFlowControl : uint8_t {
UART_FLOW_CONTROL_NONE,
UART_FLOW_CONTROL_RTS_CTS,
UART_FLOW_CONTROL_XON_XOFF,
};
#ifdef USE_UART_DEBUGGER
enum UARTDirection {
UART_DIRECTION_RX,
@@ -185,6 +193,15 @@ class UARTComponent {
// @return Baud rate in bits per second.
uint32_t get_baud_rate() const { return baud_rate_; }
// Set the line handshaking (RTS/CTS or XON/XOFF) used in UART communication. Unrelated to
// flow_control_pin, the RS485 driver enable; backends without handshaking ignore it.
// @param flow_control Flow control option.
void set_flow_control(UARTFlowControl flow_control) { this->flow_control_ = flow_control; }
// Get the flow control used in UART communication.
// @return Flow control option.
UARTFlowControl get_flow_control() const { return this->flow_control_; }
#if defined(USE_ESP8266) || defined(USE_ESP32)
/**
* Load the UART settings.
@@ -231,6 +248,7 @@ class UARTComponent {
uint32_t baud_rate_{0};
uint8_t stop_bits_{0};
uint8_t data_bits_{0};
UARTFlowControl flow_control_{UART_FLOW_CONTROL_NONE};
UARTParityOptions parity_{UART_CONFIG_PARITY_NONE}; // Must match the parity check in __init__.py
#ifdef USE_UART_DEBUGGER
CallbackManager<void(UARTDirection, uint8_t)> debug_callback_{};
+40 -78
View File
@@ -2,95 +2,57 @@
namespace esphome::unicode {
uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) {
// Safely cast to uint8_t* for correct bitwise operations on bytes
const uint8_t *current = reinterpret_cast<const uint8_t *>(utf8_str);
uint32_t code_point = 0;
uint8_t c1 = *current++;
// Reads count continuation bytes into code_point, stopping at the first byte that is not one so a
// sequence cut short by the terminator is never read past
static bool read_continuation(const uint8_t *&current, uint8_t count, uint32_t &code_point) {
for (uint8_t i = 0; i < count; i++) {
const uint8_t c = *current;
if ((c & 0xC0) != 0x80) {
return false;
}
current++;
code_point = (code_point << 6) | (c & 0x3F);
}
return true;
}
// check for end of string
uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) {
const auto *start = reinterpret_cast<const uint8_t *>(utf8_str);
const uint8_t *current = start;
const uint8_t c1 = *current++;
uint8_t count;
uint32_t code_point;
uint32_t lowest; // the smallest code point this sequence length may encode; shorter forms are overlong
if (c1 == 0) {
*length = 0;
return 0;
}
// --- 1-Byte Sequence: 0xxxxxxx (ASCII) ---
if (c1 < 0x80) {
// Valid ASCII byte.
count = 0;
code_point = c1;
// Optimization: No need to check for continuation bytes.
lowest = 0;
} else if ((c1 & 0xE0) == 0xC0) {
count = 1;
code_point = c1 & 0x1F;
lowest = 0x80;
} else if ((c1 & 0xF0) == 0xE0) {
count = 2;
code_point = c1 & 0x0F;
lowest = 0x800;
} else if ((c1 & 0xF8) == 0xF0) {
count = 3;
code_point = c1 & 0x07;
lowest = 0x10000;
} else {
*length = 0; // a continuation byte or an invalid lead byte
return 0;
}
// --- 2-Byte Sequence: 110xxxxx 10xxxxxx ---
else if ((c1 & 0xE0) == 0xC0) {
uint8_t c2 = *current++;
// Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx)
if ((c2 & 0xC0) != 0x80) {
*length = 0;
return 0;
}
code_point = (c1 & 0x1F) << 6;
code_point |= (c2 & 0x3F);
// Error Check 2: Overlong check (2-byte must be > 0x7F)
if (code_point <= 0x7F) {
*length = 0;
return 0;
}
}
// --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx ---
else if ((c1 & 0xF0) == 0xE0) {
uint8_t c2 = *current++;
uint8_t c3 = *current++;
// Error Check 1: Check continuation bytes
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) {
*length = 0;
return 0;
}
code_point = (c1 & 0x0F) << 12;
code_point |= (c2 & 0x3F) << 6;
code_point |= (c3 & 0x3F);
// Error Check 2: Overlong check (3-byte must be > 0x7FF)
// Also check for surrogates (0xD800-0xDFFF)
if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) {
*length = 0;
return 0;
}
}
// --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx ---
else if ((c1 & 0xF8) == 0xF0) {
uint8_t c2 = *current++;
uint8_t c3 = *current++;
uint8_t c4 = *current++;
// Error Check 1: Check continuation bytes
if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) {
*length = 0;
return 0;
}
code_point = (c1 & 0x07) << 18;
code_point |= (c2 & 0x3F) << 12;
code_point |= (c3 & 0x3F) << 6;
code_point |= (c4 & 0x3F);
// Error Check 2: Overlong check (4-byte must be > 0xFFFF)
// Also check for valid Unicode range (must be <= 0x10FFFF)
if (code_point <= 0xFFFF || code_point > 0x10FFFF) {
*length = 0;
return 0;
}
}
// --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) ---
else {
if (!read_continuation(current, count, code_point) || code_point < lowest || code_point > 0x10FFFF ||
(code_point >= 0xD800 && code_point <= 0xDFFF)) {
*length = 0;
return 0;
}
*length = current - reinterpret_cast<const uint8_t *>(utf8_str);
*length = current - start;
return code_point;
}
+4 -4
View File
@@ -14,6 +14,7 @@ from esphome.components.esp32 import (
get_esp32_variant,
idf_version,
only_on_variant,
require_idf_version,
)
import esphome.config_validation as cv
from esphome.const import CONF_DEVICES, CONF_FRAMEWORK, CONF_ID
@@ -201,10 +202,9 @@ def fifo_split() -> tuple[int, int, int] | None:
def _reconcile_fifo() -> None:
if (split := fifo_split()) is None:
return
if idf_version() < FIFO_CUSTOM_MIN_IDF:
raise cv.Invalid(
f"Sizing the USB FIFO on the {get_esp32_variant()} needs ESP-IDF {FIFO_CUSTOM_MIN_IDF} or later"
)
require_idf_version(
FIFO_CUSTOM_MIN_IDF, f"Sizing the USB FIFO on the {get_esp32_variant()}"
)(None)
rx, nptx, ptx = split
cg.add_define("USE_USB_HOST_FIFO_CONFIG")
cg.add_define("USB_HOST_FIFO_RX_LINES", rx)
+16 -4
View File
@@ -1,3 +1,5 @@
from collections.abc import Callable
import esphome.codegen as cg
from esphome.components.const import CONF_DATA_BITS, CONF_PARITY, CONF_STOP_BITS
from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant
@@ -154,6 +156,19 @@ FILTER_SOURCE_FILES = filter_source_files_from_defines(
)
def _channel_limit(type_: Type) -> Callable[[list], list]:
"""Check the channel count against the type's limit, which depends on the target."""
def validator(value: list) -> list:
if len(value) > (limit := type_.max_channels):
raise cv.Invalid(
f"Device type {type_.name} supports a maximum of {limit} channels"
)
return value
return validator
def channel_schema(type_: "Type") -> cv.Schema:
schema = cv.Schema(
{
@@ -192,10 +207,7 @@ def channel_schema(type_: "Type") -> cv.Schema:
}
)
),
cv.Length(
max=type_.max_channels,
msg=f"Device type {type_.name} supports a maximum of {type_.max_channels} channels",
),
_channel_limit(type_),
),
}
)
+26 -1
View File
@@ -6,6 +6,9 @@
#include "esphome/components/bytebuffer/bytebuffer.h"
#include <algorithm>
#include <iterator>
namespace esphome::usb_uart {
using namespace bytebuffer;
@@ -15,6 +18,13 @@ static constexpr uint8_t CH341_REQ_MODEM_CTRL = 0xA4;
static constexpr uint8_t CH341_BIT_DTR = 1 << 5;
static constexpr uint8_t CH341_BIT_RTS = 1 << 6;
static constexpr uint8_t CH341_REQ_WRITE_REG = 0x9A;
static constexpr uint8_t CH341_REG_FLOW_CTL = 0x27;
static constexpr uint8_t CH341_FLOW_CTL_NONE = 0x00;
static constexpr uint8_t CH341_FLOW_CTL_RTSCTS = 0x01;
// The single-port chips, which have the flow control register
static constexpr uint16_t CH341_PIDS[] = {0x5523, 0x7522, 0x7523};
struct CH34xEntry {
const char *name;
uint16_t pid;
@@ -102,6 +112,12 @@ void USBUartTypeCH34X::dump_config() {
bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok,
const uint8_t *response) {
// TODO: flow control on the multi-port chips (PIDs 0x55Dx)
const bool has_flow_ctl_reg =
std::find(std::begin(CH341_PIDS), std::end(CH341_PIDS), this->pid_) != std::end(CH341_PIDS);
if (!has_flow_ctl_reg && step >= 1)
step++;
uint8_t cmd = 0xA1 + channel->index_;
if (channel->index_ >= 2)
cmd += 0xE;
@@ -149,7 +165,16 @@ bool USBUartTypeCH34X::config_step(USBUartChannelBase *channel, uint8_t step, bo
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, cmd, value, (factor << 8) | divisor);
return true;
}
case 1:
case 1: {
// Re-applied on every settings change. The chip has no XON/XOFF.
const bool rts_cts = channel->flow_control_ == uart::UART_FLOW_CONTROL_RTS_CTS;
const uint8_t flow_ctl = rts_cts ? CH341_FLOW_CTL_RTSCTS : CH341_FLOW_CTL_NONE;
ESP_LOGD(TAG, "FLOW_CTL: RTS/CTS=%s", ONOFF(rts_cts));
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, CH341_REQ_WRITE_REG,
(CH341_REG_FLOW_CTL << 8) | CH341_REG_FLOW_CTL, (flow_ctl << 8) | flow_ctl);
return true;
}
case 2:
this->modem_control_transfer(channel);
return true;
default:
+139 -1
View File
@@ -6,6 +6,9 @@
#include "esphome/components/bytebuffer/bytebuffer.h"
#include <cinttypes>
#include <iterator>
namespace esphome::usb_uart {
using namespace bytebuffer;
@@ -43,12 +46,56 @@ static constexpr uint8_t SET_BAUDRATE = 0x1E; // Set the baud rate.
static constexpr uint8_t SET_CHARS = 0x19; // Set special characters.
static constexpr uint8_t VENDOR_SPECIFIC = 0xFF; // Vendor specific command.
// VENDOR_SPECIFIC wValue reads
static constexpr uint16_t GET_FW_VER_2N = 0x0010;
static constexpr uint16_t GET_PARTNUM = 0x370B;
static constexpr uint8_t PARTNUM_CP2102N_QFN28 = 0x20;
static constexpr uint8_t PARTNUM_CP2102N_QFN24 = 0x21;
static constexpr uint8_t PARTNUM_CP2102N_QFN20 = 0x22;
// CP2102N firmware up to 1.0.4 reads xon_limit as flow_replace (erratum E104)
static constexpr uint32_t CP2102N_LAST_FW_WITHOUT_FLOW_CONTROL = 0x010004;
// SET_MHS value bits. The high byte selects which lines the low byte changes.
static constexpr uint16_t CONTROL_DTR = 0x0001;
static constexpr uint16_t CONTROL_RTS = 0x0002;
static constexpr uint16_t CONTROL_WRITE_DTR = 0x0100;
static constexpr uint16_t CONTROL_WRITE_RTS = 0x0200;
// CP210xFlowBlock::control_handshake bits
static constexpr uint32_t SERIAL_DTR_MASK = 0x03;
static constexpr uint32_t SERIAL_DTR_INACTIVE = 0x00;
static constexpr uint32_t SERIAL_DTR_ACTIVE = 0x01;
static constexpr uint32_t SERIAL_CTS_HANDSHAKE = 1 << 3;
static constexpr uint32_t SERIAL_DSR_HANDSHAKE = 1 << 4;
static constexpr uint32_t SERIAL_DCD_HANDSHAKE = 1 << 5;
static constexpr uint32_t SERIAL_DSR_SENSITIVITY = 1 << 6;
// CP210xFlowBlock::flow_replace bits
static constexpr uint32_t SERIAL_AUTO_TRANSMIT = 1 << 0;
static constexpr uint32_t SERIAL_AUTO_RECEIVE = 1 << 1;
static constexpr uint32_t SERIAL_RTS_MASK = 0xC0;
static constexpr uint32_t SERIAL_RTS_INACTIVE = 0x00;
static constexpr uint32_t SERIAL_RTS_ACTIVE = 0x40;
static constexpr uint32_t SERIAL_RTS_FLOW_CTL = 0x80;
// Receive queue levels at which the chip sends XON and XOFF
static constexpr uint32_t XON_XOFF_LIMIT = 128;
// SET_CHARS block: EOF, error, break, event, XON and XOFF characters. XON and XOFF are the
// termios defaults DC1 and DC3.
static constexpr uint8_t SPECIAL_CHARS[] = {0, 0, 0, 0, 0x11, 0x13};
// Put DTR and RTS into the block. Under RTS/CTS an asserted RTS hands the line to the chip.
static void set_block_modem_lines(CP210xFlowBlock &block, bool dtr, bool rts) {
block.control_handshake =
(block.control_handshake & ~SERIAL_DTR_MASK) | (dtr ? SERIAL_DTR_ACTIVE : SERIAL_DTR_INACTIVE);
uint32_t rts_bits = SERIAL_RTS_INACTIVE;
if (rts)
rts_bits = (block.control_handshake & SERIAL_CTS_HANDSHAKE) ? SERIAL_RTS_FLOW_CTL : SERIAL_RTS_ACTIVE;
block.flow_replace = (block.flow_replace & ~SERIAL_RTS_MASK) | rts_bits;
}
std::vector<CdcEps> USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev_hdl) {
const usb_config_desc_t *config_desc;
const usb_device_desc_t *device_desc;
@@ -103,11 +150,57 @@ std::vector<CdcEps> USBUartTypeCP210X::parse_descriptors(usb_device_handle_t dev
return cdc_devs;
}
bool USBUartTypeCP210X::config_device_step(uint8_t step, bool ok, const uint8_t *response) {
switch (step) {
case 0:
// A fresh device: the previous one's cached blocks must not steer modem_control_transfer()
this->no_flow_control_ = false;
this->flow_blocks_.fill({});
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_IN, VENDOR_SPECIFIC, GET_PARTNUM, 0,
std::vector<uint8_t>(1), this->cfg_response_);
return true;
case 1:
if (!ok)
return false;
ESP_LOGD(TAG, "Part number 0x%02X", response[0]);
if (response[0] != PARTNUM_CP2102N_QFN28 && response[0] != PARTNUM_CP2102N_QFN24 &&
response[0] != PARTNUM_CP2102N_QFN20)
return false;
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_IN, VENDOR_SPECIFIC, GET_FW_VER_2N, 0,
std::vector<uint8_t>(3), this->cfg_response_);
return true;
case 2: {
if (!ok)
return false;
const uint32_t version = (response[0] << 16) | (response[1] << 8) | response[2];
ESP_LOGD(TAG, "CP2102N firmware %u.%u.%u", response[0], response[1], response[2]);
this->no_flow_control_ = version <= CP2102N_LAST_FW_WITHOUT_FLOW_CONTROL;
if (this->no_flow_control_) {
ESP_LOGW(TAG, "CP2102N firmware 1.0.4 and older cannot do flow control (erratum E104), using none");
}
return false;
}
default:
return false;
}
}
bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok,
const uint8_t *response) {
// On reload, skip the one-time IFC_ENABLE step (the interface is already enabled).
if (reload)
step++;
const bool xon_xoff = channel->flow_control_ == uart::UART_FLOW_CONTROL_XON_XOFF;
if (step >= 3) {
if (this->no_flow_control_) {
// Firmware with erratum E104 takes flow_replace from xon_limit, so a SET_FLOW can drop RTS.
// Skip the flow control steps; the lines then stay on SET_MHS as the cached block has no CTS.
step += 3;
} else if (!xon_xoff) {
// SET_CHARS is only needed for XON/XOFF
step++;
}
}
switch (step) {
case 0:
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, IFC_ENABLE, 1, channel->index_);
@@ -125,7 +218,36 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_BAUDRATE, 0, channel->index_, baud.get_data());
return true;
}
case 3: // Assert DTR+RTS (init only)
case 3:
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_CHARS, 0, channel->index_,
std::vector<uint8_t>(std::begin(SPECIAL_CHARS), std::end(SPECIAL_CHARS)));
return true;
case 4: {
// Read into the channel's cached block, which the next step edits and writes back
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_IN, GET_FLOW, 0, channel->index_,
std::vector<uint8_t>(sizeof(CP210xFlowBlock)),
reinterpret_cast<uint8_t *>(&this->flow_blocks_[channel->index_]));
return true;
}
case 5: {
// Set flow control, re-applied on every settings change. The block also carries DTR and RTS,
// so this puts them on the wire too.
auto &block = this->flow_blocks_[channel->index_];
block.control_handshake &=
~(SERIAL_DSR_HANDSHAKE | SERIAL_DCD_HANDSHAKE | SERIAL_DSR_SENSITIVITY | SERIAL_CTS_HANDSHAKE);
if (channel->flow_control_ == uart::UART_FLOW_CONTROL_RTS_CTS)
block.control_handshake |= SERIAL_CTS_HANDSHAKE;
assign_bits(block.flow_replace, SERIAL_AUTO_TRANSMIT | SERIAL_AUTO_RECEIVE, xon_xoff);
if (xon_xoff) {
block.xon_limit = XON_XOFF_LIMIT;
block.xoff_limit = XON_XOFF_LIMIT;
}
set_block_modem_lines(block, channel->dtr_, channel->rts_);
this->set_flow_transfer_(channel);
return true;
}
case 6:
// Raise the lines on open (init only)
if (reload)
return false;
this->modem_control_transfer(channel);
@@ -136,11 +258,27 @@ bool USBUartTypeCP210X::config_step(USBUartChannelBase *channel, uint8_t step, b
}
void USBUartTypeCP210X::modem_control_transfer(USBUartChannelBase *channel) {
// Under RTS/CTS the chip drives RTS, so the lines go through SET_FLOW
auto &block = this->flow_blocks_[channel->index_];
if (block.control_handshake & SERIAL_CTS_HANDSHAKE) {
set_block_modem_lines(block, channel->dtr_, channel->rts_);
this->set_flow_transfer_(channel);
return;
}
const uint16_t value =
CONTROL_WRITE_DTR | CONTROL_WRITE_RTS | (channel->dtr_ ? CONTROL_DTR : 0) | (channel->rts_ ? CONTROL_RTS : 0);
ESP_LOGD(TAG, "SET_MHS: DTR=%s RTS=%s", ONOFF(channel->dtr_), ONOFF(channel->rts_));
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_MHS, value, channel->index_);
}
void USBUartTypeCP210X::set_flow_transfer_(USBUartChannelBase *channel) {
const auto &block = this->flow_blocks_[channel->index_];
ESP_LOGD(TAG, "SET_FLOW: control handshake 0x%02" PRIX32 ", flow replace 0x%02" PRIX32, block.control_handshake,
block.flow_replace);
const auto *bytes = reinterpret_cast<const uint8_t *>(&block);
this->config_transfer_(USB_VENDOR_IFC | usb_host::USB_DIR_OUT, SET_FLOW, 0, channel->index_,
std::vector<uint8_t>(bytes, bytes + sizeof(block)));
}
} // namespace esphome::usb_uart
#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 ||
+22 -1
View File
@@ -14,6 +14,13 @@ namespace esphome::usb_uart {
using namespace bytebuffer;
static constexpr uint8_t SIO_SET_MODEM_CTRL = 0x01;
static constexpr uint8_t SIO_SET_FLOW_CTRL = 0x02;
// The high byte of wIndex selects the flow control mode, the low byte the channel
static constexpr uint16_t SIO_DISABLE_FLOW_CTRL = 0x0000;
static constexpr uint16_t SIO_RTS_CTS_HS = 0x0100;
static constexpr uint16_t SIO_XON_XOFF_HS = 0x0400;
// wValue carries the XOFF and XON characters, which the termios defaults fix as DC3 and DC1
static constexpr uint16_t SIO_XON_XOFF_CHARS = 0x1311;
// The high byte selects which lines the low byte changes
static constexpr uint16_t SIO_SET_DTR_MASK = 0x0100;
static constexpr uint16_t SIO_SET_RTS_MASK = 0x0200;
@@ -377,7 +384,21 @@ bool USBUartTypeFT23XX::config_step(USBUartChannelBase *channel, uint8_t step, b
channel->cdc_dev_.bulk_interface_number + 1);
return true;
}
case 3: // set modem control DTR+RTS (init only)
case 3: { // set flow control, re-applied on every settings change
uint16_t mode = SIO_DISABLE_FLOW_CTRL;
uint16_t value = 0;
if (channel->flow_control_ == uart::UART_FLOW_CONTROL_RTS_CTS) {
mode = SIO_RTS_CTS_HS;
} else if (channel->flow_control_ == uart::UART_FLOW_CONTROL_XON_XOFF) {
mode = SIO_XON_XOFF_HS;
value = SIO_XON_XOFF_CHARS;
}
ESP_LOGD(TAG, "SIO_SET_FLOW_CTRL: mode=0x%04X", mode);
this->config_transfer_(USB_VENDOR_DEV | usb_host::USB_DIR_OUT, SIO_SET_FLOW_CTRL, value,
mode | (channel->cdc_dev_.bulk_interface_number + 1));
return true;
}
case 4: // set modem control DTR+RTS (init only)
if (reload)
return false;
this->modem_control_transfer(channel);
+60
View File
@@ -22,6 +22,25 @@ static constexpr uint8_t VENDOR_WRITE_REQUEST = 0x01;
static constexpr uint8_t VENDOR_READ_REQUEST_TYPE = 0xc0;
static constexpr uint8_t VENDOR_READ_REQUEST = 0x01;
// HXN takes its own vendor requests and reads a register at its plain address. The other
// chips read it at the address with bit 7 set.
static constexpr uint8_t VENDOR_WRITE_NREQUEST = 0x80;
static constexpr uint8_t VENDOR_READ_NREQUEST = 0x81;
static constexpr uint16_t VENDOR_READ_REG_FLAG = 0x80;
static constexpr uint8_t FLOWCTRL_REG = 0x00;
static constexpr uint8_t FLOWCTRL_MASK = 0xf0;
static constexpr uint8_t FLOWCTRL_NONE = 0x00;
static constexpr uint8_t FLOWCTRL_RTS_CTS_LEGACY = 0x40;
static constexpr uint8_t FLOWCTRL_RTS_CTS = 0x60;
static constexpr uint8_t FLOWCTRL_XON_XOFF = 0xc0;
static constexpr uint8_t HXN_FLOWCTRL_REG = 0x0a;
static constexpr uint8_t HXN_FLOWCTRL_MASK = 0x1c;
static constexpr uint8_t HXN_FLOWCTRL_NONE = 0x1c;
static constexpr uint8_t HXN_FLOWCTRL_RTS_CTS = 0x18;
static constexpr uint8_t HXN_FLOWCTRL_XON_XOFF = 0x0c;
// Supported standard baud rates for direct encoding (TYPE_H, TYPE_HX, TYPE_HXD, TYPE_HXN)
static const uint32_t SUPPORTED_BAUD_RATES[] = {
75, 150, 300, 600, 1200, 1800, 2400, 3600, 4800, 7200, 9600, 14400, 19200,
@@ -47,6 +66,29 @@ static const char *pl2303_type_name(Pl2303ChipType type) {
}
}
// The flow control register bits for the mode on each chip type
static uint8_t pl2303_flow_control_bits(Pl2303ChipType type, uart::UARTFlowControl flow_control) {
if (type == PL2303_TYPE_HXN) {
switch (flow_control) {
case uart::UART_FLOW_CONTROL_RTS_CTS:
return HXN_FLOWCTRL_RTS_CTS;
case uart::UART_FLOW_CONTROL_XON_XOFF:
return HXN_FLOWCTRL_XON_XOFF;
default:
return HXN_FLOWCTRL_NONE;
}
}
switch (flow_control) {
case uart::UART_FLOW_CONTROL_RTS_CTS:
return type == PL2303_TYPE_H ? FLOWCTRL_RTS_CTS_LEGACY : FLOWCTRL_RTS_CTS;
case uart::UART_FLOW_CONTROL_XON_XOFF:
// The legacy chip has no automatic XON/XOFF
return type == PL2303_TYPE_H ? FLOWCTRL_NONE : FLOWCTRL_XON_XOFF;
default:
return FLOWCTRL_NONE;
}
}
// Find nearest supported baud rate for direct encoding
static uint32_t nearest_supported_baud(uint32_t baud) {
size_t n = sizeof(SUPPORTED_BAUD_RATES) / sizeof(SUPPORTED_BAUD_RATES[0]);
@@ -279,6 +321,24 @@ bool USBUartTypePL2303::config_step(USBUartChannelBase *channel, uint8_t step, b
return true;
}
case 1:
// Read the flow control register, re-applied on every settings change. A short reply fails
// the step, so no write follows.
this->config_transfer_(VENDOR_READ_REQUEST_TYPE, is_hxn ? VENDOR_READ_NREQUEST : VENDOR_READ_REQUEST,
is_hxn ? HXN_FLOWCTRL_REG : (FLOWCTRL_REG | VENDOR_READ_REG_FLAG), 0,
std::vector<uint8_t>(1), this->cfg_response_);
return true;
case 2: {
// Write it back with only the flow control bits changed
const uint8_t reg = is_hxn ? HXN_FLOWCTRL_REG : FLOWCTRL_REG;
const uint8_t mask = is_hxn ? HXN_FLOWCTRL_MASK : FLOWCTRL_MASK;
const uint8_t new_byte =
(response[0] & ~mask) | pl2303_flow_control_bits(this->chip_type_, channel->flow_control_);
ESP_LOGD(TAG, "PL2303: flow control register 0x%02X: 0x%02X -> 0x%02X", reg, response[0], new_byte);
this->config_transfer_(VENDOR_WRITE_REQUEST_TYPE, is_hxn ? VENDOR_WRITE_NREQUEST : VENDOR_WRITE_REQUEST, reg,
new_byte);
return true;
}
case 3:
// Assert DTR + RTS (init only)
if (reload)
return false;
+24 -9
View File
@@ -596,14 +596,18 @@ void USBUartComponent::start_config_(ConfigMode mode) {
this->cfg_step_ = 0;
this->cfg_ok_ = true;
this->cfg_in_flight_ = false;
this->cfg_done_.store(false);
this->cfg_done_.store(0);
this->cfg_active_ = true;
this->enable_loop();
}
void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index,
const std::vector<uint8_t> &data) {
this->cfg_done_.store(false);
const std::vector<uint8_t> &data, uint8_t *dest) {
this->cfg_done_.store(0);
// Members, not captures: a lambda holding more than `this` outgrows std::function's inline
// storage and would allocate on every step
this->cfg_read_dest_ = dest;
this->cfg_read_len_ = data.size();
// The completion callback runs in the USB-task context: it only records the result and
// wakes the loop. The next transfer is issued from run_config_machine_() on the loop thread.
bool submitted = this->control_transfer(
@@ -612,11 +616,22 @@ void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t
this->cfg_ok_ = status.success;
if (!status.success) {
ESP_LOGW(TAG, "Config control transfer failed: %s", esp_err_to_name(status.error_code));
} else if (status.data_len > 0) {
memcpy(this->cfg_response_, status.data, std::min<size_t>(status.data_len, sizeof(this->cfg_response_)));
} else {
// The buffer and its length both start with the setup packet
const uint8_t *payload = status.data + usb_host::SETUP_PACKET_SIZE;
const size_t received =
status.data_len > usb_host::SETUP_PACKET_SIZE ? status.data_len - usb_host::SETUP_PACKET_SIZE : 0;
if (this->cfg_read_dest_ == nullptr) {
memcpy(this->cfg_response_, payload, std::min(received, sizeof(this->cfg_response_)));
} else if (received == this->cfg_read_len_) {
memcpy(this->cfg_read_dest_, payload, received);
} else {
ESP_LOGW(TAG, "Config read returned %zu of %zu bytes", received, this->cfg_read_len_);
this->cfg_ok_ = false;
}
}
// Release: publishes cfg_ok_/cfg_response_ before the loop observes cfg_done_.
this->cfg_done_.store(true, std::memory_order_release);
// Release: publishes cfg_ok_ and the reply before the loop observes cfg_done_.
this->cfg_done_.store(1, std::memory_order_release);
this->enable_loop_soon_any_context();
App.wake_loop_threadsafe();
},
@@ -626,7 +641,7 @@ void USBUartComponent::config_transfer_(uint8_t type, uint8_t request, uint16_t
// a failed completion here so the state machine advances/aborts instead of hanging.
ESP_LOGW(TAG, "Config control transfer submit failed");
this->cfg_ok_ = false;
this->cfg_done_.store(true, std::memory_order_release);
this->cfg_done_.store(1, std::memory_order_release);
}
}
@@ -639,7 +654,7 @@ bool USBUartComponent::run_config_machine_() {
if (!this->cfg_done_.load(std::memory_order_acquire))
return false; // still waiting; the callback will re-wake the loop (no busy spin)
this->cfg_in_flight_ = false;
this->cfg_done_.store(false);
this->cfg_done_.store(0);
this->cfg_step_++;
}
+27 -3
View File
@@ -10,6 +10,7 @@
#include "esphome/core/lock_free_queue.h"
#include "esphome/core/event_pool.h"
#include "esphome/core/log.h"
#include <array>
#include <atomic>
#include <functional>
@@ -289,8 +290,10 @@ class USBUartComponent : public usb_host::USBClient {
// callback (USB-task context) records the result/IN data, marks the step done and
// wakes the loop so run_config_machine_() advances on the loop thread. Call exactly
// once from config_step_()/config_device_step_() when issuing a step.
// An IN reply goes to cfg_response_, or to `dest` when set. A `dest` reply must be
// exactly data.size() bytes, else the step fails.
void config_transfer_(uint8_t type, uint8_t request, uint16_t value, uint16_t index,
const std::vector<uint8_t> &data = {});
const std::vector<uint8_t> &data = {}, uint8_t *dest = nullptr);
// (Re)start the config state machine. INIT runs over all channels; the other modes act
// on cfg_single_ only.
void start_config_(ConfigMode mode);
@@ -318,8 +321,11 @@ class USBUartComponent : public usb_host::USBClient {
// Config state machine
USBUartChannelBase *cfg_single_{nullptr}; // non-null: single-channel run (RELOAD or MODEM)
std::atomic<bool> cfg_done_{false}; // synchronizes cfg_ok_/cfg_response_ across threads
uint8_t cfg_response_[8]{}; // last IN transfer payload (for detection reads)
uint8_t *cfg_read_dest_{nullptr}; // where the step in flight stores its reply, if not cfg_response_
size_t cfg_read_len_{0}; // and the exact length it then expects
// synchronizes cfg_ok_/cfg_response_ across threads; a byte, since atomic<bool> does not inline on Xtensa
std::atomic<uint8_t> cfg_done_{0};
uint8_t cfg_response_[8]{}; // last IN transfer payload
uint8_t cfg_channel_idx_{0};
uint8_t cfg_step_{0};
ConfigMode cfg_mode_{ConfigMode::CONFIG_MODE_INIT};
@@ -345,14 +351,32 @@ class USBUartTypeCdcAcm : public USBUartComponent {
bool claim_comm_interface_{true};
};
// The block GET_FLOW and SET_FLOW transfer, little-endian on the wire as in memory
struct CP210xFlowBlock {
uint32_t control_handshake;
uint32_t flow_replace;
uint32_t xon_limit;
uint32_t xoff_limit;
};
class USBUartTypeCP210X : public USBUartTypeCdcAcm {
public:
USBUartTypeCP210X(uint16_t vid, uint16_t pid) : USBUartTypeCdcAcm(vid, pid) {}
protected:
// Matches max_channels of the CP210X type in __init__.py
static constexpr uint8_t MAX_CHANNELS = 3;
std::vector<CdcEps> parse_descriptors(usb_device_handle_t dev_hdl) override;
bool config_step(USBUartChannelBase *channel, uint8_t step, bool reload, bool ok, const uint8_t *response) override;
void modem_control_transfer(USBUartChannelBase *channel) override;
bool config_device_step(uint8_t step, bool ok, const uint8_t *response) override;
void set_flow_transfer_(USBUartChannelBase *channel);
// The block last read for each channel, so a DTR/RTS change under RTS/CTS is a single SET_FLOW
std::array<CP210xFlowBlock, MAX_CHANNELS> flow_blocks_{};
// A CP2102N whose firmware has erratum E104
bool no_flow_control_{false};
};
class USBUartTypeCH34X : public USBUartTypeCdcAcm {
public:
+18 -12
View File
@@ -1,25 +1,31 @@
#pragma once
#include <cstdint>
namespace esphome::valve {
class ValveTraits {
public:
ValveTraits() = default;
bool get_is_assumed_state() const { return this->is_assumed_state_; }
void set_is_assumed_state(bool is_assumed_state) { this->is_assumed_state_ = is_assumed_state; }
bool get_supports_position() const { return this->supports_position_; }
void set_supports_position(bool supports_position) { this->supports_position_ = supports_position; }
bool get_supports_toggle() const { return this->supports_toggle_; }
void set_supports_toggle(bool supports_toggle) { this->supports_toggle_ = supports_toggle; }
bool get_supports_stop() const { return this->supports_stop_; }
void set_supports_stop(bool supports_stop) { this->supports_stop_ = supports_stop; }
bool get_is_assumed_state() const { return this->flags_.is_assumed_state; }
void set_is_assumed_state(bool is_assumed_state) { this->flags_.is_assumed_state = is_assumed_state; }
bool get_supports_position() const { return this->flags_.supports_position; }
void set_supports_position(bool supports_position) { this->flags_.supports_position = supports_position; }
bool get_supports_toggle() const { return this->flags_.supports_toggle; }
void set_supports_toggle(bool supports_toggle) { this->flags_.supports_toggle = supports_toggle; }
bool get_supports_stop() const { return this->flags_.supports_stop; }
void set_supports_stop(bool supports_stop) { this->flags_.supports_stop = supports_stop; }
protected:
bool is_assumed_state_{false};
bool supports_position_{false};
bool supports_toggle_{false};
bool supports_stop_{false};
// Bit-packed flags to save memory (1 byte instead of 4)
struct ValveTraitFlags {
uint8_t is_assumed_state : 1;
uint8_t supports_position : 1;
uint8_t supports_toggle : 1;
uint8_t supports_stop : 1;
uint8_t reserved : 4;
} flags_{};
};
} // namespace esphome::valve
+14
View File
@@ -29,9 +29,15 @@ MULTI_CONF = True
CONF_MAX_UPDATE_DELAY = "max_update_delay"
CONF_UPDATE_DELAY = "update_delay"
CONF_PIXEL_FORMAT = "pixel_format"
vnc_ns = cg.esphome_ns.namespace("vnc")
VncClient = vnc_ns.class_("VncClient", cg.Component)
VncPixelFormat = vnc_ns.enum("VncPixelFormat", is_class=True)
PIXEL_FORMATS = VncPixelFormat.options(
"auto", "rgb565", "rgb332", prefix="VNC_PIXEL_FORMAT_"
)
# Drivers that cannot take another component's pixels yet.
UNSUPPORTED_DISPLAYS = dict.fromkeys(
@@ -63,6 +69,9 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(
CONF_MAX_UPDATE_DELAY, default="1s"
): cv.positive_time_period_milliseconds,
cv.Optional(CONF_PIXEL_FORMAT, default="auto"): cv.enum(
PIXEL_FORMATS, lower=True
),
cv.Optional(CONF_ON_CONNECT): automation.validate_automation({}),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation({}),
}
@@ -128,6 +137,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_reconnect_interval(config[CONF_RECONNECT_INTERVAL]))
cg.add(var.set_update_delay(config[CONF_UPDATE_DELAY]))
cg.add(var.set_max_update_delay(config[CONF_MAX_UPDATE_DELAY]))
cg.add(var.set_pixel_format(config[CONF_PIXEL_FORMAT]))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
@@ -135,4 +145,8 @@ VNC_ID_SCHEMA = automation.maybe_simple_id(
{cv.GenerateID(): cv.use_id(VncClient)},
)
automation.register_apply_action(
"vnc.refresh", VNC_ID_SCHEMA, automation.ApplyCall("refresh()")
)
automation.register_apply_condition("vnc.is_connected", VNC_ID_SCHEMA, "is_connected()")
+296 -11
View File
@@ -4,10 +4,12 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <cinttypes>
#include <cstring>
#include <iterator>
namespace esphome::vnc {
@@ -32,7 +34,19 @@ static constexpr uint8_t SECURITY_NONE = 1;
static constexpr uint8_t SECURITY_VNC_AUTH = 2;
static constexpr int32_t ENCODING_RAW = 0;
static constexpr int32_t ENCODING_HEXTILE = 5;
static constexpr int32_t ENCODING_RICH_CURSOR = -239;
// In preference order: TightVNC uses the first real encoding it supports for every rectangle.
static constexpr int32_t ENCODINGS[] = {ENCODING_HEXTILE, ENCODING_RAW, ENCODING_RICH_CURSOR};
// Indexed by VncPixelFormat
PROGMEM_STRING_TABLE(PixelFormatStrings, "auto", "RGB565", "RGB332");
static constexpr uint8_t HEXTILE_RAW = 0x01;
static constexpr uint8_t HEXTILE_BACKGROUND = 0x02;
static constexpr uint8_t HEXTILE_FOREGROUND = 0x04;
static constexpr uint8_t HEXTILE_ANY_SUBRECTS = 0x08;
static constexpr uint8_t HEXTILE_SUBRECTS_COLOURED = 0x10;
static constexpr uint8_t MSG_FRAMEBUFFER_UPDATE = 0;
static constexpr uint8_t MSG_SET_COLOUR_MAP = 1;
@@ -66,6 +80,32 @@ static int parse_three_digits(const uint8_t *p) {
void VncClient::setup() {
this->setup_ms_ = App.get_loop_component_start_time();
this->link_.begin(TAG);
// Sized for the display now; a rotation changed later swaps the sides but keeps the cell count.
const uint32_t wanted = static_cast<uint32_t>((this->display_->get_width() + 15) / 16) *
static_cast<uint32_t>((this->display_->get_height() + 15) / 16);
const uint32_t cells = std::min<uint32_t>(wanted, UINT16_MAX);
if (cells < wanted) {
ESP_LOGW(TAG, "Display has %" PRIu32 " tiles, more than the %u the cache holds", wanted, UINT16_MAX);
}
RAMAllocator<uint32_t> allocator;
this->tile_hashes_ = allocator.allocate(cells);
if (this->tile_hashes_ == nullptr) {
ESP_LOGW(TAG, "Not enough memory to skip unchanged tiles");
return;
}
this->hash_capacity_ = cells;
this->clear_tiles_();
}
void VncClient::refresh() {
this->need_full_ = true;
this->clear_tiles_();
}
void VncClient::clear_tiles_() {
if (this->tile_hashes_ != nullptr) {
std::memset(this->tile_hashes_, 0, sizeof(uint32_t) * this->hash_capacity_);
}
}
void VncClient::dump_config() {
@@ -74,9 +114,13 @@ void VncClient::dump_config() {
" Server: %s:%u\n"
" Reconnect Interval: %" PRIu32 "ms\n"
" Update Delay: %" PRIu32 "ms\n"
" Max Update Delay: %" PRIu32 "ms",
" Max Update Delay: %" PRIu32 "ms\n"
" Pixel Format: %s\n"
" Tile Cache: %u tiles",
this->link_.host(), this->link_.port(), this->link_.reconnect_interval(), this->update_delay_ms_,
this->max_update_delay_ms_);
this->max_update_delay_ms_,
LOG_STR_ARG(PixelFormatStrings::get_log_str(static_cast<uint8_t>(this->pixel_format_), 0)),
this->hash_capacity_);
}
void VncClient::on_shutdown() { this->link_.close(); }
@@ -95,12 +139,28 @@ void VncClient::loop() {
}
// A display that parks its loop when idle is busy while that loop runs. Displays that never
// park (no loop, or one that always runs) update synchronously, so they are never busy.
const bool was_busy = this->display_busy_;
if (this->display_->is_idle()) {
this->display_parks_ = true;
this->display_busy_ = false;
} else {
this->display_busy_ = this->display_parks_;
}
if (was_busy && !this->display_busy_) {
// A refresh the driver gave up on (it sets its warning) leaves the panel behind the buffer: retry it
// once and forget the cached tiles, which no longer say what the panel shows
if (!this->display_->status_has_warning()) {
this->refresh_retried_ = false;
} else if (!this->refresh_retried_) {
this->refresh_retried_ = true;
this->clear_tiles_();
if (!this->refresh_pending_) {
this->first_change_ms_ = now;
}
this->last_change_ms_ = now;
this->refresh_pending_ = true;
}
}
// A stuck panel, or another owner of the display's loop, would otherwise freeze the screen silently
if (!this->display_busy_) {
this->busy_since_ms_ = now;
@@ -147,6 +207,11 @@ void VncClient::loop() {
this->service_rx_(now);
}
if (this->state_ == VncState::VNC_STATE_RUNNING) {
// A new session lays the screen out again for the new rotation and redraws all of it.
if (static_cast<uint8_t>(this->display_->get_rotation() / 90) != this->hash_rotation_) {
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Display rotation changed"));
return;
}
// Mid-frame there is nothing to send, so a vanished server would never fail a write.
if (this->in_update_ && !blocked && !this->rx_pending_ && now - this->last_rx_ms_ >= HEARTBEAT_MS) {
this->fail_(VncFailure::VNC_FAILURE_TRANSIENT, LOG_STR("Server stopped sending"));
@@ -183,12 +248,20 @@ void VncClient::connect_step_(uint32_t now) {
}
void VncClient::reset_session_() {
// Pixels drawn but never shown would match the cache when the next session resends them.
if (this->refresh_pending_ || this->frame_drawn_) {
this->clear_tiles_();
}
// Hextile colours carry from tile to tile; a new server must not inherit the last one's
std::memset(this->hex_bg_, 0, sizeof(this->hex_bg_));
std::memset(this->hex_fg_, 0, sizeof(this->hex_fg_));
this->rx_pos_ = 0;
this->rx_len_ = 0;
this->rx_pending_ = false;
this->in_update_ = false;
this->request_outstanding_ = false;
this->refresh_pending_ = false;
this->need_full_ = false;
this->frame_drawn_ = false;
this->step_ = VncStep::VNC_STEP_VERSION;
this->reason_failure_ = VncFailure::VNC_FAILURE_TRANSIENT;
@@ -478,6 +551,7 @@ VncParse VncClient::parse_() {
this->in_update_ = true;
this->update_start_ms_ = millis();
this->update_start_bytes_ = this->rx_total_ - (this->rx_len_ - this->rx_pos_);
this->drawn_tiles_ = 0;
this->rect_done_();
break;
case MSG_SET_COLOUR_MAP:
@@ -517,7 +591,7 @@ VncParse VncClient::parse_() {
const auto encoding = static_cast<int32_t>(read_be32(p + 8));
this->rx_pos_ += 12;
this->rects_left_--;
if (encoding == ENCODING_RAW) {
if (encoding == ENCODING_RAW || encoding == ENCODING_HEXTILE) {
if (static_cast<uint32_t>(this->rect_x_) + this->rect_w_ > this->fb_w_ ||
static_cast<uint32_t>(this->rect_y_) + this->rect_h_ > this->fb_h_) {
ESP_LOGW(TAG, "Rectangle %ux%u at %u,%u is outside the %ux%u screen", this->rect_w_, this->rect_h_,
@@ -526,10 +600,15 @@ VncParse VncClient::parse_() {
}
if (this->rect_w_ == 0 || this->rect_h_ == 0) {
this->rect_done_();
} else {
} else if (encoding == ENCODING_RAW) {
this->forget_tiles_(this->rect_x_, this->rect_y_, this->rect_w_, this->rect_h_);
this->pos_x_ = 0;
this->pos_y_ = 0;
this->step_ = VncStep::VNC_STEP_RAW;
} else {
this->pos_x_ = this->rect_x_;
this->pos_y_ = this->rect_y_;
this->step_ = VncStep::VNC_STEP_TILE;
}
} else if (encoding == ENCODING_RICH_CURSOR) {
if (this->rect_w_ > MAX_CURSOR_SIDE || this->rect_h_ > MAX_CURSOR_SIDE) {
@@ -570,6 +649,84 @@ VncParse VncClient::parse_() {
}
break;
}
case VncStep::VNC_STEP_TILE: {
if (this->display_busy_) {
return VncParse::VNC_PARSE_PAUSE;
}
if (avail < 1) {
return VncParse::VNC_PARSE_MORE;
}
if (this->budget_exhausted_()) {
return VncParse::VNC_PARSE_PAUSE;
}
const uint8_t flags = p[0];
this->tile_w_ = std::min<uint32_t>(16, static_cast<uint32_t>(this->rect_x_) + this->rect_w_ - this->pos_x_);
this->tile_h_ = std::min<uint32_t>(16, static_cast<uint32_t>(this->rect_y_) + this->rect_h_ - this->pos_y_);
this->tile_hidden_ = this->pos_x_ >= this->vis_w_ || this->pos_y_ >= this->vis_h_;
// With the raw bit set the other bits mean nothing and the pixels follow at once.
if (flags & HEXTILE_RAW) {
this->rx_pos_ += 1;
this->tile_fill_ = 0;
this->step_ = VncStep::VNC_STEP_TILE_RAW;
break;
}
const size_t need = 1 + ((flags & HEXTILE_BACKGROUND) ? this->bpp_ : 0) +
((flags & HEXTILE_FOREGROUND) ? this->bpp_ : 0) + ((flags & HEXTILE_ANY_SUBRECTS) ? 1 : 0);
if (avail < need) {
return VncParse::VNC_PARSE_MORE;
}
const uint8_t *q = p + 1;
if (flags & HEXTILE_BACKGROUND) {
std::memcpy(this->hex_bg_, q, this->bpp_);
q += this->bpp_;
}
if (flags & HEXTILE_FOREGROUND) {
std::memcpy(this->hex_fg_, q, this->bpp_);
q += this->bpp_;
}
this->subrects_left_ = (flags & HEXTILE_ANY_SUBRECTS) ? *q : 0;
this->subrects_coloured_ = (flags & HEXTILE_SUBRECTS_COLOURED) != 0;
this->rx_pos_ += need;
this->fill_tile_(0, 0, this->tile_w_, this->tile_h_, this->hex_bg_);
this->step_ = VncStep::VNC_STEP_SUBRECTS;
break;
}
case VncStep::VNC_STEP_TILE_RAW: {
const size_t total = static_cast<size_t>(this->tile_w_) * this->tile_h_ * this->bpp_;
const size_t count = std::min(avail, total - this->tile_fill_);
if (count == 0) {
return VncParse::VNC_PARSE_MORE;
}
std::memcpy(this->tile_ + this->tile_fill_, p, count);
this->rx_pos_ += count;
this->tile_fill_ += count;
if (this->tile_fill_ == total) {
this->draw_tile_();
this->next_tile_();
}
break;
}
case VncStep::VNC_STEP_SUBRECTS: {
if (this->subrects_left_ == 0) {
this->draw_tile_();
this->next_tile_();
break;
}
const size_t need = (this->subrects_coloured_ ? this->bpp_ : 0) + 2;
if (avail < need) {
return VncParse::VNC_PARSE_MORE;
}
const uint8_t *pixel = this->hex_fg_;
const uint8_t *q = p;
if (this->subrects_coloured_) {
pixel = q;
q += this->bpp_;
}
this->fill_tile_(q[0] >> 4, q[0] & 0x0F, (q[1] >> 4) + 1, (q[1] & 0x0F) + 1, pixel);
this->rx_pos_ += need;
this->subrects_left_--;
break;
}
case VncStep::VNC_STEP_SKIP: {
const auto count = static_cast<uint32_t>(std::min<size_t>(avail, this->skip_left_));
this->rx_pos_ += count;
@@ -592,6 +749,20 @@ void VncClient::rect_done_() {
}
}
void VncClient::next_tile_() {
this->step_ = VncStep::VNC_STEP_TILE;
if (static_cast<uint32_t>(this->pos_x_) + 16 < static_cast<uint32_t>(this->rect_x_) + this->rect_w_) {
this->pos_x_ += 16;
return;
}
this->pos_x_ = this->rect_x_;
if (static_cast<uint32_t>(this->pos_y_) + 16 < static_cast<uint32_t>(this->rect_y_) + this->rect_h_) {
this->pos_y_ += 16;
return;
}
this->rect_done_();
}
void VncClient::frame_done_() {
this->in_update_ = false;
this->request_outstanding_ = false;
@@ -604,9 +775,9 @@ void VncClient::frame_done_() {
}
this->last_change_ms_ = now;
this->refresh_pending_ = true;
ESP_LOGD(TAG, "Update: %" PRIu32 " bytes in %" PRIu32 "ms",
ESP_LOGD(TAG, "Update: %" PRIu32 " bytes in %" PRIu32 "ms, %u hextile tiles drawn",
this->rx_total_ - (this->rx_len_ - this->rx_pos_) - this->update_start_bytes_,
millis() - this->update_start_ms_);
millis() - this->update_start_ms_, this->drawn_tiles_);
}
}
@@ -619,8 +790,96 @@ void VncClient::draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t
this->frame_drawn_ = true;
}
void VncClient::draw_tile_() {
if (this->tile_hidden_) {
return;
}
const int w = std::min<int>(this->tile_w_, this->vis_w_ - this->pos_x_);
const int h = std::min<int>(this->tile_h_, this->vis_h_ - this->pos_y_);
uint32_t *cell = nullptr;
uint32_t hash = 0;
if (this->hash_cols_ != 0) {
if ((this->pos_x_ | this->pos_y_) & 15) {
// An off-grid tile spans several cells, and a later write to any of them would make its hash stale.
this->forget_tiles_(this->pos_x_, this->pos_y_, w, h);
} else {
cell = this->tile_hashes_ + (this->pos_y_ / 16) * this->hash_cols_ + this->pos_x_ / 16;
hash = this->tile_hash_();
if (*cell == hash) {
return;
}
}
}
this->display_->draw_pixels_at(this->pos_x_, this->pos_y_, w, h, this->tile_, this->bitness_, this->big_endian_, 0, 0,
this->tile_w_ - w);
this->frame_drawn_ = true;
this->drawn_tiles_++;
if (cell != nullptr) {
*cell = hash;
}
}
uint32_t VncClient::tile_hash_() const {
// The tile's size, pixel size and pixels; the cell gives its place. Zero means "unknown".
uint32_t hash = fnv1a_hash_extend(FNV1_OFFSET_BASIS,
static_cast<uint32_t>(this->tile_w_) << 16 | this->tile_h_ << 8 | this->bpp_);
const size_t len = static_cast<size_t>(this->tile_w_) * this->tile_h_ * this->bpp_;
size_t i = 0;
for (; i + 4 <= len; i += 4) {
uint32_t word;
std::memcpy(&word, this->tile_ + i, sizeof(word)); // tile_ is aligned, so this is one load
// Byte by byte: XOR-ing whole words lets high-bit changes in two words cancel.
hash = fnv1a_hash_extend(hash, word);
}
for (; i < len; i++) {
hash = fnv1a_hash_extend(hash, this->tile_[i]);
}
return hash == 0 ? 1 : hash;
}
void VncClient::forget_tiles_(uint16_t x, uint16_t y, uint16_t w, uint16_t h) {
if (this->hash_cols_ == 0 || w == 0 || h == 0) {
return;
}
const uint16_t col_end = std::min<uint16_t>((x + w - 1) / 16, this->hash_cols_ - 1);
const uint16_t row_end = std::min<uint16_t>((y + h - 1) / 16, this->hash_rows_ - 1);
for (uint16_t row = y / 16; row <= row_end; row++) {
for (uint16_t col = x / 16; col <= col_end; col++) {
this->tile_hashes_[row * this->hash_cols_ + col] = 0;
}
}
}
void VncClient::fill_tile_(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t *pixel) {
if (this->tile_hidden_ || x >= this->tile_w_ || y >= this->tile_h_) {
return;
}
w = std::min<uint8_t>(w, this->tile_w_ - x);
h = std::min<uint8_t>(h, this->tile_h_ - y);
const size_t stride = static_cast<size_t>(this->tile_w_) * this->bpp_;
const size_t row_len = static_cast<size_t>(w) * this->bpp_;
uint8_t *first = this->tile_ + y * stride + x * this->bpp_;
if (this->bpp_ == 1) {
std::memset(first, pixel[0], w);
} else {
for (size_t i = 0; i < row_len; i += 2) {
first[i] = pixel[0];
first[i + 1] = pixel[1];
}
}
for (uint8_t row = 1; row < h; row++) {
std::memcpy(first + row * stride, first, row_len);
}
}
bool VncClient::start_session_(uint32_t now) {
const bool rgb565 = this->display_->get_display_type() != display::DISPLAY_TYPE_BINARY;
VncPixelFormat format = this->pixel_format_;
if (format == VncPixelFormat::VNC_PIXEL_FORMAT_AUTO) {
format = this->display_->get_display_type() == display::DISPLAY_TYPE_BINARY
? VncPixelFormat::VNC_PIXEL_FORMAT_RGB332
: VncPixelFormat::VNC_PIXEL_FORMAT_RGB565;
}
const bool rgb565 = format == VncPixelFormat::VNC_PIXEL_FORMAT_RGB565;
this->bpp_ = rgb565 ? 2 : 1;
this->bitness_ = rgb565 ? display::COLOR_BITNESS_565 : display::COLOR_BITNESS_332;
@@ -632,6 +891,24 @@ bool VncClient::start_session_(uint32_t now) {
ESP_LOGW(TAG, "Server screen is %ux%u but the display is %dx%d; showing the top-left %ux%u", this->fb_w_,
this->fb_h_, disp_w, disp_h, this->vis_w_, this->vis_h_);
}
const uint16_t cols = (this->vis_w_ + 15) / 16;
const uint16_t rows = (this->vis_h_ + 15) / 16;
const auto rotation = static_cast<uint8_t>(this->display_->get_rotation() / 90);
const bool rotated = rotation != this->hash_rotation_;
this->hash_rotation_ = rotation;
if (this->tile_hashes_ == nullptr) {
this->hash_cols_ = 0;
this->hash_rows_ = 0;
} else if (static_cast<uint32_t>(cols) * rows > this->hash_capacity_) {
ESP_LOGW(TAG, "Screen has %u tiles, more than the %u cached; every tile is drawn", cols * rows,
this->hash_capacity_);
this->hash_cols_ = 0;
this->hash_rows_ = 0;
} else if (cols != this->hash_cols_ || rows != this->hash_rows_ || rotated) {
this->hash_cols_ = cols;
this->hash_rows_ = rows;
this->clear_tiles_();
}
// SetPixelFormat: true colour, in the byte order the display takes
uint8_t pixel_format[20] = {};
@@ -648,10 +925,12 @@ bool VncClient::start_session_(uint32_t now) {
return false;
}
// SetEncodings: TightVNC uses the first real encoding it supports for every rectangle.
uint8_t encodings[12] = {2, 0, 0, 2};
write_be32(encodings + 4, ENCODING_RAW);
write_be32(encodings + 8, static_cast<uint32_t>(ENCODING_RICH_CURSOR));
// SetEncodings
uint8_t encodings[4 + 4 * std::size(ENCODINGS)] = {2};
write_be16(encodings + 2, std::size(ENCODINGS));
for (size_t i = 0; i < std::size(ENCODINGS); i++) {
write_be32(encodings + 4 + 4 * i, static_cast<uint32_t>(ENCODINGS[i]));
}
if (!this->send_(encodings, sizeof(encodings)) || !this->send_update_request_(false, now)) {
return false;
}
@@ -669,6 +948,12 @@ void VncClient::pace_(uint32_t now) {
if (this->display_busy_ || this->in_update_ || now - this->last_request_ms_ < MIN_REQUEST_SPACING_MS) {
return;
}
if (this->need_full_) {
if (this->send_update_request_(false, now)) {
this->need_full_ = false;
}
return;
}
// Repeating a parked request costs nothing on the server and detects a dead link.
if (!this->request_outstanding_ ||
(now - this->last_rx_ms_ >= HEARTBEAT_MS && now - this->last_request_ms_ >= HEARTBEAT_MS)) {
+41 -1
View File
@@ -10,6 +10,12 @@
namespace esphome::vnc {
enum class VncPixelFormat : uint8_t {
VNC_PIXEL_FORMAT_AUTO,
VNC_PIXEL_FORMAT_RGB565,
VNC_PIXEL_FORMAT_RGB332,
};
enum class VncState : uint8_t {
VNC_STATE_IDLE,
VNC_STATE_HANDSHAKE,
@@ -31,6 +37,9 @@ enum class VncStep : uint8_t {
VNC_STEP_RECT,
VNC_STEP_RECT_DONE,
VNC_STEP_RAW,
VNC_STEP_TILE,
VNC_STEP_TILE_RAW,
VNC_STEP_SUBRECTS,
VNC_STEP_SKIP,
};
@@ -55,6 +64,7 @@ class VncClient : public Component {
void set_reconnect_interval(uint32_t ms) { this->link_.set_reconnect_interval(ms); }
void set_update_delay(uint32_t ms) { this->update_delay_ms_ = ms; }
void set_max_update_delay(uint32_t ms) { this->max_update_delay_ms_ = ms; }
void set_pixel_format(VncPixelFormat format) { this->pixel_format_ = format; }
void setup() override;
void loop() override;
@@ -63,6 +73,8 @@ class VncClient : public Component {
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
bool is_connected() const { return this->state_ == VncState::VNC_STATE_RUNNING; }
/// Ask the server for the whole screen again.
void refresh();
template<typename F> void add_on_connect_callback(F &&callback) {
this->connect_callback_.add(std::forward<F>(callback));
@@ -73,6 +85,7 @@ class VncClient : public Component {
protected:
static constexpr size_t RX_BUFFER_SIZE = 512;
static constexpr size_t TILE_BUFFER_SIZE = 16 * 16 * 2;
void connect_step_(uint32_t now);
void start_handshake_(uint32_t now);
@@ -83,7 +96,13 @@ class VncClient : public Component {
bool send_client_init_();
void frame_done_();
void rect_done_();
void next_tile_();
void draw_tile_();
uint32_t tile_hash_() const;
void clear_tiles_();
void forget_tiles_(uint16_t x, uint16_t y, uint16_t w, uint16_t h);
void draw_row_(uint16_t x, uint16_t y, uint16_t count, const uint8_t *pixels);
void fill_tile_(uint8_t x, uint8_t y, uint8_t w, uint8_t h, const uint8_t *pixel);
void skip_(uint32_t count, VncStep next);
bool budget_exhausted_() const;
bool send_(const uint8_t *data, size_t len);
@@ -100,6 +119,9 @@ class VncClient : public Component {
LazyCallbackManager<void()> connect_callback_;
LazyCallbackManager<void()> disconnect_callback_;
HighFrequencyLoopRequester high_freq_;
// Fingerprint of the tile last drawn at each 16x16 cell, so tiles the server resends unchanged are skipped.
// A 32-bit FNV-1a collision keeps a stale tile until it changes again or vnc.refresh runs; accepted.
uint32_t *tile_hashes_{nullptr};
uint32_t update_delay_ms_{200};
uint32_t max_update_delay_ms_{1000};
@@ -131,15 +153,28 @@ class VncClient : public Component {
uint16_t rect_y_{0};
uint16_t rect_w_{0};
uint16_t rect_h_{0};
// Raw: column and row inside the rect.
// Raw: column and row inside the rect. Hextile: absolute origin of the current tile.
uint16_t pos_x_{0};
uint16_t pos_y_{0};
uint16_t tile_fill_{0};
uint16_t drawn_tiles_{0};
uint16_t hash_capacity_{0};
uint16_t hash_cols_{0};
uint16_t hash_rows_{0};
uint8_t tile_w_{0};
uint8_t tile_h_{0};
uint8_t subrects_left_{0};
uint8_t bpp_{2};
uint8_t minor_{8};
uint8_t sec_count_{0};
uint8_t text_len_{0}; // clamped to MAX_TEXT_LEN
// The display rotation the cached tiles were drawn at, in quarter turns.
uint8_t hash_rotation_{0};
uint8_t hex_bg_[2]{};
uint8_t hex_fg_[2]{};
display::ColorBitness bitness_{display::COLOR_BITNESS_565};
VncPixelFormat pixel_format_{VncPixelFormat::VNC_PIXEL_FORMAT_AUTO};
VncState state_{VncState::VNC_STATE_IDLE};
VncStep step_{VncStep::VNC_STEP_VERSION};
VncStep after_skip_{VncStep::VNC_STEP_MESSAGE};
@@ -149,14 +184,19 @@ class VncClient : public Component {
bool in_update_{false};
bool request_outstanding_{false};
bool refresh_pending_{false};
bool need_full_{false};
bool frame_drawn_{false};
bool subrects_coloured_{false};
bool tile_hidden_{false};
bool display_parks_{false};
bool display_busy_{false};
bool busy_warned_{false};
bool refresh_retried_{false}; // one retry per refresh the driver abandoned
bool network_was_up_{false};
bool big_endian_; // the pixel order the display takes
uint8_t rx_[RX_BUFFER_SIZE];
alignas(4) uint8_t tile_[TILE_BUFFER_SIZE];
};
} // namespace esphome::vnc
+13 -31
View File
@@ -5,6 +5,7 @@
#include "weikai.h"
#include "esphome/core/helpers.h"
#include "esphome/core/progmem.h"
namespace esphome::weikai {
@@ -37,35 +38,34 @@ const char *p2s(uart::UARTParityOptions parity) {
}
/// @brief Display a buffer in hexadecimal format (32 hex values / line) for debug
void print_buffer(const uint8_t *data, size_t length) {
void print_buffer(const char *tag, const uint8_t *data, size_t length, int level) {
char hex_buffer[100];
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < length; i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]);
if (i % 32 == 31) {
ESP_LOGVV(TAG, " %s", hex_buffer);
esp_log_printf_(level, tag, __LINE__, ESPHOME_LOG_FORMAT(" %s"), hex_buffer);
}
}
if (length % 32) {
// null terminate if incomplete line
hex_buffer[3 * (length % 32) + 2] = 0;
ESP_LOGVV(TAG, " %s", hex_buffer);
esp_log_printf_(level, tag, __LINE__, ESPHOME_LOG_FORMAT(" %s"), hex_buffer);
}
}
static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER",
"SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"};
static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL",
"TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"};
PROGMEM_STRING_TABLE(RegNamesPage0, "GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER", "SIFR", "TFCNT",
"RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485");
PROGMEM_STRING_TABLE(RegNamesPage1, "GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL", "TFTL", "FWTH",
"FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY");
// method to print a register value as text: used in the log messages ...
const char *reg_to_str(int reg, bool page1) {
const LogString *reg_to_str(int reg, bool page1) {
if (reg == WKREG_GPDAT) {
return "GPDAT";
return LOG_STR("GPDAT");
} else if (reg == WKREG_GPDIR) {
return "GPDIR";
return LOG_STR("GPDIR");
} else {
return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F];
return page1 ? RegNamesPage1::get_log_str(reg & 0x0F, 0) : RegNamesPage0::get_log_str(reg & 0x0F, 0);
}
}
@@ -495,24 +495,6 @@ class Increment {
uint8_t i_;
};
/// @brief Hex converter to print/display a buffer in hexadecimal format (32 hex values / line).
/// @param buffer contains the values to display
void print_buffer(std::vector<uint8_t> buffer) {
char hex_buffer[100];
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < buffer.size(); i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", buffer[i]);
if (i % 32 == 31) {
ESP_LOGI(TAG, " %s", hex_buffer);
}
}
if (buffer.size() % 32) {
// null terminate if incomplete line
hex_buffer[3 * (buffer.size() % 32) + 1] = 0;
ESP_LOGI(TAG, " %s", hex_buffer);
}
}
/// @brief test the write_array method
void WeikaiChannel::uart_send_test_(char *message) {
auto start_exec = micros();
@@ -560,7 +542,7 @@ bool WeikaiChannel::uart_receive_test_(char *message) {
for (size_t i = 0; i < RING_BUFFER_SIZE; i++) {
if (buffer[i] != i % XFER_MAX_SIZE) {
ESP_LOGE(TAG, "Read buffer contains error b=%x i=%x", buffer[i], i % XFER_MAX_SIZE);
print_buffer(buffer);
print_buffer(TAG, buffer.data(), buffer.size(), ESPHOME_LOG_LEVEL_INFO);
status = false;
break;
}
+6
View File
@@ -40,6 +40,12 @@ constexpr size_t FIFO_SIZE = 256;
/// @brief size of the ring buffer set to size of the FIFO
constexpr size_t RING_BUFFER_SIZE = FIFO_SIZE;
/// @brief Name of a register for the log messages
const LogString *reg_to_str(int reg, bool page1);
/// Logs a buffer in hexadecimal, 32 values per line, under @p tag at @p level.
void print_buffer(const char *tag, const uint8_t *data, size_t length, int level = ESPHOME_LOG_LEVEL_VERY_VERBOSE);
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/// @brief This is an helper class that provides a simple ring buffers that works as a FIFO
/// @details This ring buffer is used to buffer the bytes received in the FIFO of the Weika device. The best way to read
+6 -37
View File
@@ -8,38 +8,7 @@
namespace esphome::weikai_i2c {
ESPHOME_LOG_TAG(TAG, "weikai_i2c");
/// @brief Display a buffer in hexadecimal format (32 hex values / line).
void print_buffer(const uint8_t *data, size_t length) {
char hex_buffer[100];
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < length; i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]);
if (i % 32 == 31) {
ESP_LOGVV(TAG, " %s", hex_buffer);
}
}
if (length % 32) {
// null terminate if incomplete line
hex_buffer[3 * (length % 32) + 2] = 0;
ESP_LOGVV(TAG, " %s", hex_buffer);
}
}
static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER",
"SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"};
static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL",
"TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"};
using namespace weikai;
// method to print a register value as text: used in the log messages ...
const char *reg_to_str(int reg, bool page1) {
if (reg == WKREG_GPDAT) {
return "GPDAT";
} else if (reg == WKREG_GPDIR) {
return "GPDIR";
} else {
return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F];
}
}
enum RegType { REG = 0, FIFO = 1 }; ///< Register or FIFO
/// @brief Computes the I²C bus's address used to access the component
@@ -72,11 +41,11 @@ uint8_t WeikaiRegisterI2C::read_reg() const {
if (error == i2c::NO_ERROR) {
this->comp_->status_clear_warning();
ESP_LOGVV(TAG, "WeikaiRegisterI2C::read_reg() @%02X reg=%s ch=%u I2C_code:%d, buf=%02X", address,
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
} else { // error
this->comp_->status_set_warning();
ESP_LOGE(TAG, "WeikaiRegisterI2C::read_reg() @%02X reg=%s ch=%u I2C_code:%d, buf=%02X", address,
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
}
return value;
}
@@ -91,7 +60,7 @@ void WeikaiRegisterI2C::read_fifo(uint8_t *data, size_t length) const {
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
ESP_LOGVV(TAG, "WeikaiRegisterI2C::read_fifo() @%02X ch=%d I2C_code:%d len=%d buffer", address, this->channel_,
(int) error, length);
print_buffer(data, length);
print_buffer(TAG, data, length);
#endif
} else { // error
this->comp_->status_set_warning();
@@ -108,11 +77,11 @@ void WeikaiRegisterI2C::write_reg(uint8_t value) {
if (error == i2c::NO_ERROR) {
this->comp_->status_clear_warning();
ESP_LOGVV(TAG, "WK2168Reg::write_reg() @%02X reg=%s ch=%d I2C_code:%d buf=%02X", address,
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
} else { // error
this->comp_->status_set_warning();
ESP_LOGE(TAG, "WK2168Reg::write_reg() @%02X reg=%s ch=%d I2C_code:%d buf=%d", address,
reg_to_str(this->register_, comp_i2c->page1()), this->channel_, (int) error, value);
LOG_STR_ARG(reg_to_str(this->register_, comp_i2c->page1())), this->channel_, (int) error, value);
}
}
@@ -126,7 +95,7 @@ void WeikaiRegisterI2C::write_fifo(uint8_t *data, size_t length) {
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
ESP_LOGVV(TAG, "WK2168Reg::write_fifo() @%02X ch=%d I2C_code:%d len=%d buffer", address, this->channel_,
(int) error, length);
print_buffer(data, length);
print_buffer(TAG, data, length);
#endif
} else { // error
this->comp_->status_set_warning();
+4 -37
View File
@@ -35,39 +35,6 @@ const char *p2s(uart::UARTParityOptions parity) {
}
}
/// @brief Display a buffer in hexadecimal format (32 hex values / line).
void print_buffer(const uint8_t *data, size_t length) {
char hex_buffer[100];
hex_buffer[(3 * 32) + 1] = 0;
for (size_t i = 0; i < length; i++) {
snprintf(&hex_buffer[3 * (i % 32)], sizeof(hex_buffer), "%02X ", data[i]);
if (i % 32 == 31) {
ESP_LOGVV(TAG, " %s", hex_buffer);
}
}
if (length % 32) {
// null terminate if incomplete line
hex_buffer[3 * (length % 32) + 2] = 0;
ESP_LOGVV(TAG, " %s", hex_buffer);
}
}
static const char *const REG_TO_STR_P0[16] = {"GENA", "GRST", "GMUT", "SPAGE", "SCR", "LCR", "FCR", "SIER",
"SIFR", "TFCNT", "RFCNT", "FSR", "LSR", "FDAT", "FWCR", "RS485"};
static const char *const REG_TO_STR_P1[16] = {"GENA", "GRST", "GMUT", "SPAGE", "BAUD1", "BAUD0", "PRES", "RFTL",
"TFTL", "FWTH", "FWTL", "XON1", "XOFF1", "SADR", "SAEN", "RTSDLY"};
// method to print a register value as text: used in the log messages ...
const char *reg_to_str(int reg, bool page1) {
if (reg == WKREG_GPDAT) {
return "GPDAT";
} else if (reg == WKREG_GPDIR) {
return "GPDIR";
} else {
return page1 ? REG_TO_STR_P1[reg & 0x0F] : REG_TO_STR_P0[reg & 0x0F];
}
}
enum RegType { REG = 0, FIFO = 1 }; ///< Register or FIFO
enum CmdType { WRITE_CMD = 0, READ_CMD = 1 }; ///< Read or Write transfer
@@ -101,7 +68,7 @@ uint8_t WeikaiRegisterSPI::read_reg() const {
spi_comp->disable();
char bin_buf[9];
ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", format_bin_to(bin_buf, cmd), cmd,
reg_to_str(this->register_, this->comp_->page1()), this->channel_, val);
LOG_STR_ARG(reg_to_str(this->register_, this->comp_->page1())), this->channel_, val);
return val;
}
@@ -116,7 +83,7 @@ void WeikaiRegisterSPI::read_fifo(uint8_t *data, size_t length) const {
char bin_buf[9];
ESP_LOGVV(TAG, "WeikaiRegisterSPI::read_fifo() cmd=%s(%02X) ch=%d len=%d buffer", format_bin_to(bin_buf, cmd), cmd,
this->channel_, length);
print_buffer(data, length);
print_buffer(TAG, data, length);
#endif
}
@@ -128,7 +95,7 @@ void WeikaiRegisterSPI::write_reg(uint8_t value) {
spi_comp->disable();
char bin_buf[9];
ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_reg() cmd=%s(%02X) reg=%s ch=%d buf=%02X", format_bin_to(bin_buf, buf[0]),
buf[0], reg_to_str(this->register_, this->comp_->page1()), this->channel_, buf[1]);
buf[0], LOG_STR_ARG(reg_to_str(this->register_, this->comp_->page1())), this->channel_, buf[1]);
}
void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) {
@@ -143,7 +110,7 @@ void WeikaiRegisterSPI::write_fifo(uint8_t *data, size_t length) {
char bin_buf[9];
ESP_LOGVV(TAG, "WeikaiRegisterSPI::write_fifo() cmd=%s(%02X) ch=%d len=%d buffer", format_bin_to(bin_buf, cmd), cmd,
this->channel_, length);
print_buffer(data, length);
print_buffer(TAG, data, length);
#endif
}
+2 -6
View File
@@ -11,6 +11,7 @@ from esphome.components.esp32 import (
get_esp32_variant,
only_on_variant,
request_wifi,
require_idf_version,
require_mbedtls_tls,
require_mbedtls_tls_extras,
)
@@ -65,7 +66,6 @@ from esphome.const import (
CONF_USE_ADDRESS,
CONF_USERNAME,
PLACEHOLDER_WIFI_SSID,
Framework,
Platform,
PlatformFramework,
)
@@ -233,10 +233,7 @@ def _validate_dns_servers(value: list) -> list:
if any(ip.is_unspecified for ip in value):
raise cv.Invalid("0.0.0.0 is not a DNS server the access point can offer")
if len(value) > 1:
cv.require_framework_version(
esp_idf=cv.Version(6, 0, 0),
extra_message="The access point offers a second DNS server from ESP-IDF 6.0",
)(value)
require_idf_version(cv.Version(6, 0, 0), "A second DNS server")(value)
return value
@@ -645,7 +642,6 @@ def _lease_cache_schema(value: Any) -> ConfigType:
value = LEASE_CACHE_SCHEMA(value)
if value[CONF_ENABLED]:
cv.only_on_esp32(value)
cv.only_with_framework(Framework.ESP_IDF)(value)
if CONF_STORAGE not in value:
if not preferences.rtc_supported():
raise cv.Invalid(
+1
View File
@@ -297,6 +297,7 @@
#define USE_STATUS_LED
#define USE_STATUS_SENSOR
#define USE_SWITCH
#define USE_SWITCH_STORAGE
#define USE_TEXT
#define USE_TEXT_SENSOR
#define USE_TEXT_SENSOR_FILTER
+48
View File
@@ -42,6 +42,7 @@ from esphome.helpers import (
sanitize,
snake_case,
)
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType, EntityMetadata, Expression, SafeExpType
_LOGGER = logging.getLogger(__name__)
@@ -764,3 +765,50 @@ def entity_duplicate_validator(platform: str) -> Callable[[ConfigType], ConfigTy
return config
return validator
# restore_state: keywords, shared by the entity domains that save their state
RESTORE_STATE_KEEP = "KEEP"
RESTORE_STATE_INVERT = "INVERT"
RESTORE_STATE_INITIAL = "INITIAL"
RESTORE_STATE_ALL = "ALL"
RESTORE_STATE_NONE = "NONE"
def restore_state_keyword(value: Any, keywords: tuple[str, ...]) -> str | None:
"""The upper-cased keyword when `value` spells one of `keywords`, else None."""
if isinstance(value, str):
upper = value.strip().upper()
if upper in keywords:
return upper
return None
def restore_state_schema(fields_schema: cv.Schema, none: Any) -> Callable[[Any], Any]:
"""The validator for a domain's `restore_state:` key.
A mapping is validated with `fields_schema`. The shorthand `all` is the empty mapping,
so every field keeps its saved value, and `none` is returned as it is; a mapping goes through
`fields_schema` like one the user wrote, so each config gets its own copy.
"""
@schema_extractor("schema")
def validate(value: Any) -> Any:
if value == SCHEMA_EXTRACT:
# The shorthands have no representation in the extracted schema
return fields_schema
keyword = restore_state_keyword(value, (RESTORE_STATE_ALL, RESTORE_STATE_NONE))
if keyword == RESTORE_STATE_ALL:
value = {}
elif keyword == RESTORE_STATE_NONE:
if not isinstance(none, dict):
return none
value = none
elif isinstance(value, str):
raise cv.Invalid(
f"Unknown restore_state '{value}'; expected 'all', 'none' or a mapping, "
"for example '{state: INVERT}'"
)
return fields_schema(value)
return validate
+2 -2
View File
@@ -132,8 +132,8 @@ UDP_FLAG_COMMITTED = 0x80
UDP_MSG_ACK = 0x40
UDP_HEADER_SIZE = 9
UDP_ACK_HEADER_SIZE = 12
# Stream bytes per datagram, within one Ethernet frame
UDP_MAX_PAYLOAD = 1400
# A 1280-byte Thread IPv6 packet minus the IPv6, UDP, and OTA headers
UDP_MAX_PAYLOAD = 1280 - 40 - 8 - UDP_HEADER_SIZE
# Messages the device queues unconsumed (UDP_WINDOW in ota_esphome.h); as many
# as TCP's window holds, so UDP costs the device no more memory than TCP
UDP_WINDOW = 4
+2 -2
View File
@@ -48,7 +48,7 @@ lib_deps =
improv/Improv@1.2.7 ; improv_serial / improv_ble
kikuchan98/pngle@1.1.0 ; runtime_image
; Using the repository directly, otherwise ESP-IDF can't use the library
https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image
https://github.com/esphome-libs/JPEGDEC.git#1.8.4-esphome.3 ; runtime_image
; This dependency is used only in unit tests.
; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py
; See scripts/cpp_unit_test.py and tests/components/README.md
@@ -253,7 +253,7 @@ lib_deps =
heman/AsyncMqttClient-esphome@2.0.0 ; mqtt
improv/Improv@1.2.7 ; improv_serial
kikuchan98/pngle@1.1.0 ; runtime_image
https://github.com/bitbank2/JPEGDEC.git#1.8.4 ; runtime_image
https://github.com/esphome-libs/JPEGDEC.git#1.8.4-esphome.3 ; runtime_image
build_flags =
${common:arduino.build_flags}
-DUSE_LIBRETINY
+3 -3
View File
@@ -12,7 +12,7 @@ pyserial==3.5
platformio==6.2.0
esptool==5.6.0
click==8.5.0
aioesphomeapi==46.21.0
aioesphomeapi==46.22.0
aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi
zeroconf==0.151.5
puremagic==2.2.0
@@ -31,9 +31,9 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64"
smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64"
requests==2.34.2
py7zr==1.1.4
platformdirs==4.13.4 # native esp-idf toolchain global cache dir
platformdirs==4.13.5 # native esp-idf toolchain global cache dir
ninja==1.13.2 # native host and esp8266 arduino toolchain build driver
filelock==4.2.2 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
filelock==4.2.4 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.3
Loaded 100 of 281 files, more files were not shown because too many files have changed in this diff. Show more