diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 5f6b7d150f9..a46f9712291 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -107,4 +107,37 @@ void RealTimeClock::synchronize_epoch_(uint32_t epoch) { this->time_sync_callback_.call(); } +// Bits of each calendar byte that hold the value, byte 0 lowest; byte 3 is the one-hot weekday. +// Packed into one constant so it stays in code rather than RAM on ESP8266. +static constexpr uint64_t BCD_CALENDAR_MASKS = 0xFF1F3F7F3F7F7FULL; + +ESPTime decode_bcd_calendar(const uint8_t *raw) { + uint8_t value[7]; + for (size_t i = 0; i < 7; i++) + value[i] = bcd_to_uint8(raw[i] & static_cast(BCD_CALENDAR_MASKS >> (8 * i))); + const uint8_t weekday = raw[3] & static_cast(BCD_CALENDAR_MASKS >> 24); + ESPTime time{ + .second = value[0], + .minute = value[1], + .hour = value[2], + .day_of_week = static_cast(weekday ? __builtin_ctz(weekday) + 1 : 1), + .day_of_month = value[4], + .day_of_year = 1, + .month = value[5], + .year = static_cast(value[6] + 2000), + .is_dst = false, + .timestamp = 0, + }; + time.recalc_timestamp_utc(false); + return time; +} + +void encode_bcd_calendar(const ESPTime &time, uint8_t *raw) { + const uint8_t value[7] = { + time.second, time.minute, time.hour, 0, time.day_of_month, time.month, static_cast(time.year % 100)}; + for (size_t i = 0; i < 7; i++) + raw[i] = uint8_to_bcd(value[i]); + raw[3] = 1 << (time.day_of_week - 1); +} + } // namespace esphome::time diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index f98adb67b5b..a87778e470c 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -44,4 +44,10 @@ class RealTimeClock : public PollingComponent { LazyCallbackManager time_sync_callback_; }; +/// Decode the 7-byte BCD calendar (second, minute, hour, one-hot weekday, day, month, year since 2000) +/// shared by Epson RTCs such as the RX8025T, RX8111 and RX8130, and fill in the UTC timestamp. +ESPTime decode_bcd_calendar(const uint8_t *raw); +/// Encode @p time into that 7-byte layout; @p time must be valid (is_valid()), with the year 2000 to 2099. +void encode_bcd_calendar(const ESPTime &time, uint8_t *raw); + } // namespace esphome::time diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index c3e752c9690..d0e3c91ab0b 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -1062,6 +1062,11 @@ constexpr std::array decode_value(T val) { return ret; } +/// Decode a two-digit packed BCD byte (tens in the high nibble) to its value, e.g. 0x59 to 59. +constexpr uint8_t bcd_to_uint8(uint8_t bcd) { return (bcd >> 4) * 10 + (bcd & 0x0F); } +/// Encode a value from 0 to 99 as a two-digit packed BCD byte, e.g. 59 to 0x59. +constexpr uint8_t uint8_to_bcd(uint8_t value) { return ((value / 10) << 4) | (value % 10); } + /// Reverse the order of 8 bits. inline uint8_t reverse_bits(uint8_t x) { x = ((x & 0xAA) >> 1) | ((x & 0x55) << 1); diff --git a/tests/components/core/test_bcd.cpp b/tests/components/core/test_bcd.cpp new file mode 100644 index 00000000000..5d90d5aafcd --- /dev/null +++ b/tests/components/core/test_bcd.cpp @@ -0,0 +1,26 @@ +#include + +#include "esphome/core/helpers.h" + +namespace esphome::core::testing { + +static_assert(bcd_to_uint8(0x59) == 59); +static_assert(uint8_to_bcd(59) == 0x59); + +TEST(Bcd, RoundTripsEveryTwoDigitValue) { + for (uint8_t value = 0; value < 100; value++) { + const uint8_t bcd = uint8_to_bcd(value); + EXPECT_EQ(bcd >> 4, value / 10); + EXPECT_EQ(bcd & 0x0F, value % 10); + EXPECT_EQ(bcd_to_uint8(bcd), value); + } +} + +TEST(Bcd, DecodesBoundaryBytes) { + EXPECT_EQ(bcd_to_uint8(0x00), 0); + EXPECT_EQ(bcd_to_uint8(0x09), 9); + EXPECT_EQ(bcd_to_uint8(0x10), 10); + EXPECT_EQ(bcd_to_uint8(0x99), 99); +} + +} // namespace esphome::core::testing diff --git a/tests/components/time/bcd_calendar.cpp b/tests/components/time/bcd_calendar.cpp new file mode 100644 index 00000000000..c66454fe1b6 --- /dev/null +++ b/tests/components/time/bcd_calendar.cpp @@ -0,0 +1,51 @@ +#include + +#include + +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::time::testing { + +// 2026-04-16 23:59:58, a Thursday (day_of_week 5, one-hot bit 4). +static constexpr uint8_t RAW[7] = {0x58, 0x59, 0x23, 0x10, 0x16, 0x04, 0x26}; + +TEST(BcdCalendar, DecodesEveryField) { + const ESPTime time = decode_bcd_calendar(RAW); + EXPECT_EQ(time.second, 58); + EXPECT_EQ(time.minute, 59); + EXPECT_EQ(time.hour, 23); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.month, 4); + EXPECT_EQ(time.year, 2026); + EXPECT_EQ(time.timestamp, 1776383998); + EXPECT_TRUE(time.is_valid(true, false)); +} + +TEST(BcdCalendar, MasksStatusBits) { + // Bit 7 of seconds/minutes, the top two bits of hour and day and the top three of month are not time. + const uint8_t raw[7] = {0xD8, 0xD9, 0xE3, 0x90, 0xD6, 0xE4, 0x26}; + const ESPTime time = decode_bcd_calendar(raw); + EXPECT_EQ(time.second, 58); + EXPECT_EQ(time.minute, 59); + EXPECT_EQ(time.hour, 23); + EXPECT_EQ(time.day_of_week, 5); + EXPECT_EQ(time.day_of_month, 16); + EXPECT_EQ(time.month, 4); +} + +TEST(BcdCalendar, EmptyWeekdayDecodesAsSunday) { + uint8_t raw[7]; + std::copy(RAW, RAW + 7, raw); + raw[3] = 0; + EXPECT_EQ(decode_bcd_calendar(raw).day_of_week, 1); +} + +TEST(BcdCalendar, EncodeRoundTrips) { + uint8_t raw[7]; + encode_bcd_calendar(decode_bcd_calendar(RAW), raw); + for (size_t i = 0; i < 7; i++) + EXPECT_EQ(raw[i], RAW[i]) << "byte " << i; +} + +} // namespace esphome::time::testing