Initial commit: NTP clock for MAX7219 LED matrix

This commit is contained in:
Adam Harrison-Fuller
2026-01-21 20:06:32 +00:00
parent e10a12abca
commit 7dc20d4ecc
4 changed files with 544 additions and 0 deletions
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""
NTP Clock Display for MAX7219 8x8 LED Matrix Modules
Displays time in HH:MM:SS format using four cascaded MAX7219 8x8 dot matrix
LED modules (32x8 pixels total). Supports both hardware (Raspberry Pi) and
emulator modes for development/testing.
Usage:
python ntpclock.py --emulator # Run with Tkinter emulator
python ntpclock.py # Run on actual hardware (Raspberry Pi)
"""
import argparse
import time
import sys
from datetime import datetime
from PIL import Image, ImageDraw
# Custom 3x7 font for digits - tall and readable, fits 32px width
# Each digit is defined as a list of 7 rows, each row is 3 bits wide
# Layout: HH:MM:SS = 6 digits × 4px + 2 colons × 2px + trailing = 28px (centered)
DIGIT_FONT = {
'0': [0b111, 0b101, 0b101, 0b101, 0b101, 0b101, 0b111],
'1': [0b010, 0b110, 0b010, 0b010, 0b010, 0b010, 0b111],
'2': [0b111, 0b001, 0b001, 0b111, 0b100, 0b100, 0b111],
'3': [0b111, 0b001, 0b001, 0b111, 0b001, 0b001, 0b111],
'4': [0b101, 0b101, 0b101, 0b111, 0b001, 0b001, 0b001],
'5': [0b111, 0b100, 0b100, 0b111, 0b001, 0b001, 0b111],
'6': [0b111, 0b100, 0b100, 0b111, 0b101, 0b101, 0b111],
'7': [0b111, 0b001, 0b001, 0b001, 0b001, 0b001, 0b001],
'8': [0b111, 0b101, 0b101, 0b111, 0b101, 0b101, 0b111],
'9': [0b111, 0b101, 0b101, 0b111, 0b001, 0b001, 0b111],
':': [0b0, 0b0, 0b1, 0b0, 0b1, 0b0, 0b0], # Colon separator (1 pixel wide)
}
def draw_char(draw, x, y, char, fill="white"):
"""Draw a single character from the custom font at position (x, y)."""
if char not in DIGIT_FONT:
return 0
pattern = DIGIT_FONT[char]
width = 3 if char.isdigit() else 1
for row_idx, row in enumerate(pattern):
for col_idx in range(width):
bit_pos = width - 1 - col_idx
if row & (1 << bit_pos):
draw.point((x + col_idx, y + row_idx), fill=fill)
return width
def draw_time_string(draw, time_str, y_offset=0):
"""
Draw a time string (HH:MM:SS) centered on the display.
Layout for 32x8 display with 3x7 font:
- Each digit: 3 pixels wide
- Colon: 1 pixel wide
- Spacing: 1 pixel between all elements
Total: 6 digits (18px) + 2 colons (2px) + 7 spaces (7px) = 27px
Centered with 2-3px padding on each side.
"""
x = 2 # Center the 27px content on 32px display
for char in time_str:
if char in DIGIT_FONT:
width = draw_char(draw, x, y_offset, char)
x += width + 1 # Add 1 pixel spacing after each character
class TkinterEmulator:
"""
A simple Tkinter-based LED matrix emulator.
Displays pixels as circles to simulate LED dots.
"""
def __init__(self, width=32, height=8, scale=15, led_color='red', bg_color='#1a1a1a'):
import tkinter as tk
self.width = width
self.height = height
self.scale = scale
self.led_color = led_color
self.bg_color = bg_color
self.mode = '1'
self.root = tk.Tk()
self.root.title("LED Matrix Emulator - NTP Clock")
self.root.configure(bg='#333333')
self.root.resizable(False, False)
# Calculate canvas size with padding for LED effect
padding = 4
canvas_width = width * scale + padding * 2
canvas_height = height * scale + padding * 2
self.canvas = tk.Canvas(
self.root,
width=canvas_width,
height=canvas_height,
bg=bg_color,
highlightthickness=2,
highlightbackground='#555555'
)
self.canvas.pack(padx=10, pady=10)
# Pre-create LED circles for efficiency
self.leds = []
led_radius = scale // 2 - 2
for y in range(height):
row = []
for x in range(width):
cx = padding + x * scale + scale // 2
cy = padding + y * scale + scale // 2
led = self.canvas.create_oval(
cx - led_radius, cy - led_radius,
cx + led_radius, cy + led_radius,
fill='#2a2a2a', # Off state (dim)
outline='#1a1a1a'
)
row.append(led)
self.leds.append(row)
# Handle window close
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
self._running = True
def _on_close(self):
self._running = False
self.root.destroy()
def display(self, image):
"""Update the display with a PIL Image."""
if not self._running:
raise KeyboardInterrupt("Window closed")
# Convert to 1-bit if needed
if image.mode != '1':
image = image.convert('1')
pixels = image.load()
for y in range(self.height):
for x in range(self.width):
pixel = pixels[x, y]
# In mode '1': 255 = white (on), 0 = black (off)
if pixel:
self.canvas.itemconfig(self.leds[y][x], fill=self.led_color)
else:
self.canvas.itemconfig(self.leds[y][x], fill='#2a2a2a')
self.root.update()
def cleanup(self):
"""Clean up resources."""
if self._running:
self._running = False
self.root.destroy()
def contrast(self, value):
"""Adjust LED brightness (approximate with color intensity)."""
intensity = int((value / 255) * 255)
self.led_color = f'#{intensity:02x}0000'
class CanvasContext:
"""Context manager that mimics luma.core.render.canvas behavior."""
def __init__(self, device):
self.device = device
self.image = Image.new('1', (device.width, device.height), 0)
self.draw = ImageDraw.Draw(self.image)
def __enter__(self):
return self.draw
def __exit__(self, exc_type, exc_val, exc_tb):
self.device.display(self.image)
return False
def canvas(device):
"""Create a canvas context for drawing on the device."""
return CanvasContext(device)
def get_device(emulator=False, width=32, height=8):
"""
Create and return the appropriate display device.
Args:
emulator: If True, use Tkinter emulator; otherwise use real hardware
width: Display width in pixels
height: Display height in pixels
Returns:
Device instance (TkinterEmulator or MAX7219)
"""
if emulator:
device = TkinterEmulator(width=width, height=height, scale=15)
print("Running in emulator mode (Tkinter)")
else:
from luma.core.interface.serial import spi, noop
from luma.led_matrix.device import max7219
from luma.core.render import canvas as luma_canvas
# Replace our canvas with luma's canvas for hardware mode
global canvas
canvas = luma_canvas
serial = spi(port=0, device=0, gpio=noop())
device = max7219(
serial,
cascaded=4,
block_orientation=-90,
rotate=0,
blocks_arranged_in_reverse_order=False
)
device.contrast(128)
print("Running on hardware (MAX7219)")
return device
def format_time(dt):
"""
Format datetime as HH:MM:SS.
Args:
dt: datetime object
Returns:
Formatted time string
"""
return f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d}"
def run_clock(device):
"""
Main clock loop - continuously update the display with current time.
Syncs updates to the exact second boundary for precise timing.
Args:
device: luma device instance
"""
print("Starting clock display... Press Ctrl+C to exit.")
try:
last_second = -1
while True:
now = datetime.now()
# Only update display when the second changes
if now.second != last_second:
last_second = now.second
time_str = format_time(now)
with canvas(device) as draw:
draw_time_string(draw, time_str, y_offset=0)
# Small sleep to avoid busy-waiting, but short enough to catch the change
time.sleep(0.01)
except KeyboardInterrupt:
print("\nClock stopped.")
def main():
"""Parse arguments and start the clock."""
parser = argparse.ArgumentParser(
description='NTP Clock Display for MAX7219 LED Matrix',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s --emulator Run with pygame emulator for testing
%(prog)s Run on Raspberry Pi hardware
%(prog)s --brightness 64 Run with lower brightness
"""
)
parser.add_argument(
'--emulator', '-e',
action='store_true',
help='Use pygame emulator instead of real hardware'
)
parser.add_argument(
'--brightness', '-b',
type=int,
default=128,
choices=range(0, 256),
metavar='0-255',
help='Display brightness (0-255, default: 128)'
)
args = parser.parse_args()
try:
device = get_device(emulator=args.emulator)
if not args.emulator:
device.contrast(args.brightness)
run_clock(device)
except ImportError as e:
print(f"Import error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
finally:
if 'device' in locals():
device.cleanup()
if __name__ == '__main__':
main()