Skip to content

Read and write the RV3028 clock atomically, and reject switchover-garbled transfers 🤖🤖 - #3421

Open
ptr727 wants to merge 1 commit into
meshcore-dev:devfrom
ptr727:fix/rv3028-atomic-read
Open

ptr727 wants to merge 1 commit into
meshcore-dev:devfrom
ptr727:fix/rv3028-atomic-read

Conversation

@ptr727

@ptr727 ptr727 commented Sep 16, 2026 •

Copy link
Copy Markdown

References used throughout:

This PR has grown since it was first opened. It started as the time race below, and now also guards every RV3028 time read and write against a backup switchover, to the position agreed with the ZephCore and Zephyr RV3028 work.

Cross-project consistency. This change is part of a coordinated update to RV3028 time handling across three codebases, kept consistent in behaviour: Zephyr (zephyrproject-rtos/zephyr#121389), MeshCore (#3421) and ZephCore (liquidraver/ZephCore#103, a follow-up to liquidraver/ZephCore#98). It replaces the rule all three adopted on 2026-10-05: zero the year, write 00h-05h, then write the year. That rule split the time write into separate accesses against the manual's one-access rule (RV-3028-C7 Application Manual Rev 1.4, §4.5), with the argument that the Seconds write restarts the prescaler, so no tick lands before the year write. It also used year 00 as the "not set" marker, which made 2000-01-01 unusable as a date. Hardware tests on an RV-3028-C7 then showed three things: a write cut short by an interface reset keeps the bytes already acknowledged and leaves the rest unchanged (tested with the 950 ms bus timeout, §4.5.1); the year register keeps a non-BCD value while the clock counts; and a New Year rollover counts a left mark up like a BCD value (A0h to A1h, A9h to B0h), so it stays a non-BCD year. The shared rule is now: write 06h := A0h, then one 7-byte burst 00h-06h, and read a year of A0h-FEh as not set and FFh as a failed read. Retry policy and never-set detection (PORF) are left to each project.

12-hour mode. Each project ensures the chip is in 24-hour mode before it takes or writes a time. How each project does it: Zephyr's driver owns the chip and writes Control 2 = 00h at every boot, which also disables interrupts it does not handle, then takes the converted time. MeshCore clears 12_24 inside its read bracket, keeping the other bits, and takes the time only from a BSF- and mode-checked re-read. ZephCore selects 24-hour mode at each time write, keeping the other bits, with a read-back, and refuses a time read in 12-hour mode until the next sync, because a boot-time write would sit outside its BSF bracket. MeshCore and ZephCore trust a Control 2 read for a write only if its always-zero RESET bit reads 0; Zephyr's interrupt-enable read-modify-writes do not yet check it (open follow-up). A single-byte write is stored whole when acknowledged; a switchover in the middle of a byte is not covered (5.10).

The defects

1. A time race in the read. getCurrentTime() builds its DateTime from six Melopero getters. Each one is a separate I2C transaction, and getHour() is two because it re-reads the register to test the 12 hour flag. That is seven round trips, read most-significant-first, while the counters keep running. An hour boundary crossed between the hour read and the minute read returns a timestamp a full hour in the past:

t = 15:59:59.99   read year, month, date, hour -> hour = 15
t = 16:00:00.00   counters roll over
                  read minute -> 00, read second -> 00
result            15:00:00      (one hour in the past)

Minute boundaries misread by a minute, day boundaries by a day. The chip only holds the counters still across one access (§4.5, p. 52).

2. A switchover garbles a transfer, and nothing notices. dev runs the RV3028 in Direct Switching Mode (L39; #3545 makes it permanent). On a switchover to VBACKUP the chip disables and resets its I2C interface (§4.2, p. 45; §5.10, p. 95), so the rest of a transfer reads as 1s. A byte can still decode to a valid but larger value (year 0x26 read as 0x27), and Melopero's getters return 0xFF on a failed transfer, which decodes as 165. On the write side, setTime() is one 7-byte burst (dev L91): a switchover or power loss part way leaves the bytes written before it with the rest unchanged, a mixed time that reads back as valid on every later boot. Each byte is stored as it is acked. The Zephyr RV3028 work tested this on a RAK4631 with a RAK12002 by holding a 3-byte write to 00h past the 950 ms bus timeout (§4.5.1, p. 53): 00h-02h held the new bytes and 03h-06h the old ones, in 6 of 6 repeats, so "the previous time counter values are maintained" does not mean the old time survives a cut write.

Only the RV3028 path changes. DS3231 and PCF8563 read through RTClib's now(), and RTC_RX8130CE::getTime() already does a 7 byte write_then_read.

The fix

Links are to 0f038054.

Reads, rv3028_read_clock():

  1. Read the seven clock registers 00h-06h in one burst, which the chip holds coherent (§4.5, p. 52).
  2. Read Status (0Eh) and Control 2 (10h) in one access (L170): the read is used only if BSF reads 0 after it and the chip is in 24 hour mode. A set BSF is cleared (possible only on VDD, §3.7, p. 22) by writing 0 to it alone: a flag is kept until a 0 is written to it (p. 23) and writing 1 leaves it unchanged, so no other flag is cleared, even one set since Status was read. 12 hour mode is cleared too; in 12 hour mode the Hours register carries an AM/PM bit, so PM 1 (21h) would decode as 21:00, and clearing 12_24 converts the register itself (§ 02h, p. 15). The read is trusted for a write only if Control 2's RESET bit, which always reads 0 (p. 24), reads 0 (L75): Control 2 is the last byte, so a read cut by an interface reset shows it set, and a torn Status or Control 2 is never written back. Both are cleared by rv3028_settle(), and the read is retried once (L291). This runs before the fields are validated, since most PM hours do not decode as valid hours. The retry also clears a BSF left by an earlier power cut, without a Status write on every read.
  3. A year byte of A0h-FEh reads as a clock not yet set (L184): A0h is the mark a time write sets before its burst (below), and each new year counts it on (A1h, ..., A9h, B0h), never to a BCD year and never to FFh. FFh stays a failed read, since a read cut by an interface reset returns it (§4.5.1, p. 53). Year 00 is 2000, a valid year.
  4. Validate: BCD nibbles, month 1-12 (L191, checked first because RTClib indexes its month table unchecked), then DateTime::isValid(), which round-trips through unixtime() and rejects impossible dates such as 31 February.

A rejected read is never used (getCurrentTime()). The time runs on from the last accepted or set time (rv3028_run_on(), which moves its reference forward on each call so a millis() wrap cannot step it back), or, before there is one, comes from the fallback clock, as on a board with no RTC. The field-by-field getters are no longer used: a failed one decodes as year 2165, past RTClib's month table.

Writes, rv3028_write_time(), inside the same bracket:

  1. Clear BSF and select 24 hour mode.
  2. Write 06h = A0h on its own. A0h is not BCD, and the chip keeps it while the clock counts (read back unchanged over 4 s of counting).
  3. Burst-write 00h-06h in one access, as §4.5 (p. 52) requires.
  4. Read Status and Control 2: BSF must be 0 and 24 hour mode still set. Two tries.

The weekday is RTClib's DateTime::dayOfTheWeek(); the formula carried over from dev stored the wrong weekday on 30325 of the 36525 days from 2000 to 2099. A time outside 2000-2099 is never written (L219): the chip counts years 00-99 only, and 2100 would encode as A0h, the mark. Such a write is not confirmed, so the clock runs on from the set time without the RTC.

Year is the last byte of the burst, so a write cut anywhere in it, by a switchover or a power loss the MCU does not survive, leaves the A0h mark, which reads as a clock not yet set rather than a mixed time that passes for the real one. If neither try is confirmed, setCurrentTime() holds the RTC unread, runs on from the time being set, and getCurrentTime() retries the write every 5 s, at most 13 attempts in all (L320), so a bus that is down cannot stall each caller; if none is confirmed, the boot runs on without the RTC.

The first read failure of a boot is logged once, since getCurrentTime() runs on every received packet; a clock not yet set is expected and not logged. No log argument has a side effect, so release builds run the same paths.

Also adds RTC detection logging for DS3231 and RV3028, which had none, plus a line naming the bound driver.

Portability

The coherence guarantee is the single seven byte read, not the repeated start. Ending the pointer write without a stop avoids releasing the bus where the core honours it, but is not what makes the read atomic.

Core endTransmission(false) Verified
nRF52840 repeated start runtime, on hardware
ESP32 sets nonStop, requestFrom issues i2cWriteReadNonStop source + build
RP2040 i2c_write_blocking_until(..., !stopBit, ...), 5th arg is nostop source + build
STM32 flag ignored unless I2C_OTHER_FRAME or USE_HALV2_DRIVER, so stop-then-start source

Where the flag is ignored this degrades to stop-then-start, which is what Melopero_RV3028::readFromRegister() already does against this part.

Notes for review

  • Platform differences (see Cross-project consistency above for the shared rule). Each platform reports "not set" its own way: Zephyr returns -ENODATA, ZephCore takes no time, and MeshCore, whose clock has no "not set" value, uses the fallback clock. A chip that was never set is also detected per platform: Zephyr and ZephCore honour the power-on reset flag PORF, which only a confirmed write clears, and ZephCore rejects any year before 2025. MeshCore does not use PORF, because no MeshCore firmware has ever cleared it, so it is still set on deployed nodes whose time is correct; honouring it would drop every updated node to the fallback clock until its time is next set.
  • Known limitations:
    • The mark identifies a cut write, not a chip that was never set. After a power-on reset the chip reads 2000-01-01 (§3.18, p. 41) and that is accepted as a time, as on dev. MeshCore's clock only moves forward through clock sync, time and the contact bootstrap, so a node starting at 2000 still syncs.
    • A node left with the mark by a cut write behaves like a node with no RTC: the fallback clock restarts each boot, so its own advert timestamps can step back. That is cosmetic for a repeater, which forwards every packet the same whatever its clock says (reference), and the default repeater setup sets the time after configuration.
    • A good read after a long run on millis() can step back by the MCU clock's drift against the RTC.
    • The chip has no century: a time at or after 2100 is not written to it, and a chip counting past 2099-12-31 23:59:59 wraps to 2000-01-01 by itself.
  • RV3028_ADDRESS is defined both here and in Melopero_RV3028.h as 0b1010010. Same value, so it is harmless. Left alone to keep this PR to one fix.

Testing

Builds: RAK_4631_repeater (nRF52840) and heltec_v4_repeater (ESP32-S3) at the final iteration head. Earlier versions also built Heltec_v3_repeater and Xiao_rp2040_repeater. wio-e5_repeater (STM32) fails on unmodified dev too, with a RadioLib compile error in hal/Stm32duino/Stm32wlHal.cpp.

Hardware: a RAK4631 with a RAK12002 (RV3028), USB only, indoors with no GPS fix, so GPS never set the clock. Each build had the throwaway diagnostic CLI from test/rv3028-diag (290cfc5b, never merged) added; its rv dump prints Status 0Eh (and, in the last row, Control 2) and the RTC's own time, and rv set writes the time registers directly. The last ten rows also added rv raw (write raw bytes from a register in one access), rv wt (call setCurrentTime() directly, bypassing the CLI's forward-only check) and rv gt (call getCurrentTime()).

Build (ref) Test Result
first version ca636336 90 s of live mesh traffic burst read on every received packet, no fallback logged, clock tracked host UTC
e54493e3 boot with the chip unset (2000-01-09) read rejected as not set; clock gave the fallback 2024-05-15
e54493e3 time set at 14:59:37 write confirmed; the RTC read 14:59:54 at host 14:59:55 (whole-second truncation of the set)
e54493e3 ~60 s USB power cut time kept: RTC 15:08:37 at host 15:08:37; 0Eh 10 after boot, so the BSF a cut sets on this chip had been cleared by the first read
final logic 65907457 reflash time kept: the RTC continued from the earlier set (15:38:26)
final logic 65907457 rv set year 00, reboot, then time clock gave the fallback 2024-05-15; after time at 15:39:15 the RTC read 15:39:19
A0h mark 99e455d9 rv wt to host time, then 2000-01-01, then reboot both writes confirmed; 2000-01-01 stored as year 00, counted on, and after reboot rv gt read 2000-01-01 from the chip, not the fallback
A0h mark 99e455d9 rv raw 06 A0, then rv raw 00 11 22 03 (the mark plus a 3-byte burst, as a cut write leaves it), reboot A0h held while seconds counted (00:00:57 to 00:01:01); after reboot rv gt gave the fallback 2024-05-15 with no read failure logged
A0h mark 99e455d9 rv raw 06 1A (non-BCD, not the mark) logged RV3028: time read rejected (-1), the first log of that boot
range 9580459b rv raw 06 A1, reboot, rv raw 06 B0, rv raw 06 FF A1h and B0h gave the fallback with no log; FFh logged time read rejected (-1); time then restored with rv wt and read back after reboot
2000-2099 guard ed51d8d2 rv wt 2100-01-01, then 2099-12-31 23:59:59, then host time, reboot 2100: not confirmed, the RTC kept 2026 and the clock ran on from 2100; 2099-12-31 23:59:59: written, and the chip wrapped to 2000-01-01 a second later; host time restored and read back after reboot
weekday fe7aef4e rv wt 2024-05-15, 2026-10-06, 2000-01-01, 2099-12-31 (each 12:00), with rv dump extended to print the weekday register stored 03, 02, 06, 04: Wednesday, Tuesday, Saturday, Thursday with 0 = Sunday, as RTClib counts
24 hour mode 59e5b0c7 at 16:30, rv raw 10 02 (12_24 set), then rv gt; again 12_24 set, then rv wt 21:30 in 12 hour mode the chip held Hours 24h (PM 4); the read cleared 12_24 and returned 16:30:06, Control 2 back to 00; the write cleared it first and stored 21:30
RESET guard bc80fbdf 15:30 set, Control 2 = 82h (12_24 + TSE), reboot, rv gt begin() no longer touches Control 2; the first read cleared 12_24 only (Control 2 80h, TSE kept) and returned 15:30:23
BSF direct clear de615691 writing DFh and FFh to Status no flag set; Status stayed at its UF-only value
retry cadence 8ba3aaca 2100 set (refused without I2C, so held), rv gt every 3 s "will retry", then "giving up" after 12 retries; a normal set then worked

This PR's 0f038054 has the same tree as 8ba3aaca. Not run on hardware: a switchover during a read or write, which needs VDD brought within the DSM threshold of VBACKUP mid-transfer; the BSF paths are covered by review against §3.7 and §4.2.

Acceptance run on this PR's exact commit 0f038054 (the three projects' shared case list; the diagnostic CLI commit on top changed nothing under src/): all pass.

Case Result
A normal 500 reads, none failed or backwards; 100 random sets in 2000-2099 read back exactly, weekday included; time continued across a warm reboot
B range 2000-01-01 set and read, and read from the chip after a reboot; 2099-12-31 23:59:59 accepted; 2100 and 1999 refused with the chip registers unchanged
C corrupt all FFh, year FFh, month 13h: the fallback clock after a reboot and time read rejected (-1); seconds 5Ah, read at once since the chip counts it on to a valid value within seconds: rejected, time ran on
D marker year A0h, A5h, B9h, FEh: the fallback clock, nothing logged
E cut write a bit-banged write of 3 bytes to 00h, held 1.1 s past the bus timeout: the byte after the hold was NACKed, 00h-02h held the new bytes and 03h-05h the old ones with year A0h; after a reboot not set, and a normal set recovered
F rollover from 12-31 23:59:58: A0h to A1h and A9h to B0h, both not set; 99h to 00h, read as 2000-01-01
G Status TF set before a time write survived it; on a real USB power cut (Count TS 1, so the switchover happened) the first read cleared BSF while TF and EVF stayed set, and the time was kept
H 12 hour mode 12_24 set at 16:30 (Hours read 24h, PM 4): read returned 16:30:00, Control 2 kept TSE; a write in 12 hour mode stored 21:30; a boot in 12 hour mode read the right time

Iteration history and review: ptr727/meshcore-dev-MeshCore#1. Related: #3545, which stores the switchover config in EEPROM.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings September 16, 2026 18:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The functional change is well-scoped and includes a safe fallback path, with only a minor documentation inconsistency noted in review comments.

Pull request overview

This PR fixes a race condition in RV3028 RTC time reads by switching from multiple per-field I2C reads to a single burst read of the contiguous clock registers, improving timestamp coherence used by mesh packet stamping and neighbor aging.

Changes:

  • Add an RV3028 burst read helper that validates BCD fields and DateTime validity before returning unixtime().
  • Fall back to the existing field-by-field RV3028 getters if the burst read or validation fails, logging the fallback only once.
  • Add RTC detection and “selected driver” debug logging for easier diagnosis of RTC discovery and selection.
File summaries
File Description
src/helpers/AutoDiscoverRTCClock.cpp Implements atomic/burst RV3028 time register reads with validation and adds clearer RTC detection/selection logging.
Review details
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/helpers/AutoDiscoverRTCClock.cpp Outdated
@ptr727
ptr727 force-pushed the fix/rv3028-atomic-read branch from 9bf12e1 to ca63633 Compare September 16, 2026 19:41
@ptr727
ptr727 force-pushed the fix/rv3028-atomic-read branch from ca63633 to c408f88 Compare October 5, 2026 15:53
@ptr727 ptr727 changed the title Fix RV3028 time race by reading clock registers atomically 🤖🤖 Read and write the RV3028 clock atomically, and reject switchover-garbled transfers 🤖🤖 Oct 5, 2026
@ptr727
ptr727 requested a lite review from Copilot October 5, 2026 16:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Three moderate issues remain involving 12-hour mode, BSF handling, and year-2000 writes.

Review effort: Lite
Findings: None

Resolved since last review (1)

@ptr727

ptr727 commented Oct 5, 2026

Copy link
Copy Markdown
Author

Replying to the Copilot overview on c408f88b. It opened no threads, so the three points are answered here. None of them leads to a code change. Links are to c408f88b, and § and p. numbers are from the RV-3028-C7 Application Manual, Rev. 1.4, cited at the top of the description.

12-hour mode. MeshCore never puts the chip in 12-hour mode. begin() selects 24-hour mode before the RV3028 is adopted (L218), and 24-hour mode is also the chip's default (12_24 = 0; Hours, §3.3, p. 15). So the read masks Hours with 0x3F (L134), and the write puts the 24-hour value in directly, without Melopero's 12-hour wrapper. That wrapper never gave a correct time anyway: in 12-hour mode getHour() drops the PM flag, so 3 PM reads as 3, and dev has that same gap. liquidraver/ZephCore#98 and the Zephyr RV3028 driver also decode 24-hour mode only.

BSF handling. The overview doesn't say which part it means, so here is how BSF moves through the code, in case it helps. A read is used only if BSF reads 0 after it (L160). A set BSF is cleared, and the read is taken again once (L251). That retry also clears the BSF a power cut leaves set, which was confirmed on hardware (the ~60 s cut row in the description). A switchover garbled enough to fail validation is rejected before Status is read. The BSF it leaves set is then caught on the next call, and the time runs on in between. A write clears BSF first and requires it still 0 afterwards (L187-L191). rv3028_clear_bsf() writes the other Status flags back as read (L71-L75). A flag clears only when 0 is written to it (§3.7, p. 22), so every flag that reads 1 stays set. A flag that sets between that read and the write would be cleared, but MeshCore uses none of the other flags. If there's a specific path you think is wrong, please point to it and I'll look again.

Year-2000 writes. Zeroing the year first and writing it last is deliberate, and the description covers it under "Writes" and its limitations. A time inside the year 2000 is written as year 00, and it then reads as not set. The paths that set the time from a user don't reach that case: the CLI time and clock sync commands and the companion's CMD_SET_DEVICE_TIME each refuse a time earlier than the current one, and until a time is set the current time comes from the fallback clock, which starts in 2024. GPS sets whatever the receiver reports once its fix is valid. A receiver that reports a date in 2000 with a valid fix is already wrong, and the node then runs as if its clock were not set, instead of on a time 25 years off. liquidraver/ZephCore#98 and the Zephyr driver's guard use the same order.

ptr727 added a commit to ptr727/zephyrproject-rtos-zephyr that referenced this pull request Oct 6, 2026
On a switchover from VDD to VBACKUP the RV-3028-C7 disables and resets
its I2C interface, so an access in progress is cut short (Application
Manual Rev. 1.4, 4.2 and 5.10). The rest of a read then returns 1s, or a
byte that changed meanwhile, and rv3028_get_time() returned whatever it
decoded. A write cut short leaves a mix of the old and new time, which
the next read returned as valid. This can happen whenever the switchover
is enabled and VDD fails during an access.

The switchover sets BSF in the Status register, which can be cleared
only on VDD (3.7). Bracket the time register accesses with it: clear a
BSF left by an earlier switchover, read or write the time registers,
then read Status again and return -EIO if BSF is set. set_time() then
also leaves PORF set. get_time() additionally returns -EIO for a BCD
nibble above 9 or a time that rtc_utils_validate_rtc_time() rejects.
BSF is written only when it is already set, and the bracket runs under
the MFD lock. Retrying is left to the caller.

A BSF found set by get_time() can also come from a power loss during a
set_time() that never returned. Each byte of a write is stored as it is
acknowledged, so such a write leaves a mix of the old and new time.
set_time() therefore first writes A0h, a value that is not BCD, to the
year register, then 00h-06h in one access as before (4.5). The year is
the last byte, so a write cut short leaves A0h. The year counter
increments a left mark like a BCD value, so get_time() reports any year
from A0h as not set (-ENODATA), except FFh, which is what a read cut
short returns.

The same write order and year handling are used by MeshCore and ZephCore
for RV3028; the three are kept consistent in behaviour.

Tested on a RAK4631 with a RAK12002 (RV-3028-C7). A write to 00h cut
short by the I2C bus timeout (4.5.1), the interface reset that software
can trigger, kept the bytes acknowledged before the timeout, and the
year register held A0h. With this change, year 2000 was set and read
back, A0h followed by a write cut short by the timeout read as -ENODATA,
and a normal set and read succeeded. Across a 31 December rollover the
year register went from A0h to A1h, A9h to B0h and FFh to F0h, and each
read as -ENODATA. A switchover during an access was not reproduced.
Built for native_sim/native and native_sim/native/64 with the RTC
build_all I2C test in the ci-base v0.29.4 image.

Link: https://www.microcrystal.com/fileadmin/Media/Products/RTC/App.Manual/RV-3028-C7_App-Manual.pdf
Link: meshcore-dev/MeshCore#3421
Link: liquidraver/ZephCore#98
Assisted-by: Claude:claude-opus-5.5
Signed-off-by: Pieter Viljoen <pieter@viljoen.com>
@ptr727
ptr727 force-pushed the fix/rv3028-atomic-read branch from c408f88 to b18d07a Compare October 6, 2026 15:51
@ptr727

ptr727 commented Oct 6, 2026

Copy link
Copy Markdown
Author

Updated to the revised RV3028 time-write rule, agreed with Zephyr (zephyrproject-rtos/zephyr#121389) and ZephCore (follow-up to liquidraver/ZephCore#98): the year is marked A0h, then the time is written in one 7-byte burst, and a year of A0h-FEh reads as not set. See "Cross-project consistency" in the description for why the earlier year-00 split write was replaced.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Three moderate issues remain unresolved, including DSM persistence, BSF recovery, and year-2100 handling.

Review effort: Lite
Findings: 1 Medium severity

Open (1)

Comment thread src/helpers/AutoDiscoverRTCClock.cpp

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The weekday calculation stores incorrect values for many dates and should use RTClib’s validated calculation.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)

Comment thread src/helpers/AutoDiscoverRTCClock.cpp Outdated
@ptr727
ptr727 force-pushed the fix/rv3028-atomic-read branch from 16a2235 to 0d00235 Compare October 6, 2026 16:42
@ptr727
ptr727 requested a lite review from Copilot October 6, 2026 16:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Read failures bypass BSF retry handling, and 12-hour mode is not safely handled.

Review effort: Lite
Findings: 2 Medium severity

Open (2)
Resolved since last review (1)

Comment thread src/helpers/AutoDiscoverRTCClock.cpp
Comment thread src/helpers/AutoDiscoverRTCClock.cpp Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

millis() wraparound can make timestamps incorrect after extended idle periods.

Review effort: Lite
Findings: None

Resolved since last review (2)

ptr727 added a commit to ptr727/liquidraver-ZephCore that referenced this pull request Oct 6, 2026
The RV3028 time write set year 00h, burst-wrote 00h-05h, then wrote the
real year, so a write cut short left year 2000 and the next boot took no
time from it. That split the write into separate accesses against the
manual's one-access rule (Application Manual Rev 1.4, 4.5), relying on
the Seconds write restarting the prescaler, and used a real date as the
"not set" mark.

The write now sets 06h to A0h, which is not BCD, then writes all seven
registers 00h-06h in one burst, year last, inside the existing BSF
bracket. The chip keeps each byte as it is acknowledged, so a write cut
between the mark and the year byte leaves the mark. On read at an
rv3028-eeprom-config descriptor, a year of A0h-FEh gives no time: a New
Year counts a left mark on (A0h to A1h, A9h to B0h). FFh stays a failed
read, since a read cut short returns 1s and the year is the last byte.

The save also refuses a year outside 2000-2099, for every chip, because
ZephCore writes the year as two BCD digits and no century bit (the
DS3231 and PCF8563 century bits are written as 0 and masked off on
read). It encoded y % 100, so a time before 2000, accepted by a node
whose clock had no time yet, was stored as a year near the end of the
century: `time 900000000` (1998) was restored at the next boot as 2098,
past the 2025 floor. At an rv3028-eeprom-config descriptor a refused
year is marked A0h instead, so the next boot takes no time rather than
the older time the chip still holds; the mark gets the BSF bracket and
the same 5 s repeat as a time write, sharing its 13-attempt cap, and a
repeat whose run-on time leaves 2000-2099 marks instead. Other chips
keep their older time.

The header contract and the binding describe both; the Kconfig help and
ARCHITECTURE.md describe the 2000-2099 range, and ARCHITECTURE.md the
refused-year mark.

The same write order, year handling and 2000-2099 range are used by
Zephyr and MeshCore for the RV3028; the three are kept consistent in
behaviour.

Link: zephyrproject-rtos/zephyr#121389
Link: meshcore-dev/MeshCore#3421
Link: liquidraver#98

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ptr727
ptr727 force-pushed the fix/rv3028-atomic-read branch from 6033e3e to 81b7549 Compare October 6, 2026 22:59
@ptr727

ptr727 commented Oct 6, 2026

Copy link
Copy Markdown
Author

Updated to 81b7549 after a cross-check with the Zephyr (zephyrproject-rtos/zephyr#121389) and ZephCore (liquidraver/ZephCore#103) RV3028 work.

A Status/Control 2 read is now trusted for a write-back only if Control 2's RESET bit, which always reads 0 (manual p. 24), reads 0. Before this, a read cut by an interface reset returns 1s, so the 12_24 clear would have written FDh to Control 2, turning on every interrupt enable and resetting the prescaler.

Melopero's unchecked set24HourMode() is no longer called from begin(). Each time read and write now selects 24-hour mode, with these checks.

The shared position on 12-hour mode is in the new "12-hour mode" paragraph of the description.

@ptr727
ptr727 requested a lite review from Copilot October 6, 2026 23:08
ptr727 added a commit to ptr727/zephyrproject-rtos-zephyr that referenced this pull request Oct 6, 2026
On a switchover from VDD to VBACKUP the RV-3028-C7 disables and resets
its I2C interface, so an access in progress is cut short (Application
Manual Rev. 1.4, 4.2 and 5.10). The rest of a read then returns 1s, or a
byte that changed meanwhile, and rv3028_get_time() returned whatever it
decoded. A write cut short leaves a mix of the old and new time, which
the next read returned as valid. This can happen whenever the switchover
is enabled and VDD fails during an access.

The switchover sets BSF in the Status register, which can be cleared
only on VDD (3.7). Bracket the time register accesses with it: clear a
BSF left by an earlier switchover, read or write the time registers,
then read Status again and return -EIO if BSF is set. set_time() then
also leaves PORF set. get_time() additionally returns -EIO for a BCD
nibble above 9 or a time that rtc_utils_validate_rtc_time() rejects. BSF
is written only when it is already set, and the bracket runs under the
MFD lock. Status flags, BSF and PORF, are cleared by writing 0 to the
flag and 1 to the others, which leaves them unchanged (3.7), so no read
precedes the write: a flag set in between is not lost, and a read cut
short is not written back. Retrying is left to the caller.

A BSF found set by get_time() can also come from a power loss during a
set_time() that never returned. Each byte of a write is stored as it is
acknowledged, so such a write leaves a mix of the old and new time.
set_time() therefore first writes A0h, a value that is not BCD, to the
year register, then 00h-06h in one access as before (4.5). The year is
the last byte, so a write cut short leaves A0h. The year counter
increments a left mark like a BCD value, so get_time() reports any year
from A0h as not set (-ENODATA), except FFh, which is what a read cut
short returns.

The same write order and year handling are used by MeshCore and ZephCore
for RV3028; the three are kept consistent in behaviour.

Tested on a RAK4631 with a RAK12002 (RV-3028-C7). A write to 00h cut
short by the I2C bus timeout (4.5.1), the interface reset that software
can trigger, kept the bytes acknowledged before the timeout, and the
year register held A0h. With this change, year 2000 was set and read
back, A0h followed by a write cut short by the timeout read as -ENODATA,
and a normal set and read succeeded. Across a 31 December rollover the
year register went from A0h to A1h, A9h to B0h and FFh to F0h, and each
read as -ENODATA. Writing 7Eh and then 01h to a clear Status register
left every flag 0, and an update flag set before set_time() was still
set after it. A switchover during an access was not reproduced. Built
for native_sim/native and native_sim/native/64 with the RTC build_all
I2C test in the ci-base v0.29.4 image.

Link: https://www.microcrystal.com/fileadmin/Media/Products/RTC/App.Manual/RV-3028-C7_App-Manual.pdf
Link: meshcore-dev/MeshCore#3421
Link: liquidraver/ZephCore#98
Assisted-by: Claude:claude-opus-5.5
Signed-off-by: Pieter Viljoen <pieter@viljoen.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unbounded write retries can repeatedly block packet processing when the I2C bus is unavailable.

Review effort: Lite
Findings: 1 High severity

Open (1)

Comment thread src/helpers/AutoDiscoverRTCClock.cpp Outdated
…bled transfers

getCurrentTime() built its DateTime from six Melopero getters, seven I2C
transactions read most-significant-first while the counters run, so an
hour boundary crossed between the hour and minute reads returned a time
an hour in the past. Read the seven clock registers in one burst, which
the chip holds coherent (RV-3028-C7 Application Manual Rev 1.4, 4.5),
and validate the BCD nibbles, the month and DateTime::isValid().

A switchover to VBACKUP disables and resets the chip's I2C interface
(4.2, 5.10), so the rest of a transfer reads as 1s and a byte can still
decode to a valid but larger value. A read is used only if BSF reads 0
after it; a set BSF is cleared (3.7) by writing 0 to it alone, since
writing 1 leaves a flag unchanged, and the read retried once, which also
clears a flag left by an earlier power cut. Control 2 is read in the
same access, and 12 hour mode, which begin() leaves unchecked, is
cleared the same way (the chip converts Hours itself, manual 02h), so a
PM hour cannot decode as a wrong 24 hour one. Control 2's RESET bit
always reads 0, so a read showing it set was cut short and is not
written back; Melopero's unchecked set24HourMode() is no longer called
from begin(). A rejected read is never used: the time runs on from the
last accepted or set time, or before there is one comes from the
fallback clock, as with no RTC. The field-by-field getters are no longer
used, since a failed read decodes as year 2165, past RTClib's month
table.

setCurrentTime() writes inside the same bracket. It marks the year A0h,
which is not BCD, then writes Seconds through Year in one access (4.5).
Bytes acked before an interface reset are kept, so a write cut part way
leaves the A0h mark rather than a mixed time that passes for the real
one. The mark, or the mark counted on by a new year (A0h-FEh), reads as
a clock not yet set; FFh stays a failed read, as a read cut by an
interface reset returns it. Year 00 reads as 2000. An unconfirmed write
is retried every 5 s, at most 13 times in all, so a bus that is down
cannot stall each caller of getCurrentTime(). A time outside 2000-2099
is never written, since the chip counts years 00-99 and 2100 would
encode as the A0h mark; the clock runs on from it without the RTC. The
weekday is now DateTime::dayOfTheWeek(); the formula carried over from
dev stored the wrong weekday on most days.

This is part of a coordinated RV3028 update, kept consistent in
behaviour across Zephyr (zephyrproject-rtos/zephyr#121389), MeshCore and
ZephCore (follow-up to liquidraver/ZephCore#98). It replaces the year-00
split write that all three adopted on 2026-10-05.

Also log RTC detection for DS3231 and RV3028, which had none, plus a
line naming the driver that was bound.

Tested on a RAK4631 with a RAK12002: time set and read back, 2000-01-01
set and read back across a reboot, a staged cut write (A0h plus a
partial burst) and years A1h and B0h use the fallback clock, FFh is
logged as a failed read, a 2100 set leaves the RTC untouched, the stored
weekday is right, 12 hour mode is cleared on read and write with the
other Control 2 bits kept, and a ~60 s power cut keeps the time. Built
for ESP32-S3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Persistent DSM configuration must be merged or included before relying on switchover protection.

Review effort: Lite
Findings: None

Resolved since last review (1)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants