Skip to content

Commit

Permalink
Lots of work on the docs
Browse files Browse the repository at this point in the history
The API docs are about 80% complete. Should get all of that (with the
possible exception of calibrate.py) finished this holiday. The
application docs and tutorial really need finishing, but packaging work
should probably come first...
  • Loading branch information
waveform80 committed Dec 2, 2024
1 parent 5145975 commit 1763f1b
Show file tree
Hide file tree
Showing 31 changed files with 762 additions and 94 deletions.
6 changes: 3 additions & 3 deletions blinkenxmas/animations.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ def range_of(it):
Returns a (minimum, maximum) tuple for the range of values in *it*,
utilizing a single pass. This can be slightly more efficient that calling
:func:`min` and :func:`max` separately on *it* depending on its size.
However, it may also be more efficient to :func:`sort` *it* and simply
access the first and last element, depending on circumstance.
However, it may also be more efficient to :meth:`~list.sort` *it* and
simply access the first and last element, depending on circumstance.
"""
min_ = max_ = None
for value in it:
Expand All @@ -52,7 +52,7 @@ def pairwise(it):
def preview(anim):
"""
On a true-color capable terminal, print a line per frame of the specified
*anim*.
*anim*. This is primarily intended as a debugging function.
"""
for frame in anim:
print(''.join(
Expand Down
13 changes: 12 additions & 1 deletion blinkenxmas/calibrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,23 @@

class PointNotFound(ValueError):
"""
Exception raised by the calibration engine when it cannot find an LED by
Exception raised by :class:`AngleScanner` when it cannot find an LED by
comparison to a base unlit image.
"""


def weighted_median(seq):
"""
Given *seq*, a sequence of (item, weight) tuples, return the (item, weight)
tuple at the 50th percentile of the cumulative weights. This function is
specifically coded to return the item at *or immediately after* the median
to ensure that the returned item is a member of the original set (not an
average of two values).
This is technically a special case of the `weighted median`_.
.. _weighted median: https://en.wikipedia.org/wiki/Weighted_median
"""
items = sorted(seq, key=itemgetter(0))
cum_weights = list(accumulate(weight for item, weight in items))
try:
Expand Down
66 changes: 62 additions & 4 deletions blinkenxmas/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,18 @@ class AbstractSource:
"""
An abstract camera source.
The *config* is the application configuration object.
The *config* is the application configuration object (a
:class:`~argparse.Namespace` instance).
.. attribute:: frame
The current preview frame's data. This is a :class:`bytes` string
containing the JPEG data of the frame.
.. attribute:: frame_ready
A :class:`~threading.Condition` which clients must wait upon to be
notified of a new JPEG available in :attr:`frame`.
"""
def __init__(self, config):
self._lock = Lock()
Expand All @@ -20,9 +31,8 @@ def __init__(self, config):

def start_preview(self, angle):
"""
Start a live preview from the camera, passing JPEG image frames to
the internal :meth:`_preview_frame` method. The *angle* is the current
angle of the tree.
Start a live preview from the camera, passing JPEG image frames to the
:attr:`frame` attribute. The *angle* is the current angle of the tree.
"""
raise NotImplementedError

Expand All @@ -47,13 +57,23 @@ def _preview_frame(self, frame):
self.frame_ready.notify_all()

def add_client(self, client):
"""
Called to add *client* (a :class:`~http.server.BaseHTTPRequestHandler`
instance) to the list of clients wanting to receive live preview frames
from the camera.
"""
with self._lock:
if not self._clients:
angle = int(client.query.get('angle', '0'))
self.start_preview(angle)
self._clients.append(client)

def remove_client(self, client):
"""
Called to remove *client* (a
:class:`~http.server.BaseHTTPRequestHandler` instance) from the list of
clients wanting to receive live preview frames from the camera.
"""
with self._lock:
try:
self._clients.remove(client)
Expand All @@ -64,6 +84,22 @@ def remove_client(self, client):


class FilesSource(AbstractSource):
"""
This "camera" is primarily intended for testing purposes. It is implemented
simple as a list of JPEG files which must conform to the following naming
convention:
:file:`angle{A}_base.jpg`
The base "unlit" image of the tree at the specified angle *n* (in
degrees), where *A* is zero-padded to three digits. For example
``angle090_base.jpg``.
:file:`angle{A}_led{L}.jpg`
The image of the tree at angle *A* (in degrees, zero-padded to three
digits) with LED at index *L* (zero-padded to three digits) lit bright
white. For example ``angle090_led049.jpg``.
"""

thread = None
lock = Lock()
stop = Event()
Expand Down Expand Up @@ -111,6 +147,10 @@ def capture(self, angle, led=None):


class PiCameraOutput:
"""
A :mod:`picamera` `custom output <Custom outputs>`_ used by
:class:`PiCameraSource` to route preview frames to clients.
"""
def __init__(self, source):
self.source = source
self.frame = io.BytesIO()
Expand All @@ -128,6 +168,15 @@ def flush(self):


class PiCameraSource(AbstractSource):
"""
A camera implementation that uses the legacy :mod:`picamera` library.
.. warning::
Be warned that this will only work with Raspberry Pi models up to the
4B (specifically *not* the Pi 5), and only with legacy 32-bit versions
of RaspiOS or Ubuntu.
"""
lock = Lock()
output = None

Expand Down Expand Up @@ -165,6 +214,15 @@ def capture(self, angle, led=None):


class GStreamerSource(AbstractSource):
"""
A camera implementation based on `GStreamer`_.
This is primarily intended for use with USB web-cams. However, be warned
that most USB web-cams have terrible quality compared to proper camera
modules. You are *far* better off using a Pi camera module.
.. _GStreamer: https://gstreamer.freedesktop.org/
"""
Gst = None
GstVideo = None

Expand Down
45 changes: 23 additions & 22 deletions blinkenxmas/cli.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
"""
The command line interface for the BlinkenXmas project. Provides a simple
command line which can load or delete an existing preset, set all LEDs to a
specific color, or set an individual LED to a specified color.
"""

import os
import sys
from queue import Queue
Expand All @@ -16,6 +10,10 @@


def get_cli_parser():
"""
Return an :class:`~blinkenxmas.config.ConfigArgumentParser` instance for
handling the options of :program:`bxcli`.
"""
config = get_config()
parser = get_parser(config, description=__doc__)
parser.set_defaults(func=do_help)
Expand All @@ -24,35 +22,30 @@ def get_cli_parser():

help_cmd = commands.add_parser(
'help',
description="With no arguments, display the list of sub-commands. "
"If a command name is given, display the description and options for "
"that command",
description=do_help.__doc__,
help="Display command help")
help_cmd.add_argument(
'cmd', nargs='?',
help="The name of the command to display help for")
help_cmd.set_defaults(func=do_help)

off_cmd = commands.add_parser(
'off', aliases=['clear'],
description="Switch all LEDs off",
help="Switch all LEDs off")
'off', aliases=['clear'], description=do_off.__doc__,
help=do_off.__doc__)
off_cmd.set_defaults(func=do_off)

on_cmd = commands.add_parser(
'on', aliases=['all'],
description="Switch all LEDs on with the specified color",
help="Switch all LEDs to the specified color")
'on', aliases=['all'], description=do_on.__doc__,
help=do_on.__doc__)
on_cmd.add_argument(
'color', type=Color,
help="The color to set all LEDs to; may be given as a common CSS3 "
"color name, or an HTML color code, i.e. #RRGGBB")
on_cmd.set_defaults(func=do_on)

set_cmd = commands.add_parser(
'set',
description="Switch on a single LED to the specified color",
help="Switch one LED on to the specified color")
'set', description=do_set.__doc__,
help=do_set.__doc__)
set_cmd.add_argument(
'number', type=int,
help="The number of the LED (from 1 to the number of LEDs) to light")
Expand All @@ -63,14 +56,12 @@ def get_cli_parser():
set_cmd.set_defaults(func=do_set)

list_cmd = commands.add_parser(
'list', aliases=['ls'],
description="List the names of all available presets",
'list', aliases=['ls'], description=do_list.__doc__,
help="List all presets")
list_cmd.set_defaults(func=do_list)

show_cmd = commands.add_parser(
'show', aliases=['load'],
description="Load and display the specified preset",
'show', aliases=['load'], description=do_show.__doc__,
help="Show a preset")
show_cmd.add_argument(
'preset',
Expand All @@ -83,6 +74,10 @@ def get_cli_parser():


def do_help(config, queue):
"""
With no arguments, display the list of sub-commands. If a command name is
given, display the description and options for that command
"""
parser = get_cli_parser()
if 'cmd' in config and config.cmd is not None:
parser.parse_args([config.cmd, '-h'])
Expand All @@ -91,14 +86,17 @@ def do_help(config, queue):


def do_off(config, queue):
"Switch all LEDs off"
queue.put([[]])


def do_on(config, queue):
"Switch all LEDs on with the specified color"
queue.put([[config.color.html] * config.led_count])


def do_set(config, queue):
"Switch on a single LED to the specified color"
black = Color('black')
queue.put([[
config.color.html if number == config.number else black
Expand All @@ -107,17 +105,20 @@ def do_set(config, queue):


def do_list(config, queue):
"List the names of all available presets"
store = Storage(config.db)
for preset in store.presets:
print(preset)


def do_show(config, queue):
"Load and display the specified preset"
store = Storage(config.db)
queue.put(store.presets[config.preset])


def main(args=None):
"Entry point for :program:`bxcli`"
try:
config = get_cli_parser().parse_args(args)
if config.led_count == 0:
Expand Down
23 changes: 20 additions & 3 deletions blinkenxmas/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ def strips(s):


def get_parser(config, **kwargs):
"""
Given *config*, a :class:`~configparser.ConfigParser` containing the stored
application configuration (presumably returned by :func:`get_config`), and
any keyword arguments that should be passed to the argument parser,
constructions and returns a :class:`ConfigArgumentParser` instance with the
command line parameters common to all the applications.
"""
parser = ConfigArgumentParser(**kwargs)
parser.add_argument(
'--version', action='version', version=version('blinkenxmas'))
Expand Down Expand Up @@ -260,9 +267,14 @@ def get_parser(config, **kwargs):


def get_config():
# Load the default configuration from the project resources, defining the
# valid sections and keys from the default (amalgamating the example leds
# sections into a template "leds:*" section)
"""
Load the default configuration from the project resources, defining the
valid sections and keys from the default (amalgamating the example leds
sections into a template "leds:\\*" section).
Returns a :class:`~configparser.ConfigParser` instance with the stored
configuration loaded.
"""
config = ConfigParser(
delimiters=('=',), empty_lines_in_values=False, interpolation=None,
converters={'list': lambda s: s.strip().splitlines()}, strict=False)
Expand Down Expand Up @@ -304,6 +316,11 @@ def get_config():


def get_pico_config(config):
"""
Given *config*, a :class:`~argparse.Namespace` instance containing the
active application configuration, returns a :class:`str` containing the
MicroPython code for the config.py module on the Pico.
"""
leds = [
(
config[section]['driver'],
Expand Down
13 changes: 8 additions & 5 deletions blinkenxmas/flash.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
"""
Flash the BlinkenXmas code to a Pico for use on an xmas tree.
"""

import os
import sys
import tempfile
Expand Down Expand Up @@ -31,12 +27,19 @@


def default_port():
"""
Attempts to determine the serial port on which the Pico is connected.
"""
for port in list_ports.comports():
if port.vid is not None and port.pid is not None:
return port.device


def get_flash_parser(config):
"""
Return an :class:`~blinkenxmas.config.ConfigArgumentParser` instance for
handling the options of :program:`bxflash`.
"""
parser = get_parser(config, description=__doc__)

parser.add_argument(
Expand All @@ -48,6 +51,7 @@ def get_flash_parser(config):


def main(args=None):
"Entry point for :program:`bxflash`."
try:
config = get_config()
options = get_flash_parser(config).parse_args(args)
Expand Down Expand Up @@ -94,4 +98,3 @@ def main(args=None):
pdb.post_mortem()
else:
return 0

Loading

0 comments on commit 1763f1b

Please sign in to comment.