diff --git a/rewrite/data/__init__.py b/rewrite/data/__init__.py new file mode 100644 index 00000000..e594aeba --- /dev/null +++ b/rewrite/data/__init__.py @@ -0,0 +1,11 @@ +from data.schedule import Schedule + +class Data: + + def __init__(self, screen_queue): + self._screen_queue = screen_queue + + self.schedule = Schedule() + + def request_next_screen(self, screen): + self._screen_queue.put(screen) diff --git a/rewrite/data/game.py b/rewrite/data/game.py new file mode 100644 index 00000000..ef3d31cf --- /dev/null +++ b/rewrite/data/game.py @@ -0,0 +1,18 @@ +from data.status import Status + +class Game: + + @staticmethod + def from_schedule(game_data): + game = Game(game_data) + + if game.update(True) == Status.SUCCESS: + return game + + return None + + def __init__(self, data): + self._data = data + + self.id = data["game_id"] + diff --git a/rewrite/data/schedule.py b/rewrite/data/schedule.py new file mode 100644 index 00000000..d88a42ed --- /dev/null +++ b/rewrite/data/schedule.py @@ -0,0 +1,38 @@ +import datetime, statsapi, time + +from utils import logger as ScoreboardLogger + +from data.status import Status +from data.game import Game + +class Schedule: + + REFRESH_RATE = 5 * 60 # minutes + + def __init__(self): + # Contains a list of parsed game objects + self.games = [] + # Cached request from statsapi + self._games = [] + + self.update() + + def update(self): + self.last_update = time.time() + + ScoreboardLogger.log(f"Updating schedule for {datetime.datetime.today()}") + + try: + self.__fetch_updated_schedule(datetime.datetime.today().strftime("%Y-%m-%d")) + except Exception as exception: + ScoreboardLogger.exception("Networking error while refreshing schedule!") + ScoreboardLogger.exception(exception) + + return Status.FAIL + + return Status.SUCCESS + + def __fetch_updated_schedule(self, date): + self._games = statsapi.schedule(date.strftime("%Y-%m-%d")) + + self.games = [Game.from_schedule(game) for game in self._games] diff --git a/rewrite/data/status.py b/rewrite/data/status.py new file mode 100644 index 00000000..9e1654bd --- /dev/null +++ b/rewrite/data/status.py @@ -0,0 +1,14 @@ +from enum import Enum + + +class Status(Enum): + SUCCESS = 2 + DEFERRED = 1 + FAIL = 0 + + +def ok(status): + return status in [Status.SUCCESS, Status.DEFERRED] + +def fail(status): + return status in [Status.FAIL] diff --git a/rewrite/driver/__init__.py b/rewrite/driver/__init__.py new file mode 100644 index 00000000..5685e40d --- /dev/null +++ b/rewrite/driver/__init__.py @@ -0,0 +1,50 @@ +import sys + +from utils import args +from driver.mode import DriverMode + +class DriverWrapper: + def __init__(self): + self.hardware_load_failed = False + self.mode = None + + if args().emulated: + self.set_mode(DriverMode.SOFTWARE_EMULATION) + else: + self.set_mode(DriverMode.HARDWARE) + + + @property + def __name__(self): + return 'driver' + + def is_hardware(self): + return self.mode == DriverMode.HARDWARE + + def is_emulated(self): + return self.mode == DriverMode.SOFTWARE_EMULATION + + def set_mode(self, mode): + self.mode = mode + + if self.is_hardware(): + try: + import rgbmatrix + + self.driver = rgbmatrix + except ImportError: + import RGBMatrixEmulator + + self.mode = DriverMode.SOFTWARE_EMULATION + self.driver = RGBMatrixEmulator + self.hardware_load_failed = True + else: + import RGBMatrixEmulator + + self.driver = RGBMatrixEmulator + + def __getattr__(self, name): + return getattr(self.driver, name) + + +sys.modules['driver'] = DriverWrapper() diff --git a/rewrite/driver/mode.py b/rewrite/driver/mode.py new file mode 100644 index 00000000..5cfe61fc --- /dev/null +++ b/rewrite/driver/mode.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class DriverMode(Enum): + HARDWARE = 0 + SOFTWARE_EMULATION = 1 diff --git a/rewrite/main.py b/rewrite/main.py new file mode 100644 index 00000000..e38bf495 --- /dev/null +++ b/rewrite/main.py @@ -0,0 +1,58 @@ +import statsapi, sys, threading + +from queue import PriorityQueue + +from version import SCRIPT_NAME, SCRIPT_VERSION + +from utils import args, led_matrix_options +from utils import logger as ScoreboardLogger + +from screens.screen_manager import ScreenManager + +from data import Data + +import driver +from driver import RGBMatrix, __version__ + + +statsapi_version = tuple(map(int, statsapi.__version__.split("."))) +if statsapi_version < (1, 5, 1): + ScoreboardLogger.error("We require MLB-StatsAPI 1.5.1 or higher. You may need to re-run install.sh") + sys.exit(1) +elif statsapi_version < (1, 6, 1): + ScoreboardLogger.warning("We recommend MLB-StatsAPI 1.6.1 or higher. You may want to re-run install.sh") + + +def main(matrix): + #TODO: Configure matrix dimensions + ScoreboardLogger.info(f"{SCRIPT_NAME} - v#{SCRIPT_VERSION} (32x32)") + + canvas = matrix.CreateFrameCanvas() + screen_queue = PriorityQueue(10) + + render_thread = threading.Thread( + target=ScreenManager.start, + args=[matrix, canvas, screen_queue], + name="render_thread", + daemon=True + ) + + render_thread.start() + + while render_thread.is_alive(): + pass + + +if __name__ == "__main__": + command_line_args = args() + matrix_options = led_matrix_options(command_line_args) + + matrix = RGBMatrix(options=matrix_options) + + try: + main(matrix) + except: + ScoreboardLogger.exception("Untrapped error in main!") + sys.exit(1) + finally: + matrix.Clear() diff --git a/rewrite/screens/__init__.py b/rewrite/screens/__init__.py new file mode 100644 index 00000000..ad6fa307 --- /dev/null +++ b/rewrite/screens/__init__.py @@ -0,0 +1,7 @@ +from enum import Enum + +class Screen(Enum): + CLOCK = 1 + WEATHER = 2 + +from screens.screen_manager import ScreenManager diff --git a/rewrite/screens/base.py b/rewrite/screens/base.py new file mode 100644 index 00000000..a858fa46 --- /dev/null +++ b/rewrite/screens/base.py @@ -0,0 +1,38 @@ +from datetime import datetime as dt + +class ScreenBase(): + + def __init__(self, manager): + self._manager = manager + + self.start_time = None + self.duration = 0 + + def request_next_screen(self, screen): + self._manager.queue.put(screen) + + def track_duration(fn): + def wrapper(self, *args, **kwargs): + if self.start_time is None: + self.start_time = dt.now() + + fn(self, *args, **kwargs) + + self.duration = (dt.now() - self.start_time).total_seconds() * 1000 + + return wrapper + + def render(self): + raise NotImplementedError("Subclasses must implement render()") + + @track_duration + def _render(self): + self.render() + + @property + def matrix(self): + return self._manager.matrix + + @property + def canvas(self): + return self._manager.canvas diff --git a/rewrite/screens/clock.py b/rewrite/screens/clock.py new file mode 100644 index 00000000..d3c4c3e7 --- /dev/null +++ b/rewrite/screens/clock.py @@ -0,0 +1,30 @@ +import os, time + +from driver import graphics + +from screens import Screen +from screens.base import ScreenBase + + +class ClockScreen(ScreenBase): + + MAX_DURATION_SECONDS = 3 + + def __init__(self, *args): + ScreenBase.__init__(self, *args) + + def render(self): + time_format_str = "%H:%M%p" + time_text = time.strftime(time_format_str) + + font_paths = ["../assets/fonts/patched", "../submodules/matrix/fonts"] + for font_path in font_paths: + path = f"{font_path}/4x6.bdf" + if os.path.isfile(path): + font = graphics.Font() + font.LoadFont(path) + + graphics.DrawText(self.canvas, font, 5, 5, (255, 255, 255), time_text) + + if self.duration > self.MAX_DURATION_SECONDS * 1000: + self.request_next_screen(Screen.WEATHER) diff --git a/rewrite/screens/screen_manager.py b/rewrite/screens/screen_manager.py new file mode 100644 index 00000000..dfb15cfb --- /dev/null +++ b/rewrite/screens/screen_manager.py @@ -0,0 +1,37 @@ +from screens import Screen +from screens.base import ScreenBase +from screens.clock import ClockScreen +from screens.weather import WeatherScreen + +class ScreenManager(): + + SCREENS = { + Screen.CLOCK: ClockScreen, + Screen.WEATHER: WeatherScreen + } + + @classmethod + def start(cls, matrix, canvas, queue): + ScreenManager(matrix, canvas, queue).__start() + + def __init__(self, matrix, canvas, queue): + self.matrix = matrix + self.canvas = canvas + self.queue = queue + self.screen = WeatherScreen(self) + self.priority = "normal" # TODO + + def __start(self): + while True: + if not self.queue.empty(): + screen = self.queue.get() + screen_class = self.SCREENS.get(screen, None) + + if issubclass(screen_class, ScreenBase): + self.screen = screen_class(self) + # TODO: This could be a transition of some kind + self.canvas.Clear() + + self.screen._render() + + self.canvas = self.matrix.SwapOnVSync(self.canvas) diff --git a/rewrite/screens/weather.py b/rewrite/screens/weather.py new file mode 100644 index 00000000..9619d250 --- /dev/null +++ b/rewrite/screens/weather.py @@ -0,0 +1,29 @@ +import os + +from driver import graphics + +from screens import Screen +from screens.base import ScreenBase + + +class WeatherScreen(ScreenBase): + + MAX_DURATION_SECONDS = 3 + + def __init__(self, *args): + ScreenBase.__init__(self, *args) + + def render(self): + weather_text = "It's weathery" + + font_paths = ["../assets/fonts/patched", "../submodules/matrix/fonts"] + for font_path in font_paths: + path = f"{font_path}/4x6.bdf" + if os.path.isfile(path): + font = graphics.Font() + font.LoadFont(path) + + graphics.DrawText(self.canvas, font, 0, 10, (255, 255, 255), weather_text) + + if self.duration > self.MAX_DURATION_SECONDS * 1000: + self.request_next_screen(Screen.CLOCK) diff --git a/rewrite/utils/__init__.py b/rewrite/utils/__init__.py new file mode 100644 index 00000000..e36d94a5 --- /dev/null +++ b/rewrite/utils/__init__.py @@ -0,0 +1,169 @@ +import argparse + +from utils import logger as ScoreboardLogger + +def args(): + parser = argparse.ArgumentParser() + + # Options for the rpi-rgb-led-matrix library + parser.add_argument( + "--led-rows", + action="store", + help="Display rows. 16 for 16x32, 32 for 32x32. (Default: 32)", + default=32, + type=int, + ) + parser.add_argument( + "--led-cols", action="store", help="Panel columns. Typically 32 or 64. (Default: 32)", default=32, type=int + ) + parser.add_argument("--led-chain", action="store", help="Daisy-chained boards. (Default: 1)", default=1, type=int) + parser.add_argument( + "--led-parallel", + action="store", + help="For Plus-models or RPi2: parallel chains. 1..3. (Default: 1)", + default=1, + type=int, + ) + parser.add_argument( + "--led-pwm-bits", action="store", help="Bits used for PWM. Range 1..11. (Default: 11)", default=11, type=int + ) + parser.add_argument( + "--led-brightness", + action="store", + help="Sets brightness level. Range: 1..100. (Default: 100)", + default=100, + type=int, + ) + parser.add_argument( + "--led-gpio-mapping", + help="Hardware Mapping: regular, adafruit-hat, adafruit-hat-pwm", + choices=["regular", "adafruit-hat", "adafruit-hat-pwm"], + type=str, + ) + parser.add_argument( + "--led-scan-mode", + action="store", + help="Progressive or interlaced scan. 0 = Progressive, 1 = Interlaced. (Default: 1)", + default=1, + choices=range(2), + type=int, + ) + parser.add_argument( + "--led-pwm-lsb-nanoseconds", + action="store", + help="Base time-unit for the on-time in the lowest significant bit in nanoseconds. (Default: 130)", + default=130, + type=int, + ) + parser.add_argument( + "--led-show-refresh", action="store_true", help="Shows the current refresh rate of the LED panel." + ) + parser.add_argument( + "--led-slowdown-gpio", + action="store", + help="Slow down writing to GPIO. Range: 0..4. (Default: 1)", + choices=range(5), + type=int, + ) + parser.add_argument("--led-no-hardware-pulse", action="store", help="Don't use hardware pin-pulse generation.") + parser.add_argument( + "--led-rgb-sequence", + action="store", + help="Switch if your matrix has led colors swapped. (Default: RGB)", + default="RGB", + type=str, + ) + parser.add_argument( + "--led-pixel-mapper", action="store", help='Apply pixel mappers. e.g "Rotate:90"', default="", type=str + ) + parser.add_argument( + "--led-row-addr-type", + action="store", + help="0 = default; 1 = AB-addressed panels; 2 = direct row select; 3 = ABC-addressed panels. (Default: 0)", + default=0, + type=int, + choices=[0, 1, 2, 3], + ) + parser.add_argument( + "--led-multiplexing", + action="store", + help="Multiplexing type: 0 = direct; 1 = strip; 2 = checker; 3 = spiral; 4 = Z-strip; 5 = ZnMirrorZStripe;" + "6 = coreman; 7 = Kaler2Scan; 8 = ZStripeUneven. (Default: 0)", + default=0, + type=int, + ) + parser.add_argument( + "--led-limit-refresh", + action="store", + help="Limit refresh rate to this frequency in Hz. Useful to keep a constant refresh rate on loaded system. " + "0=no limit. Default: 0", + default=0, + type=int, + ) + parser.add_argument( + "--led-pwm-dither-bits", action="store", help="Time dithering of lower bits (Default: 0)", default=0, type=int, + ) + parser.add_argument( + "--config", + action="store", + help="Base file name for config file. Can use relative path, e.g. config/rockies.config", + default="config", + type=str, + ) + parser.add_argument( + "--emulated", + action="store_const", + help="Force using emulator mode over default matrix display.", + const=True + ) + return parser.parse_args() + + +def led_matrix_options(args): + from driver import RGBMatrixOptions + + options = RGBMatrixOptions() + + if args.led_gpio_mapping is not None: + options.hardware_mapping = args.led_gpio_mapping + + options.rows = args.led_rows + options.cols = args.led_cols + options.chain_length = args.led_chain + options.parallel = args.led_parallel + options.row_address_type = args.led_row_addr_type + options.multiplexing = args.led_multiplexing + options.pwm_bits = args.led_pwm_bits + options.brightness = args.led_brightness + options.scan_mode = args.led_scan_mode + options.pwm_lsb_nanoseconds = args.led_pwm_lsb_nanoseconds + options.led_rgb_sequence = args.led_rgb_sequence + + try: + options.pixel_mapper_config = args.led_pixel_mapper + except AttributeError: + ScoreboardLogger.warning("Your compiled RGB Matrix Library is out of date.") + ScoreboardLogger.warning("The --led-pixel-mapper argument will not work until it is updated.") + + try: + options.pwm_dither_bits = args.led_pwm_dither_bits + except AttributeError: + ScoreboardLogger.warning("Your compiled RGB Matrix Library is out of date.") + ScoreboardLogger.warning("The --led-pwm-dither-bits argument will not work until it is updated.") + + try: + options.limit_refresh_rate_hz = args.led_limit_refresh + except AttributeError: + ScoreboardLogger.warning("Your compiled RGB Matrix Library is out of date.") + ScoreboardLogger.warning("The --led-limit-refresh argument will not work until it is updated.") + + if args.led_show_refresh: + options.show_refresh_rate = 1 + + if args.led_slowdown_gpio is not None: + options.gpio_slowdown = args.led_slowdown_gpio + + if args.led_no_hardware_pulse: + options.disable_hardware_pulsing = True + + return options diff --git a/rewrite/utils/logger.py b/rewrite/utils/logger.py new file mode 100644 index 00000000..ab10b485 --- /dev/null +++ b/rewrite/utils/logger.py @@ -0,0 +1,14 @@ +import logging + +logger = logging.getLogger("mlbled") +fmter = logging.Formatter("{levelname} ({asctime}): {message}", style="{", datefmt="%H:%M:%S") +strmhdl = logging.StreamHandler() +strmhdl.setFormatter(fmter) +logger.addHandler(strmhdl) +logger.propagate = False + +info = logger.info +warning = logger.warning +error = logger.error +log = logger.debug +exception = logger.exception diff --git a/rewrite/version.py b/rewrite/version.py new file mode 100644 index 00000000..bc71e0dc --- /dev/null +++ b/rewrite/version.py @@ -0,0 +1,6 @@ +SCRIPT_NAME = "MLB LED Scoreboard" +SCRIPT_VERSION = "9.0.0" + + +if __name__ == "__main__": + print(f"{SCRIPT_NAME} v{SCRIPT_VERSION}")