[rx8111] Add basic RX8111CE real-time clock support (#20785)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
techdregs
2026-10-15 08:30:25 -10:00
committed by GitHub
co-authored by J. Nick Koston
parent 6356458081
commit 108ed67d31
14 changed files with 434 additions and 0 deletions
+1
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
"""Epson RX8111CE real-time clock."""
CODEOWNERS = ["@techdregs"]
DOMAIN = "rx8111"
+143
View File
@@ -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<uint8_t>(__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<uint16_t>(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<uint8_t>(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
+20
View File
@@ -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
+36
View File
@@ -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)
+7
View File
@@ -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"]
+10
View File
@@ -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
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
rx8111: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml
rx8111: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml
rx8111: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/nrf52.yaml
rx8111: !include common.yaml
@@ -0,0 +1,3 @@
packages:
i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml
rx8111: !include common.yaml
+160
View File
@@ -0,0 +1,160 @@
#include <gtest/gtest.h>
#include <array>
#include <ctime>
#include <vector>
#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<uint8_t, 256> regs{};
std::vector<uint8_t> 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<uint8_t>{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<uint8_t>{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
@@ -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