[mcp4461] nonvolatile-by-default persistence, TCON boot sync, wiper actions (#17561)

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: Oliver Kleinecke <kleinecke.oliver@googlemail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver Kleinecke
2026-08-05 14:11:03 -04:00
committed by GitHub
co-authored by Claude pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Oliver Kleinecke Copilot Autofix powered by AI
parent 9bfce75bc5
commit 4627f07a7b
7 changed files with 404 additions and 29 deletions
+145 -8
View File
@@ -21,7 +21,18 @@ void Mcp4461Component::setup() {
auto init_val = this->reg_[i].initial_value; auto init_val = this->reg_[i].initial_value;
if (init_val.has_value()) { if (init_val.has_value()) {
uint16_t initial_state = static_cast<uint16_t>(*init_val * 256.0f); uint16_t initial_state = static_cast<uint16_t>(*init_val * 256.0f);
this->write_wiper_level_(i, initial_state); if (i > 3) {
// NV wiper: an unconditional write would cost one EEPROM erase/write cycle on EVERY
// boot. Only write when the stored value actually differs — and always write when
// the read itself failed (a failed read returns 0, which would silently skip the
// write whenever initial_value is 0).
bool read_ok = false;
if (this->read_wiper_level_(i, &read_ok) != initial_state || !read_ok) {
this->write_wiper_level_(i, initial_state);
}
} else {
this->write_wiper_level_(i, initial_state);
}
} }
if (this->reg_[i].enabled) { if (this->reg_[i].enabled) {
this->reg_[i].state = this->read_wiper_level_(i); this->reg_[i].state = this->read_wiper_level_(i);
@@ -34,6 +45,23 @@ void Mcp4461Component::setup() {
} }
} }
} }
// Push the YAML terminal configuration to the TCON registers. TCON is volatile — on POR
// the chip restores wiper levels from the NV registers but resets TCON to "all terminals
// connected", so any terminal_a/b/w disables from the config MUST be written here.
for (uint8_t t = 0; t < 2; t++) {
Mcp4461TerminalIdx terminal_connector = static_cast<Mcp4461TerminalIdx>(t);
uint8_t terminal_byte = this->calc_terminal_connector_byte_(terminal_connector);
this->set_terminal_register_(terminal_connector, terminal_byte);
}
}
void Mcp4461Component::set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms) {
uint8_t wiper_idx = static_cast<uint8_t>(wiper);
if (wiper_idx > 3) {
return; // NV channels E-H are the persistence target themselves
}
this->reg_[wiper_idx].nonvolatile = true;
this->reg_[wiper_idx].nonvolatile_write_delay_ms = write_delay_ms;
} }
void Mcp4461Component::set_initial_value(Mcp4461WiperIdx wiper, float initial_value) { void Mcp4461Component::set_initial_value(Mcp4461WiperIdx wiper, float initial_value) {
@@ -77,9 +105,12 @@ void Mcp4461Component::dump_config() {
// so also invalid for nonvolatile. For these, only print current level. // so also invalid for nonvolatile. For these, only print current level.
// reworked to be a one-line intentionally, as output would not be in order // reworked to be a one-line intentionally, as output would not be in order
if (i < 4) { if (i < 4) {
ESP_LOGCONFIG(TAG, " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, A: %s, B: %s, W: %s", i, ESP_LOGCONFIG(TAG,
this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw), " ├── Volatile wiper [%u] level: %u, Status: %s, HW: %s, "
ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w)); "A: %s, B: %s, W: %s, NV: %s",
i, this->reg_[i].state, ONOFF(this->reg_[i].enabled), ONOFF(this->reg_[i].terminal_hw),
ONOFF(this->reg_[i].terminal_a), ONOFF(this->reg_[i].terminal_b), ONOFF(this->reg_[i].terminal_w),
ONOFF(this->reg_[i].nonvolatile));
} else { } else {
ESP_LOGCONFIG(TAG, " ├── Nonvolatile wiper [%u] level: %u", i, this->reg_[i].state); ESP_LOGCONFIG(TAG, " ├── Nonvolatile wiper [%u] level: %u", i, this->reg_[i].state);
} }
@@ -92,8 +123,10 @@ void Mcp4461Component::loop() {
} }
for (uint8_t i = 0; i < 8; i++) { for (uint8_t i = 0; i < 8; i++) {
if (this->reg_[i].update_level) { if (this->reg_[i].update_level) {
// set wiper i state if changed // set wiper i state if changed — a failed read (returns 0) must not suppress the
if (this->reg_[i].state != this->read_wiper_level_(i)) { // write when the target state is 0, same hardening as the NV read-compare paths
bool read_ok = false;
if (this->reg_[i].state != this->read_wiper_level_(i, &read_ok) || !read_ok) {
this->write_wiper_level_(i, this->reg_[i].state); this->write_wiper_level_(i, this->reg_[i].state);
} }
} }
@@ -112,6 +145,67 @@ void Mcp4461Component::loop() {
} }
this->reg_[i].update_terminal = false; this->reg_[i].update_terminal = false;
} }
this->process_nonvolatile_dirty_();
}
void Mcp4461Component::process_nonvolatile_dirty_() {
const uint32_t now = millis();
for (uint8_t i = 0; i < 4; i++) {
if (!this->reg_[i].nonvolatile || !this->reg_[i].nonvolatile_dirty) {
continue;
}
if ((now - this->reg_[i].last_level_change_ms) < this->reg_[i].nonvolatile_write_delay_ms) {
continue; // still settling — debounce window not over yet
}
// Never block the loop on a still-running EEPROM cycle (t_WC up to 10 ms); datasheet:
// during an EEPROM write only volatile commands are accepted. Retry on the next loop.
if (this->is_writing_()) {
continue;
}
// Clear the dirty flag on success — and equally when WP or WiperLock block the write
// permanently, instead of retrying forever.
if (this->store_level_nonvolatile_(static_cast<Mcp4461WiperIdx>(i)) || this->write_protected_ ||
this->reg_[i].wiper_lock_active) {
this->reg_[i].nonvolatile_dirty = false;
} else {
// Transient failure (e.g. I2C error): without this, the retry fires on every single
// loop() iteration, spamming a warning each time. Re-arming the timestamp reuses the
// stability delay as a natural retry backoff.
this->reg_[i].last_level_change_ms = now;
}
}
}
bool Mcp4461Component::store_level_nonvolatile_(Mcp4461WiperIdx wiper) {
if (this->is_failed()) {
ESP_LOGE(TAG, "%s", LOG_STR_ARG(this->get_message_string(this->error_code_)));
return false;
}
uint8_t wiper_idx = static_cast<uint8_t>(wiper);
if (wiper_idx > 3) {
// E-H ARE the nonvolatile registers — keep this consistent with the other guards
// instead of failing silently (reachable via the store_nonvolatile action).
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
return false;
}
if (this->reg_[wiper_idx].wiper_lock_active) {
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED)));
return false;
}
const uint16_t level = this->reg_[wiper_idx].state;
// Skip the EEPROM cycle entirely when the NV register already holds the value. A failed
// read must NOT count as a match (it returns 0): fall through to the write instead — if
// the bus is really down, the write fails too and the dirty flag stays set for a retry.
bool read_ok = false;
if (this->read_wiper_level_(wiper_idx + 4, &read_ok) == level && read_ok) {
return true;
}
ESP_LOGV(TAG, "Persisting wiper %u level %u to nonvolatile register", wiper_idx, level);
if (!this->mcp4461_write_(this->get_wiper_address_(wiper_idx + 4), level, true)) {
ESP_LOGW(TAG, "Error persisting wiper %u level %u", wiper_idx, level);
return false;
}
return true;
} }
uint8_t Mcp4461Component::get_status_register_() { uint8_t Mcp4461Component::get_status_register_() {
@@ -210,7 +304,10 @@ uint16_t Mcp4461Component::get_wiper_level_(Mcp4461WiperIdx wiper) {
return this->read_wiper_level_(wiper_idx); return this->read_wiper_level_(wiper_idx);
} }
uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) { uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx, bool *ok) {
if (ok != nullptr) {
*ok = false;
}
uint8_t addr = this->get_wiper_address_(wiper_idx); uint8_t addr = this->get_wiper_address_(wiper_idx);
uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::READ); uint8_t reg = addr | static_cast<uint8_t>(Mcp4461Commands::READ);
if (wiper_idx > 3) { if (wiper_idx > 3) {
@@ -225,6 +322,9 @@ uint16_t Mcp4461Component::read_wiper_level_(uint8_t wiper_idx) {
ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx); ESP_LOGW(TAG, "Error fetching %swiper %u value", (wiper_idx > 3) ? "nonvolatile " : "", wiper_idx);
return 0; return 0;
} }
if (ok != nullptr) {
*ok = true;
}
return buf; return buf;
} }
@@ -265,6 +365,10 @@ bool Mcp4461Component::set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value) {
ESP_LOGV(TAG, "Setting MCP4461 wiper %u to %u", wiper_idx, value); ESP_LOGV(TAG, "Setting MCP4461 wiper %u to %u", wiper_idx, value);
this->reg_[wiper_idx].state = value; this->reg_[wiper_idx].state = value;
this->reg_[wiper_idx].update_level = true; this->reg_[wiper_idx].update_level = true;
if (this->reg_[wiper_idx].nonvolatile) {
this->reg_[wiper_idx].nonvolatile_dirty = true;
this->reg_[wiper_idx].last_level_change_ms = millis();
}
return true; return true;
} }
@@ -335,6 +439,12 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) {
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED)));
return false; return false;
} }
if (wiper_idx > 3) {
// Datasheet: increment commands are only valid for the volatile wiper registers —
// the chip NACKs them on nonvolatile addresses.
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
return false;
}
if (this->reg_[wiper_idx].state == 256) { if (this->reg_[wiper_idx].state == 256) {
ESP_LOGV(TAG, "Maximum wiper level reached, further increase of wiper %u prohibited", wiper_idx); ESP_LOGV(TAG, "Maximum wiper level reached, further increase of wiper %u prohibited", wiper_idx);
return false; return false;
@@ -349,6 +459,10 @@ bool Mcp4461Component::increase_wiper_(Mcp4461WiperIdx wiper) {
return false; return false;
} }
this->reg_[wiper_idx].state++; this->reg_[wiper_idx].state++;
if (this->reg_[wiper_idx].nonvolatile) {
this->reg_[wiper_idx].nonvolatile_dirty = true;
this->reg_[wiper_idx].last_level_change_ms = millis();
}
return true; return true;
} }
@@ -366,6 +480,12 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) {
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED))); ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_WIPER_LOCKED)));
return false; return false;
} }
if (wiper_idx > 3) {
// Datasheet: decrement commands are only valid for the volatile wiper registers —
// the chip NACKs them on nonvolatile addresses.
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
return false;
}
if (this->reg_[wiper_idx].state == 0) { if (this->reg_[wiper_idx].state == 0) {
ESP_LOGV(TAG, "Minimum wiper level reached, further decrease of wiper %u prohibited", wiper_idx); ESP_LOGV(TAG, "Minimum wiper level reached, further decrease of wiper %u prohibited", wiper_idx);
return false; return false;
@@ -380,11 +500,18 @@ bool Mcp4461Component::decrease_wiper_(Mcp4461WiperIdx wiper) {
return false; return false;
} }
this->reg_[wiper_idx].state--; this->reg_[wiper_idx].state--;
if (this->reg_[wiper_idx].nonvolatile) {
this->reg_[wiper_idx].nonvolatile_dirty = true;
this->reg_[wiper_idx].last_level_change_ms = millis();
}
return true; return true;
} }
uint8_t Mcp4461Component::calc_terminal_connector_byte_(Mcp4461TerminalIdx terminal_connector) { uint8_t Mcp4461Component::calc_terminal_connector_byte_(Mcp4461TerminalIdx terminal_connector) {
uint8_t i = static_cast<uint8_t>(terminal_connector) <= 1 ? 0 : 2; // TCON0 covers wipers 0/1 (A/B), TCON1 covers wipers 2/3 (C/D). The enum only holds
// 0 and 1, so the old `<= 1 ? 0 : 2` collapsed to always-0 and built TCON1 from
// channels A/B's flags — mirror the (correct) read path in update_terminal_register_().
uint8_t i = static_cast<uint8_t>(terminal_connector) == 0 ? 0 : 2;
uint8_t new_value_byte = 0; uint8_t new_value_byte = 0;
new_value_byte += static_cast<uint8_t>(this->reg_[i].terminal_b); new_value_byte += static_cast<uint8_t>(this->reg_[i].terminal_b);
new_value_byte += static_cast<uint8_t>(this->reg_[i].terminal_w) << 1; new_value_byte += static_cast<uint8_t>(this->reg_[i].terminal_w) << 1;
@@ -471,6 +598,12 @@ void Mcp4461Component::enable_terminal_(Mcp4461WiperIdx wiper, char terminal) {
return; return;
} }
uint8_t wiper_idx = static_cast<uint8_t>(wiper); uint8_t wiper_idx = static_cast<uint8_t>(wiper);
if (wiper_idx > 3) {
// Terminal control only exists for the volatile wipers; loop() would otherwise emit
// an unrelated TCON write and silently drop the request.
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
return;
}
ESP_LOGV(TAG, "Enabling terminal %c of wiper %u", terminal, wiper_idx); ESP_LOGV(TAG, "Enabling terminal %c of wiper %u", terminal, wiper_idx);
switch (terminal) { switch (terminal) {
case 'h': case 'h':
@@ -498,6 +631,10 @@ void Mcp4461Component::disable_terminal_(Mcp4461WiperIdx wiper, char terminal) {
return; return;
} }
uint8_t wiper_idx = static_cast<uint8_t>(wiper); uint8_t wiper_idx = static_cast<uint8_t>(wiper);
if (wiper_idx > 3) {
ESP_LOGW(TAG, "%s", LOG_STR_ARG(this->get_message_string(MCP4461_PROHIBITED_FOR_NONVOLATILE)));
return;
}
ESP_LOGV(TAG, "Disabling terminal %c of wiper %u", terminal, wiper_idx); ESP_LOGV(TAG, "Disabling terminal %c of wiper %u", terminal, wiper_idx);
switch (terminal) { switch (terminal) {
case 'h': case 'h':
+27 -1
View File
@@ -17,6 +17,16 @@ struct WiperState {
bool wiper_lock_active = false; bool wiper_lock_active = false;
bool update_level = false; bool update_level = false;
bool update_terminal = false; bool update_terminal = false;
// Nonvolatile persistence (volatile wipers 0-3 only): when enabled, every level change is
// mirrored into the chip's NV wiper register after nonvolatile_write_delay of stability, so
// the chip restores it on power-on. The delay both debounces bursts (e.g. light transitions
// writing dozens of levels per second) and protects the EEPROM's limited endurance —
// without it, every intermediate step would cost one of the ~1M erase/write cycles and
// stall the bus for up to t_WC (10 ms) each.
bool nonvolatile = false;
uint32_t nonvolatile_write_delay_ms = 1000;
bool nonvolatile_dirty = false;
uint32_t last_level_change_ms = 0;
}; };
// default wiper state is 128 / 0x80h // default wiper state is 128 / 0x80h
@@ -86,6 +96,11 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice {
/// @param[in] wiper - the wiper to set the value for /// @param[in] wiper - the wiper to set the value for
/// @param[in] initial_value - the initial value in range 0-1.0 as float /// @param[in] initial_value - the initial value in range 0-1.0 as float
void set_initial_value(Mcp4461WiperIdx wiper, float initial_value); void set_initial_value(Mcp4461WiperIdx wiper, float initial_value);
/// @brief enable nonvolatile persistence for a volatile wiper (0-3): every level change is
/// mirrored to the corresponding NV wiper register after the given stability delay
/// @param[in] wiper - the (volatile) wiper to persist
/// @param[in] write_delay_ms - stability delay before the NV write (debounce / EEPROM wear)
void set_nonvolatile(Mcp4461WiperIdx wiper, uint32_t write_delay_ms);
/// @brief public function used to set disable terminal config /// @brief public function used to set disable terminal config
/// @param[in] wiper - the wiper to set the value for /// @param[in] wiper - the wiper to set the value for
/// @param[in] terminal - the terminal to disable, one of ['a','b','w','h'] /// @param[in] terminal - the terminal to disable, one of ['a','b','w','h']
@@ -98,7 +113,10 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice {
bool read_16_(uint8_t address, uint16_t *buf); bool read_16_(uint8_t address, uint16_t *buf);
void update_write_protection_status_(); void update_write_protection_status_();
uint8_t get_wiper_address_(uint8_t wiper); uint8_t get_wiper_address_(uint8_t wiper);
uint16_t read_wiper_level_(uint8_t wiper); /// Read a wiper register. On I2C failure returns 0 — callers that must distinguish
/// a real 0 from a failed read pass `ok` (added for the NV read-compare paths, where
/// acting on a failed read would skip a required write or drop a pending persist).
uint16_t read_wiper_level_(uint8_t wiper, bool *ok = nullptr);
uint8_t get_status_register_(); uint8_t get_status_register_();
uint16_t get_wiper_level_(Mcp4461WiperIdx wiper); uint16_t get_wiper_level_(Mcp4461WiperIdx wiper);
bool set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value); bool set_wiper_level_(Mcp4461WiperIdx wiper, uint16_t value);
@@ -110,6 +128,11 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice {
void enable_terminal_(Mcp4461WiperIdx wiper, char terminal); void enable_terminal_(Mcp4461WiperIdx wiper, char terminal);
void disable_terminal_(Mcp4461WiperIdx, char terminal); void disable_terminal_(Mcp4461WiperIdx, char terminal);
bool is_writing_(); bool is_writing_();
/// Copy the current volatile level of wiper 0-3 into its NV register (immediate, blocking
/// only for a pending previous EEPROM cycle). Returns false while WP is active or on error.
bool store_level_nonvolatile_(Mcp4461WiperIdx wiper);
/// Deferred NV mirroring driven from loop() — see WiperState::nonvolatile.
void process_nonvolatile_dirty_();
bool is_eeprom_ready_for_writing_(bool wait_if_not_ready); bool is_eeprom_ready_for_writing_(bool wait_if_not_ready);
void write_wiper_level_(uint8_t wiper, uint16_t value); void write_wiper_level_(uint8_t wiper, uint16_t value);
bool mcp4461_write_(uint8_t addr, uint16_t data, bool nonvolatile = false); bool mcp4461_write_(uint8_t addr, uint16_t data, bool nonvolatile = false);
@@ -139,6 +162,9 @@ class Mcp4461Component final : public Component, public i2c::I2CDevice {
return LOG_STR("MCP4461 Wiper is locked using WiperLock-technology. All actions on this wiper are prohibited."); return LOG_STR("MCP4461 Wiper is locked using WiperLock-technology. All actions on this wiper are prohibited.");
case MCP4461_STATUS_OK: case MCP4461_STATUS_OK:
return LOG_STR("Status OK"); return LOG_STR("Status OK");
case MCP4461_PROHIBITED_FOR_NONVOLATILE:
return LOG_STR(
"Increment/decrement, store, and terminal control are prohibited on the nonvolatile wipers (E-H).");
default: default:
return LOG_STR("Unknown"); return LOG_STR("Unknown");
} }
@@ -1,3 +1,4 @@
from esphome import automation
import esphome.codegen as cg import esphome.codegen as cg
from esphome.components import output from esphome.components import output
import esphome.config_validation as cv import esphome.config_validation as cv
@@ -26,6 +27,43 @@ CHANNEL_OPTIONS = {
CONF_TERMINAL_A = "terminal_a" CONF_TERMINAL_A = "terminal_a"
CONF_TERMINAL_B = "terminal_b" CONF_TERMINAL_B = "terminal_b"
CONF_TERMINAL_W = "terminal_w" CONF_TERMINAL_W = "terminal_w"
CONF_NONVOLATILE = "nonvolatile"
CONF_NONVOLATILE_WRITE_DELAY = "nonvolatile_write_delay"
# Volatile wiper channels that have a nonvolatile shadow register on the chip
VOLATILE_CHANNELS = ("A", "B", "C", "D")
def _validate_nonvolatile(config):
channel = str(config[CONF_CHANNEL])
# Channels E-H address the nonvolatile registers directly — the mirroring options only
# make sense for the volatile channels A-D.
if channel not in VOLATILE_CHANNELS:
# Only reject what the user EXPLICITLY asked for and cannot have: enabling the
# mirroring or tuning its delay on E-H. An explicit `nonvolatile: false` is a
# harmless no-op and stays valid; bare configs (no key at all) must keep working.
# NOTE: FINAL_VALIDATE_SCHEMA intentionally mutates `config` in-place (uses setdefault) to apply defaults for callers.
if config.get(CONF_NONVOLATILE) or CONF_NONVOLATILE_WRITE_DELAY in config:
raise cv.Invalid(
f"enabling '{CONF_NONVOLATILE}' or setting '{CONF_NONVOLATILE_WRITE_DELAY}' is only valid for the "
f"volatile channels A-D; channels E-H are the nonvolatile registers themselves"
)
return config
config.setdefault(CONF_NONVOLATILE, True)
if config[CONF_NONVOLATILE]:
config.setdefault(
CONF_NONVOLATILE_WRITE_DELAY,
cv.positive_time_period_milliseconds("1s"),
)
elif CONF_NONVOLATILE_WRITE_DELAY in config:
# Same consistency as the E-H rejection above: never silently ignore user input.
raise cv.Invalid(
f"'{CONF_NONVOLATILE_WRITE_DELAY}' requires '{CONF_NONVOLATILE}: true'"
)
return config
CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend( CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
{ {
@@ -36,9 +74,21 @@ CONFIG_SCHEMA = output.FLOAT_OUTPUT_SCHEMA.extend(
cv.Optional(CONF_TERMINAL_B, default=True): cv.boolean, cv.Optional(CONF_TERMINAL_B, default=True): cv.boolean,
cv.Optional(CONF_TERMINAL_W, default=True): cv.boolean, cv.Optional(CONF_TERMINAL_W, default=True): cv.boolean,
cv.Optional(CONF_INITIAL_VALUE): cv.float_range(min=0.0, max=1.0), cv.Optional(CONF_INITIAL_VALUE): cv.float_range(min=0.0, max=1.0),
# No schema defaults here: a default would materialize the keys on EVERY channel,
# making existing bare E-H configs fail final validation. The effective defaults
# (nonvolatile: true, delay 1s) are applied for the volatile channels A-D inside
# _validate_nonvolatile instead. Default-on rationale: the chip restores the
# nonvolatile wiper levels at power-on, so persisting every settled level change is
# the least surprising behavior — the pot simply comes back where it was. The write
# is deferred by nonvolatile_write_delay to debounce transitions and protect the
# EEPROM's endurance.
cv.Optional(CONF_NONVOLATILE): cv.boolean,
cv.Optional(CONF_NONVOLATILE_WRITE_DELAY): cv.positive_time_period_milliseconds,
} }
) )
FINAL_VALIDATE_SCHEMA = _validate_nonvolatile
async def to_code(config): async def to_code(config):
parent = await cg.get_variable(config[CONF_MCP4461_ID]) parent = await cg.get_variable(config[CONF_MCP4461_ID])
@@ -57,5 +107,71 @@ async def to_code(config):
cg.add( cg.add(
parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE]) parent.set_initial_value(config[CONF_CHANNEL], config[CONF_INITIAL_VALUE])
) )
if str(config[CONF_CHANNEL]) in VOLATILE_CHANNELS and config[CONF_NONVOLATILE]:
cg.add(
parent.set_nonvolatile(
config[CONF_CHANNEL],
config[CONF_NONVOLATILE_WRITE_DELAY],
)
)
await output.register_output(var, config) await output.register_output(var, config)
await cg.register_parented(var, config[CONF_MCP4461_ID]) await cg.register_parented(var, config[CONF_MCP4461_ID])
# ---- Actions ----
WiperIncreaseAction = mcp4461_ns.class_("WiperIncreaseAction", automation.Action)
WiperDecreaseAction = mcp4461_ns.class_("WiperDecreaseAction", automation.Action)
WiperStoreNonvolatileAction = mcp4461_ns.class_(
"WiperStoreNonvolatileAction", automation.Action
)
WiperSetTerminalAction = mcp4461_ns.class_("WiperSetTerminalAction", automation.Action)
WIPER_ACTION_SCHEMA = automation.maybe_simple_id(
{cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper)}
)
CONF_TERMINAL = "terminal"
CONF_ENABLE = "enable"
TERMINAL_ACTION_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(Mcp4461Wiper),
cv.Required(CONF_TERMINAL): cv.one_of("a", "b", "w", "h", lower=True),
cv.Required(CONF_ENABLE): cv.boolean,
}
)
@automation.register_action(
"mcp4461.wiper.increase", WiperIncreaseAction, WIPER_ACTION_SCHEMA, synchronous=True
)
@automation.register_action(
"mcp4461.wiper.decrease", WiperDecreaseAction, WIPER_ACTION_SCHEMA, synchronous=True
)
async def mcp4461_wiper_step_to_code(config, action_id, template_arg, args):
wiper = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, wiper)
@automation.register_action(
"mcp4461.wiper.store_nonvolatile",
WiperStoreNonvolatileAction,
WIPER_ACTION_SCHEMA,
synchronous=True,
)
async def mcp4461_wiper_store_to_code(config, action_id, template_arg, args):
wiper = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, wiper)
@automation.register_action(
"mcp4461.wiper.set_terminal",
WiperSetTerminalAction,
TERMINAL_ACTION_SCHEMA,
synchronous=True,
)
async def mcp4461_wiper_terminal_to_code(config, action_id, template_arg, args):
wiper = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(
action_id, template_arg, wiper, ord(config[CONF_TERMINAL]), config[CONF_ENABLE]
)
@@ -0,0 +1,56 @@
#pragma once
#include "esphome/core/automation.h"
#include "mcp4461_output.h"
namespace esphome::mcp4461 {
template<typename... Ts> class WiperIncreaseAction : public Action<Ts...> {
public:
explicit WiperIncreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {}
void play(Ts... x) override { this->wiper_->increase_wiper(); }
protected:
Mcp4461Wiper *wiper_;
};
template<typename... Ts> class WiperDecreaseAction : public Action<Ts...> {
public:
explicit WiperDecreaseAction(Mcp4461Wiper *wiper) : wiper_(wiper) {}
void play(Ts... x) override { this->wiper_->decrease_wiper(); }
protected:
Mcp4461Wiper *wiper_;
};
// Persist the current level to the chip's nonvolatile register immediately — useful with
// nonvolatile: false to persist only at deliberate moments (e.g. on a button press), or to
// bypass the stability delay of the automatic mirroring.
template<typename... Ts> class WiperStoreNonvolatileAction : public Action<Ts...> {
public:
explicit WiperStoreNonvolatileAction(Mcp4461Wiper *wiper) : wiper_(wiper) {}
void play(Ts... x) override { this->wiper_->store_nonvolatile(); }
protected:
Mcp4461Wiper *wiper_;
};
template<typename... Ts> class WiperSetTerminalAction : public Action<Ts...> {
public:
WiperSetTerminalAction(Mcp4461Wiper *wiper, char terminal, bool enable)
: wiper_(wiper), terminal_(terminal), enable_(enable) {}
void play(Ts... x) override {
if (this->enable_) {
this->wiper_->enable_terminal(this->terminal_);
} else {
this->wiper_->disable_terminal(this->terminal_);
}
}
protected:
Mcp4461Wiper *wiper_;
char terminal_;
bool enable_;
};
} // namespace esphome::mcp4461
@@ -66,6 +66,12 @@ void Mcp4461Wiper::decrease_wiper() {
} }
} }
void Mcp4461Wiper::store_nonvolatile() {
if (this->parent_->store_level_nonvolatile_(this->wiper_)) {
ESP_LOGV(TAG, "Stored wiper %u level to nonvolatile register", static_cast<uint8_t>(this->wiper_));
}
}
void Mcp4461Wiper::enable_terminal(char terminal) { this->parent_->enable_terminal_(this->wiper_, terminal); } void Mcp4461Wiper::enable_terminal(char terminal) { this->parent_->enable_terminal_(this->wiper_, terminal); }
void Mcp4461Wiper::disable_terminal(char terminal) { this->parent_->disable_terminal_(this->wiper_, terminal); } void Mcp4461Wiper::disable_terminal(char terminal) { this->parent_->disable_terminal_(this->wiper_, terminal); }
@@ -36,6 +36,9 @@ class Mcp4461Wiper final : public output::FloatOutput, public Parented<Mcp4461Co
/// @brief Disable given terminal /// @brief Disable given terminal
/// @param[in] terminal single char parameter defining desired terminal to disable, one of { 'a', 'b', 'w', 'h' } /// @param[in] terminal single char parameter defining desired terminal to disable, one of { 'a', 'b', 'w', 'h' }
void disable_terminal(char terminal); void disable_terminal(char terminal);
/// @brief Immediately persist the current wiper level to the chip's nonvolatile register
/// (independent of the deferred nonvolatile mirroring / its stability delay)
void store_nonvolatile();
protected: protected:
void write_state(float state) override; void write_state(float state) override;
+51 -20
View File
@@ -3,30 +3,61 @@ mcp4461:
i2c_id: i2c_bus i2c_id: i2c_bus
output: output:
# All-terminals-off coverage lives here (folded from a former second channel-A
# output — one output per channel keeps the reg_ state deterministic).
- platform: mcp4461 - platform: mcp4461
id: digipot_wiper_1 id: digipot_wiper_1
mcp4461_id: mcp4461_digipot_01 mcp4461_id: mcp4461_digipot_01
channel: A channel: A
- platform: mcp4461
id: digipot_wiper_2
mcp4461_id: mcp4461_digipot_01
channel: B
- platform: mcp4461
id: digipot_wiper_3
mcp4461_id: mcp4461_digipot_01
channel: C
- platform: mcp4461
id: digipot_wiper_4
mcp4461_id: mcp4461_digipot_01
channel: D
- platform: mcp4461
id: digipot_wiper_5
mcp4461_id: mcp4461_digipot_01
channel: A
terminal_a: false terminal_a: false
terminal_b: false terminal_b: false
terminal_w: false terminal_w: false
- platform: mcp4461
id: digipot_wiper_2
mcp4461_id: mcp4461_digipot_01
channel: B
nonvolatile: false
- platform: mcp4461
id: digipot_wiper_3
mcp4461_id: mcp4461_digipot_01
channel: C
nonvolatile_write_delay: 5s
initial_value: 0.5
# TCON1 coverage: terminal flags on a channel D output exercise the
# calc_terminal_connector_byte_() write path for wipers 2/3.
- platform: mcp4461
id: digipot_wiper_4
mcp4461_id: mcp4461_digipot_01
channel: D
terminal_a: false
terminal_w: false
# Bare NV-channel output — the pre-existing persistence workaround; must
# keep validating without any nonvolatile key (regression: schema default
# used to materialize the key on every channel and fail final validation).
- platform: mcp4461
id: digipot_nv_wiper_1
mcp4461_id: mcp4461_digipot_01
channel: E
# Explicit opt-out on an NV channel is a harmless no-op and stays valid.
- platform: mcp4461
id: digipot_nv_wiper_2
mcp4461_id: mcp4461_digipot_01
channel: F
nonvolatile: false
button:
- platform: template
name: "Digipot test actions"
on_press:
- mcp4461.wiper.increase: digipot_wiper_1
- mcp4461.wiper.decrease: digipot_wiper_1
- mcp4461.wiper.store_nonvolatile: digipot_wiper_2
- mcp4461.wiper.set_terminal:
id: digipot_wiper_1
terminal: a
enable: false