diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2a7b46 --- /dev/null +++ b/README.md @@ -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 diff --git a/ntpclock.py b/ntpclock.py new file mode 100644 index 0000000..c7db5c3 --- /dev/null +++ b/ntpclock.py @@ -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() diff --git a/ntpclock.service b/ntpclock.service new file mode 100644 index 0000000..506f0d8 --- /dev/null +++ b/ntpclock.service @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..87a472e --- /dev/null +++ b/requirements.txt @@ -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