Make program installable with setuptools.

This commit is contained in:
Tony DiCola
2015-02-10 19:43:02 -08:00
parent e095906241
commit a1da408897
10 changed files with 354 additions and 3 deletions

View File

@@ -0,0 +1,2 @@
# Import main program.
from .video_looper import main

View File

@@ -0,0 +1,35 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
class DirectoryReader(object):
def __init__(self, config):
"""Create an instance of a file reader that just reads a single
directory on disk.
"""
self._load_config(config)
def _load_config(self, config):
self._path = config.get('directory', 'path')
def search_paths(self):
"""Return a list of paths to search for files."""
return [self._path]
def is_changed(self):
"""Return true if the file search paths have changed."""
# For now just return false and assume the path never changes. In the
# future it might be interesting to watch for file changes and return
# true if new files are added/removed from the directory. This is
# called in a tight loop of the main program so it needs to be fast and
# not resource intensive.
return False
def idle_message(self):
"""Return a message to display when idle and no files are found."""
return 'No files found in {0}'.format(self._path)
def create_file_reader(config):
"""Create new file reader based on reading a directory on disk."""
return DirectoryReader(config)

View File

@@ -0,0 +1,69 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import os
import subprocess
import time
class HelloVideoPlayer(object):
def __init__(self, config):
"""Create an instance of a video player that runs hello_video.bin in the
background.
"""
self._process = None
self._load_config(config)
def _load_config(self, config):
self._extensions = config.get('hello_video', 'extensions') \
.translate(None, ' \t\r\n.') \
.split(',')
def supported_extensions(self):
"""Return list of supported file extensions."""
return self._extensions
def play(self, movie, loop=False):
"""Play the provided movied file, optionally looping it repeatedly."""
self.stop(3) # Up to 3 second delay to let the old player stop.
# Assemble list of arguments.
args = ['hello_video.bin']
if loop:
args.append('--loop') # Add loop parameter if necessary.
args.append(movie) # Add movie file path.
# Run hello_video process and direct standard output to /dev/null.
self._process = subprocess.Popen(args,
stdout=open(os.devnull, 'wb'),
close_fds=True)
def is_playing(self):
"""Return true if the video player is running, false otherwise."""
if self._process is None:
return False
self._process.poll()
return self._process.returncode is None
def stop(self, block_timeout_sec=None):
"""Stop the video player. block_timeout_sec is how many seconds to
block waiting for the player to stop before moving on.
"""
# Stop the player if it's running.
if self._process is not None and self._process.returncode is None:
# process.kill() doesn't seem to work reliably if USB drive is
# removed, instead just run a kill -9 on it.
subprocess.call(['kill', '-9', str(self._process.pid)])
# If a blocking timeout was specified, wait up to that amount of time
# for the process to stop.
start = time.time()
while self._process is not None and self._process.returncode is None:
if (time.time() - start) >= block_timeout_sec:
break
time.sleep(0)
# Let the process be garbage collected.
self._process = None
def create_player(config):
"""Create new video player based on hello_video."""
return HelloVideoPlayer(config)

View File

@@ -0,0 +1,31 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
class Playlist(object):
"""Representation of a playlist of movies."""
def __init__(self, movies):
"""Create a playlist from the provided list of movies."""
self._movies = movies
self._index = None
def get_next(self):
"""Get the next movie in the playlist. Will loop to start of playlist
after reaching end.
"""
# Check if no movies are in the playlist and return nothing.
if len(self._movies) == 0:
return None
# Start at the first movie and increment through them in order.
if self._index is None:
self._index = 0
else:
self._index += 1
# Wrap around to the start after finishing.
if self._index >= len(self._movies):
self._index = 0
return self._movies[self._index]
def length(self):
"""Return the number of movies in the playlist."""
return len(self._movies)

View File

@@ -0,0 +1,74 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import os
import subprocess
import time
class OMXPlayer(object):
def __init__(self, config):
"""Create an instance of a video player that runs omxplayer in the
background.
"""
self._process = None
self._load_config(config)
def _load_config(self, config):
self._extensions = config.get('omxplayer', 'extensions') \
.translate(None, ' \t\r\n.') \
.split(',')
self._extra_args = config.get('omxplayer', 'extra_args').split()
self._sound = config.get('omxplayer', 'sound').lower()
assert self._sound in ('hdmi', 'local', 'both'), 'Unknown omxplayer sound configuration value: {0} Expected hdmi, local, or both.'.format(self._sound)
def supported_extensions(self):
"""Return list of supported file extensions."""
return self._extensions
def play(self, movie, loop=False):
"""Play the provided movied file, optionally looping it repeatedly."""
self.stop(3) # Up to 3 second delay to let the old player stop.
# Assemble list of arguments.
args = ['omxplayer']
args.extend(['-o', self._sound]) # Add sound arguments.
args.extend(self._extra_args) # Add extra arguments from config.
if loop:
args.append('--loop') # Add loop parameter if necessary.
args.append(movie) # Add movie file path.
# Run omxplayer process and direct standard output to /dev/null.
self._process = subprocess.Popen(args,
stdout=open(os.devnull, 'wb'),
close_fds=True)
def is_playing(self):
"""Return true if the video player is running, false otherwise."""
if self._process is None:
return False
self._process.poll()
return self._process.returncode is None
def stop(self, block_timeout_sec=None):
"""Stop the video player. block_timeout_sec is how many seconds to
block waiting for the player to stop before moving on.
"""
# Stop the player if it's running.
if self._process is not None and self._process.returncode is None:
# process.kill() doesn't seem to work reliably if USB drive is
# removed, instead just run a kill -9 on it.
subprocess.call(['kill', '-9', str(self._process.pid)])
# If a blocking timeout was specified, wait up to that amount of time
# for the process to stop.
start = time.time()
while self._process is not None and self._process.returncode is None:
if (time.time() - start) >= block_timeout_sec:
break
time.sleep(0)
# Let the process be garbage collected.
self._process = None
def create_player(config):
"""Create new video player based on omxplayer."""
return OMXPlayer(config)

View File

@@ -0,0 +1,46 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import glob
from usb_drive_mounter import USBDriveMounter
class USBDriveReader(object):
def __init__(self, config):
"""Create an instance of a file reader that uses the USB drive mounter
service to keep track of attached USB drives and automatically mount
them for reading videos.
"""
self._load_config(config)
self._mounter = USBDriveMounter(root=self._mount_path,
readonly=self._readonly)
self._mounter.start_monitor()
def _load_config(self, config):
self._mount_path = config.get('usb_drive', 'mount_path')
self._readonly = config.getboolean('usb_drive', 'readonly')
def search_paths(self):
"""Return a list of paths to search for files. Will return a list of all
mounted USB drives.
"""
self._mounter.mount_all()
return glob.glob(self._mount_path + '*')
def is_changed(self):
"""Return true if the file search paths have changed, like when a new
USB drive is inserted.
"""
return self._mounter.poll_changes()
def idle_message(self):
"""Return a message to display when idle and no files are found."""
return 'Insert USB drive with compatible movies.'
def create_file_reader(config):
"""Create new file reader based on mounting USB drives."""
return USBDriveReader(config)

View File

@@ -0,0 +1,80 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import glob
import subprocess
import time
import pyudev
class USBDriveMounter(object):
"""Service for automatically mounting attached USB drives."""
def __init__(self, root='/mnt/usbdrive', readonly=False):
"""Create an instance of the USB drive mounter service. Root is an
optional parameter which specifies the location and file name prefix for
mounted drives (a number will be appended to each mounted drive file
name). Readonly is a boolean that indicates if the drives should be
mounted as read-only or not (default false, writable).
"""
self._root = root
self._readonly = readonly
self._context = pyudev.Context()
def remove_all(self):
"""Unmount and remove mount points for all mounted drives."""
for path in glob.glob(self._root + '*'):
subprocess.call(['umount', '-l', path])
subprocess.call(['rm', '-r', path])
def mount_all(self):
"""Mount all attached USB drives. Readonly is a boolean that specifies
if the drives should be mounted read only (defaults to false).
"""
self.remove_all()
# Enumerate USB drive partitions by path like /dev/sda1, etc.
nodes = [x.device_node for x in self._context.list_devices(subsystem='block',
DEVTYPE='partition') \
if 'ID_BUS' in x and x['ID_BUS'] == 'usb']
# Mount each drive under the mount root.
for i, node in enumerate(nodes):
path = self._root + str(i)
subprocess.call(['mkdir', path])
args = ['mount']
if self._readonly:
args.append('-r')
args.extend([node, path])
subprocess.check_call(args)
def start_monitor(self):
"""Initialize monitoring of USB drive changes."""
self._monitor = pyudev.Monitor.from_netlink(self._context)
self._monitor.filter_by('block', 'partition')
self._monitor.start()
def poll_changes(self):
"""Check for changes to USB drives. Returns true if there was a USB
drive change, otherwise false.
"""
# Look for a drive change.
device = self._monitor.poll(0)
# If a USB drive changed (added/remove) remount all drives.
if device is not None and device['ID_BUS'] == 'usb':
return True
# Else nothing changed.
return False
if __name__ == '__main__':
# Run as a service that mounts all USB drives as read-only under the default
# path of /mnt/usbdrive*.
drive_mounter = USBDriveMounter(readonly=True)
drive_mounter.mount_all()
drive_mounter.start_monitor()
print 'Listening for USB drive changes (press Ctrl-C to quite)...'
while True:
if drive_mounter.poll_changes():
print 'USB drives changed!'
drive_mounter.mount_all()
time.sleep(0)

View File

@@ -0,0 +1,218 @@
# Copyright 2015 Adafruit Industries.
# Author: Tony DiCola
# License: GNU GPLv2, see LICENSE.txt
import atexit
import ConfigParser
import importlib
import os
import re
import sys
import time
import pygame
from model import Playlist
# Basic video looper architecure:
#
# - VideoLooper class contains all the main logic for running the looper program.
#
# - Almost all state is configured in a .ini config file which is required for
# loading and using the VideoLooper class.
#
# - VideoLooper has loose coupling with file reader and video player classes that
# are used to find movie files and play videos respectively. The configuration
# defines which file reader and video player module will be loaded.
#
# - A file reader module needs to define at top level create_file_reader function
# that takes as a parameter a ConfigParser config object. The function should
# return an instance of a file reader class. See usb_drive.py and directory.py
# for the two provided file readers and their public interface.
#
# - Similarly a video player modules needs to define a top level create_player
# function that takes in configuration. See omxplayer.py and hello_video.py
# for the two provided video players and their public interface.
#
# - Future file readers and video players can be provided and referenced in the
# config to extend the video player use to read from different file sources
# or use different video players.
class VideoLooper(object):
def __init__(self, config_path):
"""Create an instance of the main video looper application class. Must
pass path to a valid video looper ini configuration file.
"""
# Load the configuration.
self._config = ConfigParser.SafeConfigParser()
if len(self._config.read(config_path)) == 0:
raise RuntimeError('Failed to find configuration file at {0}, is the application properly installed?'.format(config_path))
self._console_output = self._config.getboolean('video_looper', 'console_output')
# Load configured video player and file reader modules.
self._player = self._load_player()
atexit.register(self._player.stop) # Make sure to stop player on exit.
self._reader = self._load_file_reader()
# Load other configuration values.
self._osd = self._config.getboolean('video_looper', 'osd')
# Parse string of 3 comma separated values like "255, 255, 255" into
# list of ints for colors.
self._bgcolor = map(int, self._config.get('video_looper', 'bgcolor') \
.translate(None, ',') \
.split())
self._fgcolor = map(int, self._config.get('video_looper', 'fgcolor') \
.translate(None, ',') \
.split())
# Initialize pygame and display a blank screen.
pygame.display.init()
pygame.font.init()
pygame.mouse.set_visible(False)
size = (pygame.display.Info().current_w, pygame.display.Info().current_h)
self._screen = pygame.display.set_mode(size, pygame.FULLSCREEN)
self._blank_screen()
# Set other static internal state.
self._extensions = self._player.supported_extensions()
self._small_font = pygame.font.Font(None, 50)
self._big_font = pygame.font.Font(None, 250)
def _print(self, message):
"""Print message to standard output if console output is enabled."""
if self._console_output:
print(message)
def _load_player(self):
"""Load the configured video player and return an instance of it."""
module = self._config.get('video_looper', 'video_player')
return importlib.import_module('.' + module, 'Adafruit_Video_Looper') \
.create_player(self._config)
def _load_file_reader(self):
"""Load the configured file reader and return an instance of it."""
module = self._config.get('video_looper', 'file_reader')
return importlib.import_module('.' + module, 'Adafruit_Video_Looper') \
.create_file_reader(self._config)
def _build_playlist(self):
"""Search all the file reader paths for movie files with the provided
extensions.
"""
# Get list of paths to search from the file reader.
paths = self._reader.search_paths()
# Enumerate all movie files inside those paths.
movies = []
for ex in self._extensions:
for path in paths:
# Skip paths that don't exist or are files.
if not os.path.exists(path) or not os.path.isdir(path):
continue
movies.extend(['{0}/{1}'.format(path.rstrip('/'), x) \
for x in os.listdir(path) \
if re.search('\.{0}$'.format(ex), x,
flags=re.IGNORECASE)])
# Create a playlist with the sorted list of movies.
return Playlist(sorted(movies))
def _blank_screen(self):
"""Render a blank screen filled with the background color."""
self._screen.fill(self._bgcolor)
pygame.display.update()
def _render_text(self, message, font=None):
"""Draw the provided message and return as pygame surface of it rendered
with the configured foreground and background color.
"""
# Default to small font if not provided.
if font is None:
font = self._small_font
return font.render(message, True, self._fgcolor, self._bgcolor)
def _animate_countdown(self, playlist, seconds=10):
"""Print text with the number of loaded movies and a quick countdown
message if the on screen display is enabled.
"""
# Print message to console with number of movies in playlist.
message = 'Found {0} movie{1}.'.format(playlist.length(),
's' if playlist.length() >= 2 else '')
self._print(message)
# Do nothing else if the OSD is turned off.
if not self._osd:
return
# Draw message with number of movies loaded and animate countdown.
# First render text that doesn't change and get static dimensions.
label1 = self._render_text(message + ' Starting playback in:')
l1w, l1h = label1.get_size()
sw, sh = self._screen.get_size()
for i in range(seconds, 0, -1):
# Each iteration of the countdown rendering changing text.
label2 = self._render_text(str(i), self._big_font)
l2w, l2h = label2.get_size()
# Clear screen and draw text with line1 above line2 and all
# centered horizontally and vertically.
self._screen.fill(self._bgcolor)
self._screen.blit(label1, (sw/2-l1w/2, sh/2-l2h/2-l1h))
self._screen.blit(label2, (sw/2-l2w/2, sh/2-l2h/2))
pygame.display.update()
# Pause for a second between each frame.
time.sleep(1)
def _idle_message(self):
"""Print idle message from file reader."""
# Print message to console.
message = self._reader.idle_message()
self._print(message)
# Do nothing else if the OSD is turned off.
if not self._osd:
return
# Display idle message in center of screen.
label = self._render_text(message)
lw, lh = label.get_size()
sw, sh = self._screen.get_size()
self._screen.fill(self._bgcolor)
self._screen.blit(label, (sw/2-lw/2, sh/2-lh/2))
pygame.display.update()
def _prepare_to_run_playlist(self, playlist):
"""Display messages when a new playlist is loaded."""
# If there are movies to play show a countdown first (if OSD enabled),
# or if no movies are available show the idle message.
if playlist.length() > 0:
self._animate_countdown(playlist)
self._blank_screen()
else:
self._idle_message()
def run(self):
"""Main program loop. Will never return!"""
# Get playlist of movies to play from file reader.
playlist = self._build_playlist()
self._prepare_to_run_playlist(playlist)
# Main loop to play videos in the playlist and listen for file changes.
while True:
# Load and play a new movie if nothing is playing.
if not self._player.is_playing():
movie = playlist.get_next()
if movie is not None:
# Start playing the first available movie.
self._print('Playing movie: {0}'.format(movie))
self._player.play(movie, loop=playlist.length() == 1)
# Check for changes in the file search path (like USB drives added)
# and rebuild the playlist.
if self._reader.is_changed():
self._player.stop(3) # Up to 3 second delay waiting for old
# player to stop.
# Rebuild playlist and show countdown again (if OSD enabled).
playlist = self._build_playlist()
self._prepare_to_run_playlist(playlist)
# Give the CPU some time to do other tasks.
time.sleep(0)
# Main entry point.
def main():
# Default config path to /boot.
config_path = '/boot/video_looper.ini'
# Override config path if provided as parameter.
if len(sys.argv) == 2:
config_path = sys.argv[1]
# Create video looper and run it.
videolooper = VideoLooper(config_path)
videolooper.run()