PyPCF8523/pypcf8523/pcf8523.py
2025-11-10 18:05:25 +01:00

296 lines
9.3 KiB
Python

"""PCF8523 Real Time Clock Driver
This module provides an interface to the PCF8523 RTC chip via I2C.
Designed for Raspberry Pi and similar Linux boards with I2C support.
SPDX-FileCopyrightText: 2016 Philip R. Moyer for Adafruit Industries
SPDX-FileCopyrightText: 2016 Radomir Dopieralski for Adafruit Industries
SPDX-FileCopyrightText: 2025 Converted for Python 3.14
SPDX-License-Identifier: MIT
"""
import time
from typing import Optional
try:
from smbus2 import SMBus
except ImportError:
raise ImportError("smbus2 is required. Install with: pip install smbus2")
# Power management constants
STANDARD_BATTERY_SWITCHOVER_AND_DETECTION = 0b000
BATTERY_SWITCHOVER_OFF = 0b111
# I2C address for PCF8523
PCF8523_ADDRESS = 0x68
# Register addresses
_CONTROL_1 = 0x00
_CONTROL_2 = 0x01
_CONTROL_3 = 0x02
_SECONDS = 0x03
_MINUTES = 0x04
_HOURS = 0x05
_DAYS = 0x06
_WEEKDAYS = 0x07
_MONTHS = 0x08
_YEARS = 0x09
_MINUTE_ALARM = 0x0A
_HOUR_ALARM = 0x0B
_DAY_ALARM = 0x0C
_WEEKDAY_ALARM = 0x0D
_OFFSET = 0x0E
_TMR_CLKOUT_CTRL = 0x0F
def _bcd2bin(value: int) -> int:
"""Convert binary coded decimal to binary."""
return value - 6 * (value >> 4)
def _bin2bcd(value: int) -> int:
"""Convert binary to binary coded decimal."""
return value + 6 * (value // 10)
class PCF8523:
"""Interface to the PCF8523 RTC.
Args:
i2c_bus: I2C bus number (typically 1 for Raspberry Pi)
address: I2C address of the PCF8523 (default: 0x68)
Example:
>>> rtc = PCF8523(1) # Use I2C bus 1
>>> print(rtc.datetime)
time.struct_time(tm_year=2025, tm_mon=11, tm_mday=9, ...)
"""
def __init__(self, i2c_bus: int = 1, address: int = PCF8523_ADDRESS):
"""Initialize the PCF8523 driver.
Args:
i2c_bus: I2C bus number (default: 1)
address: I2C device address (default: 0x68)
"""
self._bus = SMBus(i2c_bus)
self._address = address
def _read_byte(self, register: int) -> int:
"""Read a single byte from a register."""
return self._bus.read_byte_data(self._address, register)
def _write_byte(self, register: int, value: int) -> None:
"""Write a single byte to a register."""
self._bus.write_byte_data(self._address, register, value)
def _read_bit(self, register: int, bit: int) -> bool:
"""Read a specific bit from a register."""
value = self._read_byte(register)
return bool((value >> bit) & 1)
def _write_bit(self, register: int, bit: int, value: bool) -> None:
"""Write a specific bit in a register."""
reg_value = self._read_byte(register)
if value:
reg_value |= (1 << bit)
else:
reg_value &= ~(1 << bit)
self._write_byte(register, reg_value)
def _read_bits(self, register: int, bit: int, length: int) -> int:
"""Read multiple bits from a register."""
value = self._read_byte(register)
mask = (1 << length) - 1
return (value >> bit) & mask
def _write_bits(self, register: int, bit: int, length: int, value: int) -> None:
"""Write multiple bits to a register."""
reg_value = self._read_byte(register)
mask = ((1 << length) - 1) << bit
reg_value = (reg_value & ~mask) | ((value << bit) & mask)
self._write_byte(register, reg_value)
@property
def lost_power(self) -> bool:
"""True if the device has lost power since the time was set."""
return self._read_bit(_CONTROL_3, 7)
@lost_power.setter
def lost_power(self, value: bool) -> None:
"""Clear or set the power lost flag."""
self._write_bit(_CONTROL_3, 7, value)
@property
def power_management(self) -> int:
"""Power management state that dictates battery switchover.
Defaults to BATTERY_SWITCHOVER_OFF (0b111).
"""
return self._read_bits(_CONTROL_3, 5, 3)
@power_management.setter
def power_management(self, value: int) -> None:
"""Set power management mode."""
self._write_bits(_CONTROL_3, 5, 3, value)
@property
def datetime(self) -> time.struct_time:
"""Get the current date and time as a time.struct_time object."""
# Read all time registers at once
buffer = self._bus.read_i2c_block_data(self._address, _SECONDS, 7)
# Convert BCD to binary
seconds = _bcd2bin(buffer[0] & 0x7F)
minutes = _bcd2bin(buffer[1] & 0x7F)
hours = _bcd2bin(buffer[2] & 0x3F)
days = _bcd2bin(buffer[3] & 0x3F)
weekday = buffer[4] & 0x07
months = _bcd2bin(buffer[5] & 0x1F)
years = _bcd2bin(buffer[6]) + 2000
return time.struct_time((years, months, days, hours, minutes, seconds,
weekday, -1, -1))
@datetime.setter
def datetime(self, value: time.struct_time) -> None:
"""Set the current date and time from a time.struct_time object.
Args:
value: time.struct_time with year, month, day, hour, minute, second, weekday
"""
# Enable battery switchover and clear lost power flag
self.power_management = STANDARD_BATTERY_SWITCHOVER_AND_DETECTION
# Convert to BCD and write to registers
buffer = [
_bin2bcd(value.tm_sec) & 0x7F,
_bin2bcd(value.tm_min) & 0x7F,
_bin2bcd(value.tm_hour) & 0x3F,
_bin2bcd(value.tm_mday) & 0x3F,
value.tm_wday & 0x07,
_bin2bcd(value.tm_mon) & 0x1F,
_bin2bcd(value.tm_year - 2000) & 0xFF,
]
self._bus.write_i2c_block_data(self._address, _SECONDS, buffer)
# Clear the power lost flag
self.lost_power = False
@property
def alarm_interrupt(self) -> bool:
"""True if the interrupt pin will output when alarm is alarming."""
return self._read_bit(_CONTROL_1, 1)
@alarm_interrupt.setter
def alarm_interrupt(self, value: bool) -> None:
"""Enable or disable alarm interrupt output."""
self._write_bit(_CONTROL_1, 1, value)
@property
def alarm_status(self) -> bool:
"""True if alarm is alarming. Set to False to reset."""
return self._read_bit(_CONTROL_2, 3)
@alarm_status.setter
def alarm_status(self, value: bool) -> None:
"""Clear alarm status flag."""
self._write_bit(_CONTROL_2, 3, value)
@property
def battery_low(self) -> bool:
"""True if the battery is low and should be replaced."""
return self._read_bit(_CONTROL_3, 2)
@property
def high_capacitance(self) -> bool:
"""True for high oscillator capacitance (12.5pF), False for lower (7pF)."""
return self._read_bit(_CONTROL_1, 7)
@high_capacitance.setter
def high_capacitance(self, value: bool) -> None:
"""Set oscillator capacitance mode."""
self._write_bit(_CONTROL_1, 7, value)
@property
def calibration_schedule_per_minute(self) -> bool:
"""False to apply calibration offset every 2 hours (1 LSB = 4.340ppm);
True to offset every minute (1 LSB = 4.069ppm).
"""
return self._read_bit(_OFFSET, 7)
@calibration_schedule_per_minute.setter
def calibration_schedule_per_minute(self, value: bool) -> None:
"""Set calibration schedule mode."""
self._write_bit(_OFFSET, 7, value)
@property
def calibration(self) -> int:
"""Calibration offset to apply, from -64 to +63."""
value = self._read_bits(_OFFSET, 0, 7)
# Convert to signed integer
if value > 63:
value -= 128
return value
@calibration.setter
def calibration(self, value: int) -> None:
"""Set calibration offset (-64 to +63)."""
if not -64 <= value <= 63:
raise ValueError("Calibration must be between -64 and +63")
# Convert to unsigned for register
if value < 0:
value += 128
self._write_bits(_OFFSET, 0, 7, value)
def set_alarm(self, minute: Optional[int] = None, hour: Optional[int] = None,
day: Optional[int] = None, weekday: Optional[int] = None) -> None:
"""Set an alarm.
Args:
minute: Minute to trigger (0-59) or None to disable
hour: Hour to trigger (0-23) or None to disable
day: Day to trigger (1-31) or None to disable
weekday: Weekday to trigger (0-6) or None to disable
Note: Alarms only fire at full minutes (seconds are ignored).
"""
alarm_regs = [0x80, 0x80, 0x80, 0x80] # All disabled by default
if minute is not None:
alarm_regs[0] = _bin2bcd(minute) & 0x7F
if hour is not None:
alarm_regs[1] = _bin2bcd(hour) & 0x3F
if day is not None:
alarm_regs[2] = _bin2bcd(day) & 0x3F
if weekday is not None:
alarm_regs[3] = weekday & 0x07
self._bus.write_i2c_block_data(self._address, _MINUTE_ALARM, alarm_regs)
def clear_alarm(self) -> None:
"""Disable and clear the alarm."""
self.alarm_status = False
self.set_alarm() # Disable all alarm fields
def close(self) -> None:
"""Close the I2C bus connection."""
self._bus.close()
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close()
return False