A clock can return 2026-10-20 12:34:56 while knowing nothing about whether that is the correct time. The digits may be left from a factory test, a previous program, or an automatic “set to compilation time” example. Build a small laboratory that asks three separate questions: is the calendar structurally possible, has the oscillator reported a stop, and where did this particular time setting come from?

We use an Analog Devices/Maxim DS3231SN# in the wide16-pin small-outline integrated circuit (SOIC) package, on a passive adapter, with an original Raspberry Pi Pico RP2040 running official MicroPython1.26.0. An RTC is a real-time clock: a circuit that maintains date and time while the main processor is off. A TCXO is a temperature-compensated crystal oscillator; it reduces clock-rate changes with temperature. Neither abbreviation means that the calendar was set correctly.

Verification: host tests execute the delivered calendar parser, register driver and console loop with an injected register bus. No Pico execution, firmware build, battery-retention measurement or physical accuracy test is claimed. The diagrams describe an untested build. Keep the blank bench sheet blank until you perform it.

The experiment is a validity gate

This is a companion to our earlier basic DS3231 clock project, which uses a library and display. Here the useful result is a refusal: the program should not turn unknown history into a confident timestamp. A received string and a trustworthy time reference are different things.

OSF, oscillator stop flag, is a sticky status bit: once set, it remains set until software clears it. Read it as evidence of interrupted or uncertain timekeeping, not a stopwatch telling you when the interruption happened. A cleared OSF also cannot prove that another program did not write an incorrect but plausible date. Provenance means recorded origin: the source and circumstances of a setting. Our record is deliberately an operator claim, not authenticated network time.

Exact parts and power

Use3.3V for both the integrated circuit (IC) and its serial-bus pullups. A pullup resistor returns an open-drain signal to its high level when no device is pulling low. At3.3V a4.7kΩ pullup carries about3.3/4700=0.70mA while low; two simultaneously low lines draw about1.40mA. Short wiring and100kHz are the starting conditions, not proof that every bus capacitance will meet its rise-time limit.

The DS3231 operating supply range is2.3–5.5V; the6V absolute rating is a damage-limit category, not a design supply. This build stays at3.3V. An arbitrary prebuilt RTC board may add5V pullups or a coin-cell charger; it is not the passive circuit below. A non-rechargeable CR2032 must not be connected to a charging path. We make no generic module compatibility promise.

Connect package pins, not remembered header positions

Exact DS3231SN# wide SOIC16 on a passive adapter: VCC2 to Pico3V3 physical36, GND13 and NC5–12 to ground, SDA15 to GP0 physical1, SCL16 to GP1 physical2, two4.7k pullups,100nF bypass and noncharging CR2032 holder at VBAT14.
Original connection schematic. Numbers at the IC are package pins, not arbitrary module header positions. No charger or display is present.
Exact IC pinConnection and purpose
2 VCC;13 GNDPico3V3 OUT physical36; PicoGND physical38. C1=100nF across these IC pins.
15 SDA;16 SCLGP0 physical1;GP1 physical2. Each has its own4.7kΩ pullup to3.3V.
14 VBATHolder positive; holder negative toGND. This is backup power, not a charging output.
5–12 N.C.Connect all eight to ground, as required for this exact device.
1 32kHz;3 INT/SQW;4 RSTUnused and open in this experiment. RST has an internal pullup; do not add the usual external one.

The package suffix matters: DS3231M is a different oscillator family. This lesson does not import its errata or behavior into the DS3231SN design. The current manufacturer datasheet served at review was19-5170 revision10,March2015. Its package drawing and N.C. grounding instruction agree; a targeted primary-source errata check found no separate applicable SN sheet. That is not a guarantee that no future notice exists.

With all power removed, inspect adapter orientation, shorts and holder polarity. Connect the ground first, then the remaining unpowered circuit. Measure the3.3V rail and idle bus levels after powering the Pico. For the retention trial, remove the whole Pico/RTC main supply so no powered microcontroller pin can feed an otherwise unpowered bus. Do not remove only VCC while leaving externally driven SDA/SCL connected.

Decode a calendar before displaying it

I²C, Inter-Integrated Circuit, is the two-wire addressable bus used here. The seven-bit RTC address is0x68; the prefix0x means hexadecimal notation. Registers0x00–0x06 contain seven date/time bytes. Read them in one burst starting at0x00. The device's snapshot buffer keeps that burst coherent across a seconds update; seven separate transactions would discard that useful property.

BCD, binary-coded decimal, stores one decimal digit in each four-bit group. Byte0x59 represents59;0x5A has an invalid decimal digit even though it is an ordinary binary number. The parser also checks ranges, reserved bits, month lengths and leap years. April31 is rejected rather than normalized to a different date. A plausible calendar is the first gate, not the final verdict.

The code handles the device's12-hour format, including12AM→00 and12PM→12, and writes24-hour format when explicitly requested. Its declared policy is2000–2099; a set century bit is refused instead of guessed. Weekday values1–7 are preserved as the device's user-defined sequence. When you enter a reference, record your weekday convention; the routine does not pretend that every old library numbered weekdays identically.

Separate structural validity, oscillator history and source

Validity policy flow: malformed reads reject; OSF revokes provenance; plausible OSF-clear date remains UNPROVEN until an explicit source setting, and every new process starts unproven.
Original software-policy diagram. CHECKED_SESSION records a narrow set of checks; it is not an accuracy certificate.

observe() reads status before and after the calendar burst. OSF seen in either reading produces OSF_SET and revokes the session source. The separate control-bit check catches EOSC=1: the enable-oscillator control bit, with1 disabling oscillation on backup power. It does not silently rewrite a control register that may be shared with alarms. An I²C error, short read or impossible date produces READ_INVALID with no candidate timestamp.

With a plausible date, OSF clear and backup oscillation enabled, the state is still UNPROVEN after every program restart. After an intentional setting and readback it can be CHECKED_SESSION. That means only that this process recorded a source claim and checked the specified registers. It does not prove reference accuracy, detect every later write by another bus master, or authenticate anything. Run this lab as the only time-setting program on its bus.

Complete program and an intentionally explicit write

Save the entire file as ds3231-clock-validity.py on the Pico and run it with your editor. Its output is the USB serial console; no display library or external package is required. USB is Universal Serial Bus. The REPL, read–evaluate–print loop, is the interactive console left available after Ctrl+C. Startup only observes: it never substitutes a computer's build timestamp or clears OSF automatically.

Download the complete program. The listing below is the same file, including its error handling and entry point.

Complete MicroPython program
"""DS3231SN# validity lab. MicroPython1.26 RP2; no automatic time setting."""
ADDRESS = 0x68


def bcd(byte, maximum, minimum=0):
    if byte < 0 or byte > 0x99 or (byte & 15) > 9 or (byte >> 4) > 9:
        raise ValueError("invalid BCD")
    value = (byte >> 4) * 10 + (byte & 15)
    if not minimum <= value <= maximum:
        raise ValueError("BCD value outside range")
    return value


def leap(year):
    return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)


def check_calendar(values):
    if len(values) != 7 or any(type(n) is not int for n in values):
        raise ValueError("need integer year,month,date,weekday,hour,minute,second")
    year, month, day, weekday, hour, minute, second = values
    if not (2000 <= year <= 2099 and 1 <= month <= 12 and 1 <= weekday <= 7
            and 0 <= hour <= 23 and 0 <= minute <= 59 and 0 <= second <= 59):
        raise ValueError("calendar outside experiment range")
    days = (31, 29 if leap(year) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
    if not 1 <= day <= days[month - 1]:
        raise ValueError("impossible date")
    return tuple(values)


def decode(raw):
    if len(raw) != 7:
        raise ValueError("short clock burst")
    second, minute, hour, weekday, day, month, year = raw
    if second & 0x80 or minute & 0x80 or hour & 0x80 or weekday & 0xF8 or day & 0xC0 or month & 0x60:
        raise ValueError("reserved calendar bits")
    if month & 0x80:
        raise ValueError("century outside 2000-2099 policy")
    if hour & 0x40:
        hour24 = bcd(hour & 0x1F, 12, 1) % 12 + (12 if hour & 0x20 else 0)
    else:
        hour24 = bcd(hour & 0x3F, 23)
    return check_calendar((2000 + bcd(year, 99), bcd(month, 12, 1),
                           bcd(day, 31, 1), bcd(weekday, 7, 1), hour24,
                           bcd(minute, 59), bcd(second, 59)))


def encode(values):
    year, month, day, weekday, hour, minute, second = check_calendar(values)
    pack = lambda n: ((n // 10) << 4) | (n % 10)
    return bytes((pack(second), pack(minute), pack(hour), weekday, pack(day), pack(month), pack(year - 2000)))


def serial_seconds(values):
    year, month, day, _, hour, minute, second = check_calendar(values)
    days = sum(366 if leap(y) else 365 for y in range(2000, year))
    lengths = (31, 29 if leap(year) else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
    days += sum(lengths[:month - 1]) + day - 1
    return ((days * 24 + hour) * 60 + minute) * 60 + second


def iso(values):
    year, month, day, _, hour, minute, second = values
    return "%04d-%02d-%02dT%02d:%02d:%02dZ" % (year, month, day, hour, minute, second)


class Clock:
    def __init__(self, bus):
        self.bus = bus
        self.provenance = None  # Deliberately not inferred from OSF=0.

    def read_bytes(self, register, count):
        data = self.bus.readfrom_mem(ADDRESS, register, count)
        if len(data) != count:
            raise ValueError("short I2C read")
        return data

    def observe(self):
        try:
            before = self.read_bytes(0x0F, 1)[0]
            calendar = decode(self.read_bytes(0x00, 7))
            after = self.read_bytes(0x0F, 1)[0]
            control = self.read_bytes(0x0E, 1)[0]
            if (before | after) & 0x80:
                self.provenance = None
                state = "OSF_SET"
            elif control & 0x80:
                self.provenance = None
                state = "BACKUP_OSC_DISABLED"
            else:
                state = "CHECKED_SESSION" if self.provenance else "UNPROVEN"
            return {"state": state, "calendar_candidate_utc": iso(calendar),
                    "status_before": before, "status_after": after,
                    "provenance": self.provenance,
                    "accuracy_or_authenticity_proved": False}
        except (OSError, ValueError) as error:
            self.provenance = None
            return {"state": "READ_INVALID", "calendar_candidate_utc": None,
                    "provenance": None, "error": str(error)}

    def set_from_reference(self, values, source, allow_write=False):
        """Explicit operator action; source is a recorded claim, not authentication."""
        self.provenance = None
        if allow_write is not True or not isinstance(source, str) or not source.strip():
            raise ValueError("explicit write permission and reference description required")
        expected = check_calendar(values)
        control = self.read_bytes(0x0E, 1)[0]
        if control & 0x80:
            raise ValueError("review EOSC setting first; helper does not rewrite control")
        # One seven-byte burst. Do not set registers by hand one second apart.
        self.bus.writeto_mem(ADDRESS, 0x00, encode(expected))
        readback = decode(self.read_bytes(0x00, 7))
        delta = serial_seconds(readback) - serial_seconds(expected)
        if not 0 <= delta <= 1:
            raise ValueError("time write readback mismatch")
        status = self.read_bytes(0x0F, 1)[0]
        # Keep EN32kHz; write1 to both write-zero-to-clear alarm flags so an
        # alarm arriving after the read is not accidentally acknowledged.
        self.bus.writeto_mem(ADDRESS, 0x0F, bytes(((status & 0x08) | 0x03,)))
        checked = self.observe()
        if checked["state"] != "UNPROVEN":
            raise ValueError("status did not qualify after time setting")
        final = decode(self.read_bytes(0x00, 7))
        if not 0 <= serial_seconds(final) - serial_seconds(expected) <= 2:
            raise ValueError("post-clear calendar mismatch")
        self.provenance = {"source_claim": source.strip(), "set_utc": iso(expected)}
        return self.observe()


def run(clock, sleep, output=print, samples=None):
    count = 0
    while samples is None or count < samples:
        output(clock.observe())
        count += 1
        sleep(1)


if __name__ == "__main__":
    from machine import Pin, SoftI2C
    from time import sleep
    rtc = Clock(SoftI2C(sda=Pin(0), scl=Pin(1), freq=100000, timeout=50000))
    # No compile-time date, automatic OSF clear, or automatic write on startup.
    try:
        run(rtc, sleep)
    except KeyboardInterrupt:
        print("Stopped; rtc remains available at the REPL")

To perform the optional write, stop the loop with Ctrl+C and use the retained rtc object. Call rtc.set_from_reference(values, source, allow_write=True) only after checking a fresh reference. The seven-element tuple is (year, month, date, weekday, hour, minute, second) in your declared UTC convention. Replace every field with your current reference; no dated example here should become an automatic startup command.

The setter validates inputs, writes all seven time bytes in one transaction, verifies a0–1second readback advance, then clears OSF. When clearing status it preserves the32kHz enable choice and writes ones to both alarm-flag positions. Those bits clear on zero, so this avoids accidentally acknowledging an alarm that arrived between reading and writing status. A second check allows at most2seconds of calendar advance over the setting operation. These are software acceptance limits, not measured I²C timing guarantees. A failed bus write or readback is an exception; it must not be treated as success.

Repeatable experiments without inventing a fault

  1. Observe first. Record the actual IC marking, adapter orientation, battery type, rail voltage, firmware version, raw status and candidate calendar. A newly powered chip may report OSF; leave that evidence intact until you deliberately set time.
  2. Compare OSF with provenance. If OSF is already clear, restart only the program. It must say UNPROVEN. This is the important counterexample: a clear hardware flag does not supply a reference source.
  3. Set deliberately. Use a checked external UTC reference and record its identity and uncertainty. Enter the current tuple, source description and explicit permission. Record whether the code reaches CHECKED_SESSION. The write/readback latency contributes setting uncertainty; this is not a synchronized timing instrument.
  4. Restart the software. A new Clock object must again be UNPROVEN. The oscillator can continue correctly while the process correctly admits that it lacks its previous in-memory provenance record.
  5. Test real backup retention. With a verified noncharging holder and battery fitted, safely remove all main Pico/RTC power for an interval timed by a separate reference. Restore main power and record OSF, candidate date and offset. The new process still starts UNPROVEN; assess retention from the actual observations, not from that label alone.
  6. Run hostile data on the host. The supplied tests inject invalid BCD, impossible dates, short reads, failed writes, a failed OSF clear and an alarm arriving during the status update. Use these fakes to reproduce cases rather than corrupting a useful live clock.

Blank observation sheet contains no measurements. Record unexpected outcomes as results, including a flag that does not change. Never short a battery or deliberately disturb the crystal to force OSF.

What this tells you about accuracy

A2ppm frequency error means2parts per million. Multiplying2×10⁻⁶ by86,400seconds gives0.1728seconds per day; over365days it gives63.072seconds. These are calculations for a constant error at that bound under the stated datasheet conditions, not this unit's measured drift or a lifetime guarantee. Initial reference error, manual setting delay, aging, voltage and temperature changes still matter.

To measure drift, compare two recorded reference offsets separated by a known interval: approximate rate error in ppm is (offset₂−offset₁)/elapsed_seconds×10⁶. For example, a synthetic0.40second offset change over200,000seconds is2ppm. A single offset reading cannot separate setting error from rate error. Use a reference whose uncertainty is small enough for the effect you want to resolve.

Challenge: a plausible date, OSF=0, no record of who set it

Which state should be used? UNPROVEN. Do not clear a flag that is already clear or overwrite the calendar with compilation time to make the display look healthy. Record the uncertainty, obtain a reference if needed and perform an explicit setting. The absence of a known stop is not the presence of a known origin.

Verification boundaries and next steps

The host tests exercise the actual parser, setter, status-write mask and console loop. They do not establish electrical bus margins, detect counterfeit silicon, time an oscillator, test a real battery, or produce a Pico firmware binary. The design intentionally omits persistent provenance, network synchronization and security. Those would require their own failure and trust models. First master the underlying signals in the electronics lessons, then connect a logger only after deciding how uncertain timestamps should be represented.

Primary references