328 lines
9.4 KiB
Python
328 lines
9.4 KiB
Python
#!/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()
|