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
+197
View File
@@ -0,0 +1,197 @@
# NTP Clock for MAX7219 LED Matrix
A Python application that displays the current time in `HH:MM:SS` format using four cascaded MAX7219 8x8 dot matrix LED modules.
## Features
- Displays time in HH:MM:SS format
- Custom 3x7 pixel font optimized for 8-pixel-high displays
- Syncs display updates to second boundaries for accurate timing
- Emulator mode (Tkinter) for development/testing without hardware
- Hardware mode for Raspberry Pi deployment
- Adjustable brightness (0-255)
## Hardware Requirements
- Raspberry Pi (any model with SPI)
- 4x MAX7219 8x8 Dot Matrix LED Modules (cascaded)
- Jumper wires
### Wiring (Raspberry Pi to MAX7219)
| MAX7219 Pin | Raspberry Pi Pin |
|-------------|------------------|
| VCC | 5V (Pin 2) |
| GND | GND (Pin 6) |
| DIN | MOSI (Pin 19) |
| CS | CE0 (Pin 24) |
| CLK | SCLK (Pin 23) |
## Installation
### On Raspberry Pi
1. Clone or download this repository:
```bash
cd ~/ntpclock
```
2. Create a virtual environment:
```bash
python3 -m venv venv
source venv/bin/activate
```
3. Install dependencies:
```bash
pip install luma.led_matrix spidev Pillow
```
4. Enable SPI:
```bash
sudo raspi-config
# Navigate to: Interface Options -> SPI -> Enable
```
5. Add your user to the SPI and GPIO groups:
```bash
sudo usermod -a -G spi,gpio $USER
```
Then log out and back in (or reboot).
6. Verify SPI is enabled:
```bash
ls /dev/spi*
# Should show /dev/spidev0.0 and /dev/spidev0.1
```
### For Emulator Only (development machine)
```bash
python3 -m venv venv
source venv/bin/activate
pip install Pillow
```
## Usage
### Emulator Mode (for development/testing)
```bash
python ntpclock.py --emulator
```
This opens a Tkinter window simulating the LED matrix display with red LED dots.
### Hardware Mode (on Raspberry Pi)
```bash
python ntpclock.py
```
### Options
```
usage: ntpclock.py [-h] [--emulator] [--brightness 0-255]
NTP Clock Display for MAX7219 LED Matrix
optional arguments:
-h, --help show this help message and exit
--emulator, -e Use Tkinter emulator instead of real hardware
--brightness, -b 0-255
Display brightness (0-255, default: 128)
```
### Examples
```bash
# Run with emulator
python ntpclock.py -e
# Run on hardware with dim display (good for nighttime)
python ntpclock.py -b 16
# Run on hardware with full brightness
python ntpclock.py -b 255
```
## Display Layout
The display shows time in `HH:MM:SS` format across 32x8 pixels:
```
┌────────────────────────────────┐
│ 12:34:56 │
└────────────────────────────────┘
└─┬─┘ └─┬─┘ └─┬─┘
Hours Minutes Seconds
```
## Running at Boot (Raspberry Pi)
A systemd service file is included. To install:
```bash
# Copy the service file
sudo cp ntpclock.service /etc/systemd/system/
# Reload systemd
sudo systemctl daemon-reload
# Enable to start on boot
sudo systemctl enable ntpclock.service
# Start now
sudo systemctl start ntpclock.service
```
To check status or view logs:
```bash
sudo systemctl status ntpclock.service
journalctl -u ntpclock.service -f
```
To stop or disable:
```bash
sudo systemctl stop ntpclock.service
sudo systemctl disable ntpclock.service
```
**Note:** Edit the service file if you need to adjust the brightness or if your installation path differs from `/home/dietpi/ntpclock/`.
## Troubleshooting
### SPI Permission Denied
Add your user to the spi and gpio groups:
```bash
sudo usermod -a -G spi,gpio $USER
```
Then log out and back in (or reboot).
### Display Shows Nothing
- Check wiring connections
- Verify SPI is enabled: `ls /dev/spi*`
- Try increasing brightness: `python ntpclock.py -b 255`
### Display is Mirrored or Rotated
The code uses `block_orientation=-90` which works for most pre-assembled MAX7219 modules. If your display looks wrong, edit `ntpclock.py` and try:
- `block_orientation=90` (opposite rotation)
- `block_orientation=0` (no rotation)
- `blocks_arranged_in_reverse_order=True` (if modules are chained in reverse)
### Module Not Found: spidev
Install the spidev module:
```bash
pip install spidev
```
### Emulator Window Doesn't Open
The emulator uses Tkinter which comes with Python. On some Linux systems you may need:
```bash
sudo apt-get install python3-tk
```
## License
MIT License
+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()
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=NTP Clock Display
After=network.target
[Service]
ExecStart=/home/dietpi/ntpclock/venv/bin/python /home/dietpi/ntpclock/ntpclock.py -b 16
WorkingDirectory=/home/dietpi/ntpclock
User=dietpi
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+7
View File
@@ -0,0 +1,7 @@
# Image processing (required for emulator and hardware)
Pillow>=9.0.0
# Hardware support (only needed on Raspberry Pi)
# Uncomment these when deploying to hardware:
# luma.core>=2.4.0
# luma.led_matrix>=1.7.0