Files
mini-rack-ntp-clock/ntpclock.py
T

328 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()