#!/usr/bin/env python # # This is a project to drive a 8x32 (or 8x8) LED matrix based on the # popular WS2812 RGB LEDs using a microcontroller (e.g. a Teensy 3.x # or a Pycom module with 4MB RAM) and optionally control them both # using a more powerful host computer, such as a Raspberry Pi Zero W. # # -- noah@hack.se, 2018 # import sys import time from math import ceil if hasattr(sys,'implementation') and sys.implementation.name == 'micropython': pycom_board = True import ujson as json import machine from network import WLAN # Local imports from pycomhal import PycomHAL else: pycom_board = False # Emulate https://docs.pycom.io/firmwareapi/micropython/utime.html time.ticks_ms = lambda: int(time.time() * 1000) time.sleep_ms = lambda x: time.sleep(x/1000.0) import json import os import sys import signal # Local imports from arduinoserialhal import ArduinoSerialHAL # Local imports from ledmatrix import LedMatrix from clockscene import ClockScene class RenderLoop: def __init__(self, display = None, fps=10): self.display = display self.fps = fps self.t_next_frame = None self.prev_frame = 0 self.frame = 1 self.t_init = time.ticks_ms() / 1000.0 self.debug = 1 self.scenes = [] self.scene_index = 0 self.scene_switch_effect = 0 self.reset_scene_switch_counter() def reset_scene_switch_counter(self): """ Reset counter used to automatically switch scenes. The counter is decreased in .next_frame() """ self.scene_switch_countdown = 45 * self.fps def add_scene(self, scene): """ Add new scene to the render loop """ self.scenes.append(scene) def next_scene(self): """ Transition to a new scene and re-initialize the scene """ print('RenderLoop: next_scene: transitioning scene') # Fade out current scene effect = self.scene_switch_effect self.scene_switch_effect = (effect + 1) % 3 if effect == 0: self.display.dissolve() elif effect == 1: self.display.fade() else: self.display.scrollout() self.scene_index += 1 if self.scene_index == len(self.scenes): self.scene_index = 0 i = self.scene_index print('RenderLoop: next_scene: selected {}'.format(self.scenes[i].__class__.__name__)) # (Re-)initialize scene self.scenes[i].reset() def next_frame(self, button_pressed=0): """ Display next frame, possibly after a delay to ensure we meet the FPS target """ scene = self.scenes[self.scene_index] if button_pressed: # Let the scene handle input if scene.input(0, button_pressed): # The scene handled the input itself so ignore it button_pressed = 0 t_now = time.ticks_ms() / 1000.0 - self.t_init if not self.t_next_frame: self.t_next_frame = t_now delay = self.t_next_frame - t_now if delay >= 0: # Wait until we can display next frame x = time.ticks_ms() / 1000.0 time.sleep_ms(int(1000 * delay)) x = time.ticks_ms() / 1000.0 - x if x-delay > 0.01: print('RenderLoop: WARN: Overslept when sleeping for {}s, slept {}s more'.format(delay, round(x-delay, 6))) else: if self.debug: print('RenderLoop: WARN: FPS {} might be too high, {}s behind and missed {} frames'.format(self.fps, -delay, round(-delay*self.fps, 2))) # Resynchronize t_diff = self.fps * (t_now-self.t_next_frame)/self.fps - delay if self.debug: print('RenderLoop: Should have rendered frame {} at {} but was {}s late'.format(self.frame, self.t_next_frame, t_diff)) t_diff += 1./self.fps self.frame += int(round(self.fps * t_diff)) self.t_next_frame += t_diff if self.debug: print('RenderLoop: Will instead render frame {} at {}'.format(self.frame, self.t_next_frame)) if self.debug: print('RenderLoop: Rendering frame {}, next frame at {}'.format(self.frame, round(self.t_next_frame+1./self.fps, 4))) # Render current scene t = time.ticks_ms() / 1000.0 loop_again = scene.render(self.frame, self.frame - self.prev_frame - 1, self.fps) t = time.ticks_ms() / 1000.0 - t if t > 0.1: print('RenderLoop: WARN: Spent {}s rendering'.format(t)) self.scene_switch_countdown -= 1 if button_pressed or not loop_again or not self.scene_switch_countdown: self.reset_scene_switch_counter() if not loop_again: print('RenderLoop: scene "{}" signalled completion'.format(self.scenes[self.scene_index].__class__.__name__)) else: print('RenderLoop: forcefully switching scenes (button: {}, timer: {}'.format(button_pressed, self.scene_switch_countdown)) # Transition to next scene self.next_scene() # Account for time wasted above t_new = time.ticks_ms() / 1000.0 - self.t_init t_diff = t_new - t_now frames_wasted = ceil(t_diff * self.fps) #print('RenderLoop: setup: scene switch took {}s, original t {}s, new t {}s, spent {} frames'.format(t_diff, t_now,t_new, self.fps*t_diff)) self.frame += int(frames_wasted) self.t_next_frame += frames_wasted / self.fps self.prev_frame = self.frame self.frame += 1 self.t_next_frame += 1./self.fps def sigint_handler(sig, frame): """ Clear display when the program is terminated by Ctrl-C or SIGTERM """ global driver driver.clear_display() driver.set_auto_time(True) sys.exit(0) if __name__ == '__main__': f = open('config.json') config = json.loads(f.read()) f.close() # Initialize HAL if pycom_board: # We're running under MCU here print('WLAN: Connecting') wlan = WLAN(mode=WLAN.STA) wlan.connect(config['ssid'], auth=(WLAN.WPA2, config['password'])) while not wlan.isconnected(): machine.idle() # save power while waiting time.sleep(1) print('WLAN: Connected with IP: {}'.format(wlan.ifconfig()[0])) driver = PycomHAL(config) else: # We're running on the host computer here ports = [ '/dev/tty.usbmodem575711', # Teensy 3.x on macOS '/dev/tty.usbserial-DQ008J7R', # Pycom device on macOS '/dev/ttyUSB0', # Linux '/dev/ttyACM0', # Linux ] for port in ports: if os.path.exists(port): break driver = ArduinoSerialHAL(config) driver.set_rtc(time.time() + config['tzOffsetSeconds']) driver.set_auto_time(False) # Trap Ctrl-C and service termination signal.signal(signal.SIGINT, sigint_handler) signal.signal(signal.SIGTERM, sigint_handler) # Initialize led matrix framebuffer on top of HAL num_leds = 256 rows = 8 cols = num_leds // rows display = LedMatrix(driver, cols, rows, rotation=0) driver.clear_display() if pycom_board: # If we're running on the MCU then loop forever while True: driver.serial_loop(display) # This is where it all begins r = RenderLoop(display, fps=10) scene = ClockScene(display, config['ClockScene']) r.add_scene(scene) from weatherscene import WeatherScene scene = WeatherScene(display, config['WeatherScene']) r.add_scene(scene) from animationscene import AnimationScene scene = AnimationScene(display, config['AnimationScene']) r.add_scene(scene) # Render scenes forever while True: button_pressed = 0 while True: # Drain output from MCU and detect button presses line = driver.readline() if not line: break event = line.strip() if event == 'BUTTON_SHRT_PRESS': button_pressed = 1 elif event == 'BUTTON_LONG_PRESS': button_pressed = 2 else: print('MCU: {}'.format(event)) r.next_frame(button_pressed) if button_pressed: button_state = 0