From 108ed67d3159d4a0a01acca53917c99265e2cf08 Mon Sep 17 00:00:00 2001 From: techdregs <127994432+techdregs@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:30:25 -0400 Subject: [PATCH] [rx8111] Add basic RX8111CE real-time clock support (#20785) Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/rx8111/__init__.py | 4 + esphome/components/rx8111/rx8111.cpp | 143 ++++++++++++++++ esphome/components/rx8111/rx8111.h | 20 +++ esphome/components/rx8111/time.py | 36 ++++ tests/components/rx8111/__init__.py | 7 + tests/components/rx8111/common.yaml | 10 ++ tests/components/rx8111/test.esp32-ard.yaml | 3 + tests/components/rx8111/test.esp32-idf.yaml | 3 + tests/components/rx8111/test.esp8266-ard.yaml | 3 + .../rx8111/test.nrf52-adafruit.yaml | 3 + tests/components/rx8111/test.rp2040-ard.yaml | 3 + tests/components/rx8111/test_rx8111.cpp | 160 ++++++++++++++++++ .../components/rx8111/test_rx8111.py | 38 +++++ 14 files changed, 434 insertions(+) create mode 100644 esphome/components/rx8111/__init__.py create mode 100644 esphome/components/rx8111/rx8111.cpp create mode 100644 esphome/components/rx8111/rx8111.h create mode 100644 esphome/components/rx8111/time.py create mode 100644 tests/components/rx8111/__init__.py create mode 100644 tests/components/rx8111/common.yaml create mode 100644 tests/components/rx8111/test.esp32-ard.yaml create mode 100644 tests/components/rx8111/test.esp32-idf.yaml create mode 100644 tests/components/rx8111/test.esp8266-ard.yaml create mode 100644 tests/components/rx8111/test.nrf52-adafruit.yaml create mode 100644 tests/components/rx8111/test.rp2040-ard.yaml create mode 100644 tests/components/rx8111/test_rx8111.cpp create mode 100644 tests/unit_tests/components/rx8111/test_rx8111.py diff --git a/CODEOWNERS b/CODEOWNERS index 46b94954fdc..3cbf36777c0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -510,6 +510,7 @@ esphome/components/rtttl/* @glmnet @ximex esphome/components/runtime_image/* @clydebarrow @guillempages @kahrendt esphome/components/runtime_stats/* @bdraco esphome/components/rx8025t/* @remcom +esphome/components/rx8111/* @techdregs esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/samsung/* @jorofi diff --git a/esphome/components/rx8111/__init__.py b/esphome/components/rx8111/__init__.py new file mode 100644 index 00000000000..61505825072 --- /dev/null +++ b/esphome/components/rx8111/__init__.py @@ -0,0 +1,4 @@ +"""Epson RX8111CE real-time clock.""" + +CODEOWNERS = ["@techdregs"] +DOMAIN = "rx8111" diff --git a/esphome/components/rx8111/rx8111.cpp b/esphome/components/rx8111/rx8111.cpp new file mode 100644 index 00000000000..b3f26cb2611 --- /dev/null +++ b/esphome/components/rx8111/rx8111.cpp @@ -0,0 +1,143 @@ +#include "rx8111.h" +#include "esphome/core/log.h" + +namespace esphome::rx8111 { + +ESPHOME_LOG_TAG(TAG, "rx8111"); + +static constexpr uint8_t RX8111_REG_SEC = 0x10; +static constexpr uint8_t RX8111_REG_FLAG = 0x1E; +static constexpr uint8_t RX8111_REG_CONTROL = 0x1F; +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 uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +static constexpr uint8_t uint8_to_bcd(uint8_t val) { return ((val / 10) << 4) | (val % 10); } + +void RX8111Component::setup() { this->read_time(); } + +void RX8111Component::update() { this->read_time(); } + +void RX8111Component::dump_config() { + ESP_LOGCONFIG(TAG, "RX8111:"); + LOG_I2C_DEVICE(this); + RealTimeClock::dump_config(); +} + +void RX8111Component::read_time() { + uint8_t flags; + if (!this->read_byte(RX8111_REG_FLAG, &flags)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + 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; + } + + 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; + } + static constexpr uint8_t CALENDAR_MASKS[7] = {0x7F, 0x7F, 0x3F, 0x00, 0x3F, 0x1F, 0xFF}; + 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; + } + } + 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; + } + const uint8_t weekday = static_cast(__builtin_ctz(onehot) + 1); + ESPTime rtc_time{ + .second = bcd_to_uint8(buf[0]), + .minute = bcd_to_uint8(buf[1]), + .hour = bcd_to_uint8(buf[2]), + .day_of_week = weekday, + .day_of_month = bcd_to_uint8(buf[4]), + .month = bcd_to_uint8(buf[5]), + .year = static_cast(bcd_to_uint8(buf[6]) + 2000), + .is_dst = false, + .timestamp = 0, + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid(true, false) || rtc_time.second >= 60) { + ESP_LOGW(TAG, "Invalid RTC time, not syncing to system clock"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + if (ESPTime::from_epoch_utc(rtc_time.timestamp).day_of_week != weekday) { + ESP_LOGW(TAG, "RTC weekday does not match calendar date"); + this->status_set_warning(LOG_STR("Invalid RTC time")); + return; + } + + this->status_clear_warning(); + 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); +} + +void RX8111Component::write_time() { + auto now = time::RealTimeClock::utcnow(); + if (!now.is_valid() || now.year > 2099) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC"); + return; + } + if (!this->set_stop_bit_(true)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + uint8_t buf[7]; + buf[0] = uint8_to_bcd(now.second); + buf[1] = uint8_to_bcd(now.minute); + buf[2] = uint8_to_bcd(now.hour); + buf[3] = 1 << (now.day_of_week - 1); + buf[4] = uint8_to_bcd(now.day_of_month); + buf[5] = uint8_to_bcd(now.month); + buf[6] = uint8_to_bcd(static_cast(now.year - 2000)); + const bool written = this->write_bytes(RX8111_REG_SEC, buf, sizeof(buf)); + // 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)); + return; + } + if (!this->write_byte(RX8111_REG_FLAG, RX8111_KEEP_EVENT_FLAGS)) { + this->status_set_warning(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + this->status_clear_warning(); + 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 false; + if (stop) { + control |= RX8111_CTRL_STOP; + } else { + control &= ~RX8111_CTRL_STOP; + } + return this->write_byte(RX8111_REG_CONTROL, control); +} + +} // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/rx8111.h b/esphome/components/rx8111/rx8111.h new file mode 100644 index 00000000000..9405d362b0a --- /dev/null +++ b/esphome/components/rx8111/rx8111.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::rx8111 { + +class RX8111Component final : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + + protected: + bool set_stop_bit_(bool stop); +}; + +} // namespace esphome::rx8111 diff --git a/esphome/components/rx8111/time.py b/esphome/components/rx8111/time.py new file mode 100644 index 00000000000..0b166807a22 --- /dev/null +++ b/esphome/components/rx8111/time.py @@ -0,0 +1,36 @@ +"""Epson RX8111CE time platform.""" + +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_ID +from esphome.types import ConfigType + +CODEOWNERS = ["@techdregs"] +DEPENDENCIES = ["i2c"] + +rx8111_ns = cg.esphome_ns.namespace("rx8111") +RX8111Component = rx8111_ns.class_("RX8111Component", time.RealTimeClock, i2c.I2CDevice) + +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(RX8111Component)} +).extend(i2c.i2c_device_schema(0x32)) + +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()") +) +automation.register_apply_action( + "rx8111.read_time", RX8111_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 i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/tests/components/rx8111/__init__.py b/tests/components/rx8111/__init__.py new file mode 100644 index 00000000000..ad1f584730f --- /dev/null +++ b/tests/components/rx8111/__init__.py @@ -0,0 +1,7 @@ +"""Load the RX8111 time platform's dependencies in the C++ test build.""" + +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + manifest.dependencies = manifest.dependencies + ["i2c", "time"] diff --git a/tests/components/rx8111/common.yaml b/tests/components/rx8111/common.yaml new file mode 100644 index 00000000000..c53dcad6606 --- /dev/null +++ b/tests/components/rx8111/common.yaml @@ -0,0 +1,10 @@ +esphome: + on_boot: + then: + - rx8111.read_time: rtc + - rx8111.write_time: rtc + +time: + - platform: rx8111 + id: rtc + i2c_id: i2c_bus diff --git a/tests/components/rx8111/test.esp32-ard.yaml b/tests/components/rx8111/test.esp32-ard.yaml new file mode 100644 index 00000000000..9fc690ff826 --- /dev/null +++ b/tests/components/rx8111/test.esp32-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.esp32-idf.yaml b/tests/components/rx8111/test.esp32-idf.yaml new file mode 100644 index 00000000000..5368401cdae --- /dev/null +++ b/tests/components/rx8111/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.esp8266-ard.yaml b/tests/components/rx8111/test.esp8266-ard.yaml new file mode 100644 index 00000000000..0d9801376df --- /dev/null +++ b/tests/components/rx8111/test.esp8266-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.nrf52-adafruit.yaml b/tests/components/rx8111/test.nrf52-adafruit.yaml new file mode 100644 index 00000000000..47fa151be55 --- /dev/null +++ b/tests/components/rx8111/test.nrf52-adafruit.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test.rp2040-ard.yaml b/tests/components/rx8111/test.rp2040-ard.yaml new file mode 100644 index 00000000000..4e7128f3b2a --- /dev/null +++ b/tests/components/rx8111/test.rp2040-ard.yaml @@ -0,0 +1,3 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + rx8111: !include common.yaml diff --git a/tests/components/rx8111/test_rx8111.cpp b/tests/components/rx8111/test_rx8111.cpp new file mode 100644 index 00000000000..15a7b75c5db --- /dev/null +++ b/tests/components/rx8111/test_rx8111.cpp @@ -0,0 +1,160 @@ +#include + +#include +#include +#include + +#include "esphome/components/rx8111/rx8111.h" + +namespace esphome::rx8111::testing { + +static constexpr uint8_t ADDRESS = 0x32; +static constexpr uint8_t SEC = 0x10; +static constexpr uint8_t WEEK = 0x13; +static constexpr uint8_t DAY = 0x14; +static constexpr uint8_t MONTH = 0x15; +static constexpr uint8_t FLAG = 0x1E; +static constexpr uint8_t CONTROL = 0x1F; + +class RegisterBus final : public i2c::I2CBus { + public: + std::array regs{}; + std::vector writes; + int fail_read{-1}; + int fail_write{-1}; + bool latch_event_before_flag_write{false}; + + i2c::ErrorCode write_readv(uint8_t address, const uint8_t *out, size_t out_size, uint8_t *in, + size_t in_size) override { + EXPECT_EQ(address, ADDRESS); + EXPECT_GT(out_size, 0); + if (out_size == 0) + return i2c::ERROR_TIMEOUT; + const uint8_t reg = out[0]; + if (in_size > 0) { + if (reg == this->fail_read) + return i2c::ERROR_TIMEOUT; + for (size_t i = 0; i < in_size; i++) + in[i] = this->regs[reg + i]; + } else { + if (reg == this->fail_write) + return i2c::ERROR_TIMEOUT; + this->writes.push_back(reg); + if (reg == FLAG) { + if (this->latch_event_before_flag_write) + this->regs[FLAG] |= 0x04; // EVF latches immediately before FLAG write. + // RX8111 flags are write-zero-to-clear; writing one leaves the bit unchanged. + this->regs[FLAG] &= out[1]; + } else { + for (size_t i = 1; i < out_size; i++) + this->regs[reg + i - 1] = out[i]; + } + } + return i2c::ERROR_OK; + } +}; + +class RX8111Test : public ::testing::Test { + protected: + void SetUp() override { + this->rtc_.set_i2c_bus(&this->bus_); + this->rtc_.set_i2c_address(ADDRESS); + this->rtc_.add_on_time_sync_callback([this]() { this->syncs_++; }); + } + + RegisterBus bus_; + RX8111Component rtc_; + unsigned syncs_{0}; +}; + +TEST_F(RX8111Test, WritesCalendarAndPreservesEventFlagsWithoutReadModifyWrite) { + this->bus_.regs[FLAG] = 0xB8; // POR + existing UF/TF/AF. + this->bus_.regs[CONTROL] = 0x5C; + this->bus_.latch_event_before_flag_write = true; + const auto before = ESPTime::from_epoch_utc(::time(nullptr)); + this->rtc_.write_time(); + const auto after = ESPTime::from_epoch_utc(::time(nullptr)); + EXPECT_EQ(this->bus_.regs[CONTROL], 0x5C); + EXPECT_EQ(this->bus_.regs[FLAG], 0x3C); // The newly latched EVF survives too. + auto bcd = [](int value) { return (value / 10) * 16 + value % 10; }; + EXPECT_TRUE((this->bus_.regs[SEC] == bcd(before.second) && this->bus_.regs[DAY] == bcd(before.day_of_month)) || + (this->bus_.regs[SEC] == bcd(after.second) && this->bus_.regs[DAY] == bcd(after.day_of_month))); + EXPECT_TRUE(this->bus_.regs[WEEK] == (1 << (before.day_of_week - 1)) || + this->bus_.regs[WEEK] == (1 << (after.day_of_week - 1))); + EXPECT_EQ(this->bus_.writes, (std::vector{CONTROL, SEC, CONTROL, FLAG})); +} + +TEST_F(RX8111Test, RejectsInvalidFlagsBCDWeekdayAndCalendar) { + this->bus_.regs[FLAG] = 0x83; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.regs[FLAG], 0); + this->bus_.regs[FLAG] = 0x83; + this->bus_.writes.clear(); + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + EXPECT_TRUE(this->bus_.writes.empty()); + this->bus_.regs[FLAG] = 0; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_FALSE(this->rtc_.status_has_warning()); + const uint8_t valid_weekday = this->bus_.regs[WEEK]; + const uint8_t valid_day = this->bus_.regs[DAY]; + const uint8_t valid_month = this->bus_.regs[MONTH]; + const uint8_t wrong_weekday = valid_weekday == 1 ? 2 : 1; + for (uint8_t weekday : {uint8_t{0x00}, uint8_t{0x03}, uint8_t{0x80}, wrong_weekday}) { + this->bus_.regs[WEEK] = weekday; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1) << "weekday " << unsigned(weekday); + EXPECT_TRUE(this->rtc_.status_has_warning()); + } + this->bus_.regs[WEEK] = valid_weekday; + this->bus_.regs[DAY] = 0x32; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[DAY] = 0x31; + this->bus_.regs[MONTH] = 0x04; // April 31 is always invalid. + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[DAY] = valid_day; + this->bus_.regs[MONTH] = valid_month; + this->bus_.regs[SEC] = 0x6A; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[SEC] = 0x60; // ESPTime permits leap seconds, but RX8111 only counts 00..59. + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 1); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.regs[SEC] = 0x00; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 2); + EXPECT_FALSE(this->rtc_.status_has_warning()); +} + +TEST_F(RX8111Test, I2CFailuresDoNotSyncOrLeaveTheCounterStopped) { + this->bus_.fail_read = FLAG; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + EXPECT_TRUE(this->rtc_.status_has_warning()); + this->bus_.fail_read = SEC; + this->rtc_.read_time(); + EXPECT_EQ(this->syncs_, 0); + this->bus_.fail_read = -1; + this->bus_.fail_write = CONTROL; + this->rtc_.write_time(); + EXPECT_TRUE(this->bus_.writes.empty()); + this->bus_.fail_write = SEC; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.writes, (std::vector{CONTROL, CONTROL})); + EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); + this->bus_.fail_write = FLAG; + this->bus_.regs[FLAG] = 0x83; + this->rtc_.write_time(); + EXPECT_EQ(this->bus_.regs[FLAG], 0x83); + EXPECT_EQ(this->bus_.regs[CONTROL] & 1, 0); +} + +} // namespace esphome::rx8111::testing diff --git a/tests/unit_tests/components/rx8111/test_rx8111.py b/tests/unit_tests/components/rx8111/test_rx8111.py new file mode 100644 index 00000000000..48b35d43e08 --- /dev/null +++ b/tests/unit_tests/components/rx8111/test_rx8111.py @@ -0,0 +1,38 @@ +"""Basic RX8111 configuration regressions.""" + +from pathlib import Path + +import pytest + +from esphome.config import validate_config +from esphome.core import CORE + + +def configuration(tmp_path: Path, actions: list | None = None) -> dict: + return { + "esphome": {"name": "rx8111-clock-test", "build_path": str(tmp_path / "build")}, + "esp32": {"board": "esp32dev", "framework": {"type": "esp-idf"}}, + "logger": {}, + "i2c": {"sda": 21, "scl": 22, "scan": False}, + "time": [{"platform": "rx8111", "id": "rtc"}], + "script": [ + { + "id": "clock_actions", + "then": actions or [{"rx8111.read_time": {"id": "rtc"}}], + } + ], + } + + +def validate_rx8111(tmp_path: Path, config: dict): + CORE.reset() + CORE.config_path = tmp_path / "test.yaml" + return validate_config(config, {}) + + +@pytest.mark.parametrize("action", ["rx8111.read_time", "rx8111.write_time"]) +def test_actions(tmp_path: Path, action: str) -> None: + for payload in ({}, {"id": "rtc"}): + assert not validate_rx8111( + tmp_path, configuration(tmp_path, [{action: payload}]) + ).errors