Initial commit: NTP clock for MAX7219 LED matrix
This commit is contained in:
@@ -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
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user