#!/usr/bin/env python3 # Render HH MM SS Sat as two 4x8 glyphs per 8x8 MAX7219 module (no scrolling). # Modules: [HH] [MM] [SS] [Sat] # Modified: set system time from GPS when available; GPIO21 cycles timezone display. import time import subprocess from datetime import datetime, timedelta try: import serial except Exception: serial = None # Try to import RPi.GPIO; if not available, disable button functionality try: import RPi.GPIO as GPIO GPIO_AVAILABLE = True except Exception: GPIO_AVAILABLE = False from luma.core.interface.serial import spi, noop from luma.led_matrix.device import max7219 from luma.core.render import canvas # ----------------------------- # CONFIG # ----------------------------- GPS_DEVICE = "/dev/ttyACM0" GPS_BAUD = 9600 GPS_TIMEOUT = 0.5 MODULES = 4 CASCADED = MODULES BLOCK_ORIENTATION = 90 ROTATE = 0 MODULE_REVERSED = True VERTICAL_OFFSET_UP = 0 # adjust if you want glyphs moved up (0..3) RUN_TEST_AT_START = False # Button config (BCM) BUTTON_PIN = 21 # Timezone list and fixed offsets (hours from UTC) # Adjust offsets if you want different DST handling. TIMEZONES = ["SYD", "SDST", "CALF", "NYC", "TKYO", "BJNG", "GRCE"] TZ_OFFSETS = { "SYD": 10, # Sydney standard (UTC+10) "SDST": 11, # Sydney DST (UTC+11) "CALF": -8, # California (Pacific Standard) UTC-8 "NYC": -5, # New York (Eastern Standard) UTC-5 "TKYO": 9, # Tokyo UTC+9 "BJNG": 8, # Beijing UTC+8 "GRCE": 2, # Greece UTC+2 } # ----------------------------- # DEVICE SETUP # ----------------------------- serial_spi = spi(port=0, device=0, gpio=noop()) device = max7219( serial_spi, cascaded=CASCADED, block_orientation=BLOCK_ORIENTATION, rotate=ROTATE, ) MODULE_WIDTH = 8 MODULE_HEIGHT = device.height # typically 8 # ----------------------------- # 4x8 bitmap font (each entry: 8 rows, each row is 4-bit LSB->MSB) # 1 = pixel on, 0 = pixel off # Added uppercase letters used in timezone codes. # ----------------------------- FONT_4x8 = { "0": [0b1111,0b1001,0b1001,0b1001,0b1001,0b1001,0b1111,0b0000], "1": [0b0010,0b0110,0b0010,0b0010,0b0010,0b0010,0b0111,0b0000], "2": [0b1111,0b0001,0b0001,0b1111,0b1000,0b1000,0b1111,0b0000], "3": [0b1111,0b0001,0b0001,0b0111,0b0001,0b0001,0b1111,0b0000], "4": [0b1001,0b1001,0b1001,0b1111,0b0001,0b0001,0b0001,0b0000], "5": [0b1111,0b1000,0b1000,0b1111,0b0001,0b0001,0b1111,0b0000], "6": [0b1111,0b1000,0b1000,0b1111,0b1001,0b1001,0b1111,0b0000], "7": [0b1111,0b0001,0b0001,0b0010,0b0100,0b0100,0b0100,0b0000], "8": [0b1111,0b1001,0b1001,0b1111,0b1001,0b1001,0b1111,0b0000], "9": [0b1111,0b1001,0b1001,0b1111,0b0001,0b0001,0b1111,0b0000], "H": [0b1001,0b1001,0b1001,0b1111,0b1001,0b1001,0b1001,0b0000], "M": [0b1001,0b1111,0b1111,0b1011,0b1001,0b1001,0b1001,0b0000], "S": [0b1111,0b1000,0b1000,0b1111,0b0001,0b0001,0b1111,0b0000], "E": [0b1111,0b1000,0b1000,0b1111,0b1000,0b1000,0b1111,0b0000], "-": [0b0000,0b0000,0b0000,0b1111,0b0000,0b0000,0b0000,0b0000], " ": [0b0000,0b0000,0b0000,0b0000,0b0000,0b0000,0b0000,0b0000], # Letters (simple 4x8 approximations) "A": [0b0110,0b1001,0b1001,0b1111,0b1001,0b1001,0b1001,0b0000], "B": [0b1110,0b1001,0b1001,0b1110,0b1001,0b1001,0b1110,0b0000], "C": [0b0111,0b1000,0b1000,0b1000,0b1000,0b1000,0b0111,0b0000], "D": [0b1110,0b1001,0b1001,0b1001,0b1001,0b1001,0b1110,0b0000], "F": [0b1111,0b1000,0b1000,0b1110,0b1000,0b1000,0b1000,0b0000], "G": [0b0111,0b1000,0b1000,0b1011,0b1001,0b1001,0b0111,0b0000], "J": [0b0011,0b0001,0b0001,0b0001,0b1001,0b1001,0b0110,0b0000], "K": [0b1001,0b1010,0b1100,0b1100,0b1010,0b1001,0b1001,0b0000], "L": [0b1000,0b1000,0b1000,0b1000,0b1000,0b1000,0b1111,0b0000], "N": [0b1001,0b1101,0b1111,0b1011,0b1001,0b1001,0b1001,0b0000], "O": [0b0110,0b1001,0b1001,0b1001,0b1001,0b1001,0b0110,0b0000], "R": [0b1110,0b1001,0b1001,0b1110,0b1010,0b1001,0b1001,0b0000], "T": [0b1111,0b0100,0b0100,0b0100,0b0100,0b0100,0b0100,0b0000], "Y": [0b1001,0b1001,0b1001,0b0110,0b0100,0b0100,0b0100,0b0000], } # ----------------------------- # DRAWING: draw a 4x8 glyph at (x,y) in the canvas # ----------------------------- def draw_glyph(draw, glyph_char, x, y, fill=1): bmp = FONT_4x8.get(glyph_char, FONT_4x8[" "]) for row in range(8): row_bits = bmp[row] for col in range(4): if (row_bits >> (3 - col)) & 1: px = x + col py = y + row - VERTICAL_OFFSET_UP if 0 <= px < device.width and 0 <= py < device.height: draw.point((px, py), fill="white") def show_pairs_per_module(pairs): s_pairs = [str(p)[:2].ljust(2) for p in pairs] + [" "] * MODULES s_pairs = s_pairs[:MODULES] if MODULE_REVERSED: module_indices = list(reversed(range(MODULES))) else: module_indices = list(range(MODULES)) with canvas(device) as draw: for i, pair in enumerate(s_pairs): module_index = module_indices[i] module_x = module_index * MODULE_WIDTH ch0 = pair[0] if len(pair) > 0 else " " ch1 = pair[1] if len(pair) > 1 else " " draw_glyph(draw, ch0, module_x + 0, 0) draw_glyph(draw, ch1, module_x + 4, 0) def clear_display(): with canvas(device): pass # ----------------------------- # GPS PARSING # ----------------------------- gps = None if serial is not None: try: gps = serial.Serial(GPS_DEVICE, baudrate=GPS_BAUD, timeout=GPS_TIMEOUT) except Exception: gps = None def parse_gprmc_time_hhmmss(line): if not line.startswith("$GPRMC"): return None parts = line.split(",") if len(parts) < 2: return None t = parts[1] if len(t) < 6: return None hh = t[0:2]; mm = t[2:4]; ss = t[4:6] if not (hh.isdigit() and mm.isdigit() and ss.isdigit()): return None hival = int(hh); mival = int(mm); sival = int(ss) if 0 <= hival < 24 and 0 <= mival < 60 and 0 <= sival < 60: return hh + mm + ss return None def parse_gpgga_satellites(line): if not line.startswith("$GPGGA"): return None parts = line.split(",") if len(parts) < 8: return None nsat = parts[7] if nsat.isdigit(): try: return int(nsat) except Exception: return None return None def read_gps_line(): if gps is None: return None try: raw = gps.readline() except Exception: return None if not raw: return None try: return raw.decode("ascii", errors="ignore").strip() except Exception: return None def get_system_time_hhmmss(): return datetime.now().strftime("%H%M%S") # ----------------------------- # Set system time (attempt). Requires sudo privileges. # We set only the time portion (UTC) using date -u --set="HH:MM:SS" # ----------------------------- def set_system_time_utc(hhmmss): hh = hhmmss[0:2]; mm = hhmmss[2:4]; ss = hhmmss[4:6] timestr = f"{hh}:{mm}:{ss}" try: # Use date -u --set to set UTC time subprocess.run(["sudo", "date", "-u", "--set", timestr], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return True except Exception: # fallback: try date --set (local) try: subprocess.run(["sudo", "date", "--set", timestr], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return True except Exception: return False # ----------------------------- # Helper: show a short text (1..8 chars) centered across 8 glyph positions (4 modules) # ----------------------------- def show_text_centered(text): # ensure uppercase and only allowed chars text = str(text).upper() # center into 8 characters if len(text) >= 8: s = text[:8] else: pad = (8 - len(text)) // 2 s = " " * pad + text + " " * (8 - len(text) - pad) # split into pairs for modules pairs = [s[i:i+2] for i in range(0, 8, 2)] show_pairs_per_module(pairs) # ----------------------------- # QUICK TEST # ----------------------------- def run_quick_test(): show_pairs_per_module(["12","34","56","04"]) time.sleep(5) clear_display() # ----------------------------- # GPIO button handling (cycle timezone) # ----------------------------- current_tz_index = 0 display_tz_until = 0 # epoch seconds until which timezone text is shown display_tz_code = TIMEZONES[current_tz_index] def button_pressed_callback(channel): global current_tz_index, display_tz_until, display_tz_code current_tz_index = (current_tz_index + 1) % len(TIMEZONES) display_tz_code = TIMEZONES[current_tz_index] display_tz_until = time.time() + 1.0 # show timezone text for 1 second def setup_button(): if not GPIO_AVAILABLE: return GPIO.setmode(GPIO.BCM) GPIO.setup(BUTTON_PIN, GPIO.IN, pull_up_down=GPIO.PUD_UP) # falling edge (button to ground), debounce 200ms GPIO.add_event_detect(BUTTON_PIN, GPIO.FALLING, callback=button_pressed_callback, bouncetime=200) def cleanup_button(): if not GPIO_AVAILABLE: return try: GPIO.remove_event_detect(BUTTON_PIN) except Exception: pass try: GPIO.cleanup(BUTTON_PIN) except Exception: pass # ----------------------------- # MAIN LOOP # ----------------------------- def main(): global current_tz_index, display_tz_until, display_tz_code print("Device:", device.width, "x", device.height, "Modules:", MODULES) if RUN_TEST_AT_START: run_quick_test() last_gps_time_str = None # "HHMMSS" string from last GPS last_gps_datetime_utc = None # datetime in UTC (date from system, time from GPS) last_sat_count = None setup_button() try: while True: # read a few lines to capture both sentence types for _ in range(6): line = read_gps_line() if not line: break t = parse_gprmc_time_hhmmss(line) if t: # store last GPS time string and build a datetime in UTC using today's date last_gps_time_str = t now_utc = datetime.utcnow() try: hh = int(t[0:2]); mm = int(t[2:4]); ss = int(t[4:6]) last_gps_datetime_utc = now_utc.replace(hour=hh, minute=mm, second=ss, microsecond=0) except Exception: last_gps_datetime_utc = now_utc.replace(microsecond=0) # attempt to set system time to GPS UTC time (so short locks still correct clock) ok = set_system_time_utc(t) if ok: print("System time set from GPS UTC:", t) else: print("Failed to set system time from GPS (need sudo privileges).") s = parse_gpgga_satellites(line) if s is not None: last_sat_count = s # Determine base UTC datetime for conversions: if last_gps_datetime_utc: base_utc = last_gps_datetime_utc # advance base_utc by elapsed seconds since it was captured to keep it ticking # compute how long since we set it (approx): use system UTC now and difference # This keeps seconds advancing even if GPS not continuously streaming # We'll compute offset between stored base and current UTC and add that offset # to the stored base to keep it in sync. # Simpler: use datetime.utcnow() as authoritative if system clock was set. base_utc = datetime.utcnow() else: # no GPS yet: use system UTC base_utc = datetime.utcnow() # If button pressed recently, show timezone code for 1 second now_ts = time.time() if display_tz_until > now_ts: # show timezone code centered for 1 second show_text_centered(display_tz_code) # small sleep to avoid busy loop while showing time.sleep(0.05) continue # compute timezone time using offset tz_code = TIMEZONES[current_tz_index] offset_hours = TZ_OFFSETS.get(tz_code, 0) tz_dt = base_utc + timedelta(hours=offset_hours) hh = f"{tz_dt.hour:02d}" mm = f"{tz_dt.minute:02d}" ss = f"{tz_dt.second:02d}" if last_sat_count is None: sat = "--" else: sat_val = max(0, min(99, int(last_sat_count))) sat = f"{sat_val:02d}" pairs = [hh, mm, ss, sat] show_pairs_per_module(pairs) time.sleep(1) except KeyboardInterrupt: clear_display() print("Exiting...") finally: cleanup_button() if gps is not None: try: gps.close() except: pass if __name__ == "__main__": main()