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