initial commit

This commit is contained in:
Paolo Asperti 2018-04-16 22:08:38 +02:00
commit 12ba988bd3
29 changed files with 2156 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
packages/
*.apk
.on-save.json

26
APKBUILD Normal file
View File

@ -0,0 +1,26 @@
# Contributor: Paolo Asperti <paolo@asperti.com>
# Maintainer: Paolo Asperti <paolo@asperti.com>
pkgname=openpdu-libs
pkgver=0.1.0
pkgrel=1
pkgdesc="OpenPDU project - misc python libraries"
url="https://github.com/openpdu/openpdu-libs"
arch="noarch"
license="GPL2"
depends="python"
makedepends=""
subpackages=""
source=""
options="!check"
build() {
:
}
package() {
mkdir -p "$pkgdir"
# create directory tree
install -Dd usr "$pkgdir"/usr
cp -r usr "$pkgdir"/
}

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
"""
OLED display driver for SSD1306, SSD1325, SSD1331 and SH1106 devices.
"""
__version__ = '1.5.0'

Binary file not shown.

View File

@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
class common(object):
DISPLAYOFF = 0xAE
DISPLAYON = 0xAF
DISPLAYALLON = 0xA5
DISPLAYALLON_RESUME = 0xA4
NORMALDISPLAY = 0xA6
INVERTDISPLAY = 0xA7
SETREMAP = 0xA0
SETMULTIPLEX = 0xA8
SETCONTRAST = 0x81
class ssd1306(common):
CHARGEPUMP = 0x8D
COLUMNADDR = 0x21
COMSCANDEC = 0xC8
COMSCANINC = 0xC0
EXTERNALVCC = 0x1
MEMORYMODE = 0x20
PAGEADDR = 0x22
SETCOMPINS = 0xDA
SETDISPLAYCLOCKDIV = 0xD5
SETDISPLAYOFFSET = 0xD3
SETHIGHCOLUMN = 0x10
SETLOWCOLUMN = 0x00
SETPRECHARGE = 0xD9
SETSEGMENTREMAP = 0xA1
SETSTARTLINE = 0x40
SETVCOMDETECT = 0xDB
SWITCHCAPVCC = 0x2
sh1106 = ssd1306
class ssd1331(common):
ACTIVESCROLLING = 0x2F
CLOCKDIVIDER = 0xB3
CONTINUOUSSCROLLINGSETUP = 0x27
DEACTIVESCROLLING = 0x2E
DISPLAYONDIM = 0xAC
LOCKMODE = 0xFD
MASTERCURRENTCONTROL = 0x87
NORMALDISPLAY = 0xA4
PHASE12PERIOD = 0xB1
POWERSAVEMODE = 0xB0
SETCOLUMNADDR = 0x15
SETCONTRASTA = 0x81
SETCONTRASTB = 0x82
SETCONTRASTC = 0x83
SETDISPLAYOFFSET = 0xA2
SETDISPLAYSTARTLINE = 0xA1
SETMASTERCONFIGURE = 0xAD
SETPRECHARGESPEEDA = 0x8A
SETPRECHARGESPEEDB = 0x8B
SETPRECHARGESPEEDC = 0x8C
SETPRECHARGEVOLTAGE = 0xBB
SETROWADDR = 0x75
SETVVOLTAGE = 0xBE
class ssd1325(common):
SETCOLUMNADDR = 0x15
SETROWADDR = 0x75
SETCURRENT = 0x84
SETSTARTLINE = 0xA1
SETOFFSET = 0xA2
NORMALDISPLAY = 0xA4
DISPLAYALLOFF = 0xA6
MASTERCONFIG = 0xAD
SETPRECHARGECOMPENABLE = 0xB0
SETPHASELEN = 0xB1
SETROWPERIOD = 0xB2
SETCLOCK = 0xB3
SETPRECHARGECOMP = 0xB4
SETGRAYTABLE = 0xB8
SETPRECHARGEVOLTAGE = 0xBC
SETVCOMLEVEL = 0xBE
SETVSL = 0xBF

Binary file not shown.

View File

@ -0,0 +1,410 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
# Example usage:
#
# from oled.serial import i2c, spi
# from oled.device import ssd1306, sh1106
# from oled.render import canvas
# from PIL import ImageDraw
#
# serial = i2c(port=1, address=0x3C)
# device = ssd1306(serial)
#
# with canvas(device) as draw:
# draw.rectangle(device.bounding_box, outline="white", fill="black")
# draw.text(30, 40, "Hello World", fill="white")
#
# As soon as the with-block scope level is complete, the graphics primitives
# will be flushed to the device.
#
# Creating a new canvas is effectively 'carte blanche': If you want to retain
# an existing canvas, then make a reference like:
#
# c = canvas(device)
# for X in ...:
# with c as draw:
# draw.rectangle(...)
#
# As before, as soon as the with block completes, the canvas buffer is flushed
# to the device
import atexit
from oled.serial import i2c
import oled.mixin as mixin
import oled.error
import oled.const
class device(mixin.capabilities):
"""
Base class for OLED driver classes
.. warning::
Direct use of the :func:`command` and :func:`data` methods are
discouraged: Screen updates should be effected through the
:func:`display` method, or preferably with the
:class:`oled.render.canvas` context manager.
"""
def __init__(self, const=None, serial_interface=None):
self._const = const or oled.const.common
self._serial_interface = serial_interface or i2c()
atexit.register(self.cleanup)
def command(self, *cmd):
"""
Sends a command or sequence of commands through to the delegated
serial interface.
"""
self._serial_interface.command(*cmd)
def data(self, data):
"""
Sends a data byte or sequence of data bytes through to the delegated
serial interface.
"""
self._serial_interface.data(data)
def show(self):
"""
Sets the display mode ON, waking the device out of a prior
low-power sleep mode.
"""
self.command(self._const.DISPLAYON)
def hide(self):
"""
Switches the display mode OFF, putting the device in low-power
sleep mode.
"""
self.command(self._const.DISPLAYOFF)
def contrast(self, level):
"""
Switches the display contrast to the desired level, in the range
0-255. Note that setting the level to a low (or zero) value will
not necessarily dim the display to nearly off. In other words,
this method is **NOT** suitable for fade-in/out animation.
:param level: Desired contrast level in the range of 0-255.
:type level: int
"""
assert(level >= 0)
assert(level <= 255)
self.command(self._const.SETCONTRAST, level)
def cleanup(self):
self.hide()
self.clear()
self._serial_interface.cleanup()
class sh1106(device):
"""
Encapsulates the serial interface to the monochrome SH1106 OLED display
hardware. On creation, an initialization sequence is pumped to the display
to properly configure it. Further control commands can then be called to
affect the brightness and other settings.
"""
def __init__(self, serial_interface=None, width=128, height=64, rotate=0):
super(sh1106, self).__init__(oled.const.sh1106, serial_interface)
self.capabilities(width, height, rotate)
self._pages = self._h // 8
# FIXME: Delay doing anything here with alternate screen sizes
# until we are able to get a device to test with.
if width != 128 or height != 64:
raise oled.error.DeviceDisplayModeError(
"Unsupported display mode: {0} x {1}".format(width, height))
self.command(
self._const.DISPLAYOFF,
self._const.MEMORYMODE,
self._const.SETHIGHCOLUMN, 0xB0, 0xC8,
self._const.SETLOWCOLUMN, 0x10, 0x40,
self._const.SETSEGMENTREMAP,
self._const.NORMALDISPLAY,
self._const.SETMULTIPLEX, 0x3F,
self._const.DISPLAYALLON_RESUME,
self._const.SETDISPLAYOFFSET, 0x00,
self._const.SETDISPLAYCLOCKDIV, 0xF0,
self._const.SETPRECHARGE, 0x22,
self._const.SETCOMPINS, 0x12,
self._const.SETVCOMDETECT, 0x20,
self._const.CHARGEPUMP, 0x14)
self.contrast(0x7F)
self.clear()
self.show()
def display(self, image):
"""
Takes a 1-bit :py:mod:`PIL.Image` and dumps it to the SH1106
OLED display.
"""
assert(image.mode == self.mode)
assert(image.size == self.size)
image = self.preprocess(image)
set_page_address = 0xB0
image_data = image.getdata()
pixels_per_page = self.width * 8
buf = bytearray(self.width)
for y in range(0, int(self._pages * pixels_per_page), pixels_per_page):
self.command(set_page_address, 0x02, 0x10)
set_page_address += 1
offsets = [y + self.width * i for i in range(8)]
for x in range(self.width):
buf[x] = \
(image_data[x + offsets[0]] and 0x01) | \
(image_data[x + offsets[1]] and 0x02) | \
(image_data[x + offsets[2]] and 0x04) | \
(image_data[x + offsets[3]] and 0x08) | \
(image_data[x + offsets[4]] and 0x10) | \
(image_data[x + offsets[5]] and 0x20) | \
(image_data[x + offsets[6]] and 0x40) | \
(image_data[x + offsets[7]] and 0x80)
self.data(list(buf))
class ssd1306(device):
"""
Encapsulates the serial interface to the monochrome SSD1306 OLED display
hardware. On creation, an initialization sequence is pumped to the display
to properly configure it. Further control commands can then be called to
affect the brightness and other settings.
"""
def __init__(self, serial_interface=None, width=128, height=64, rotate=0):
super(ssd1306, self).__init__(oled.const.ssd1306, serial_interface)
self.capabilities(width, height, rotate)
self._pages = self._h // 8
self._buffer = [0] * self._w * self._pages
self._offsets = [n * self._w for n in range(8)]
# Supported modes
settings = {
(128, 64): dict(multiplex=0x3F, displayclockdiv=0x80, compins=0x12),
(128, 32): dict(multiplex=0x1F, displayclockdiv=0x80, compins=0x02),
(96, 16): dict(multiplex=0x0F, displayclockdiv=0x60, compins=0x02)
}.get((width, height))
if settings is None:
raise oled.error.DeviceDisplayModeError(
"Unsupported display mode: {0} x {1}".format(width, height))
self.command(
self._const.DISPLAYOFF,
self._const.SETDISPLAYCLOCKDIV, settings['displayclockdiv'],
self._const.SETMULTIPLEX, settings['multiplex'],
self._const.SETDISPLAYOFFSET, 0x00,
self._const.SETSTARTLINE,
self._const.CHARGEPUMP, 0x14,
self._const.MEMORYMODE, 0x00,
self._const.SETREMAP,
self._const.COMSCANDEC,
self._const.SETCOMPINS, settings['compins'],
self._const.SETPRECHARGE, 0xF1,
self._const.SETVCOMDETECT, 0x40,
self._const.DISPLAYALLON_RESUME,
self._const.NORMALDISPLAY)
self.contrast(0xCF)
self.clear()
self.show()
def display(self, image):
"""
Takes a 1-bit :py:mod:`PIL.Image` and dumps it to the SSD1306
OLED display.
"""
assert(image.mode == self.mode)
assert(image.size == self.size)
image = self.preprocess(image)
self.command(
# Column start/end address
self._const.COLUMNADDR, 0x00, self._w - 1,
# Page start/end address
self._const.PAGEADDR, 0x00, self._pages - 1)
w = self._w
pix = list(image.getdata())
step = w * 8
buf = self._buffer
os0, os1, os2, os3, os4, os5, os6, os7 = self._offsets
j = 0
for y in range(0, self._pages * step, step):
i = y + w - 1
while i >= y:
buf[j] = \
(0x01 if pix[i] > 0 else 0) | \
(0x02 if pix[i + os1] > 0 else 0) | \
(0x04 if pix[i + os2] > 0 else 0) | \
(0x08 if pix[i + os3] > 0 else 0) | \
(0x10 if pix[i + os4] > 0 else 0) | \
(0x20 if pix[i + os5] > 0 else 0) | \
(0x40 if pix[i + os6] > 0 else 0) | \
(0x80 if pix[i + os7] > 0 else 0)
i -= 1
j += 1
self.data(buf)
class ssd1331(device):
"""
Encapsulates the serial interface to the 16-bit color (5-6-5 RGB) SSD1331
OLED display hardware. On creation, an initialization sequence is pumped to
the display to properly configure it. Further control commands can then be
called to affect the brightness and other settings.
"""
def __init__(self, serial_interface=None, width=96, height=64, rotate=0):
super(ssd1331, self).__init__(oled.const.ssd1331, serial_interface)
self.capabilities(width, height, rotate, mode="RGB")
self._buffer = [0] * self._w * self._h * 2
if width != 96 or height != 64:
raise oled.error.DeviceDisplayModeError(
"Unsupported display mode: {0} x {1}".format(width, height))
self.command(
self._const.DISPLAYOFF,
self._const.SETREMAP, 0x72,
self._const.SETDISPLAYSTARTLINE, 0x00,
self._const.SETDISPLAYOFFSET, 0x00,
self._const.NORMALDISPLAY,
self._const.SETMULTIPLEX, 0x3F,
self._const.SETMASTERCONFIGURE, 0x8E,
self._const.POWERSAVEMODE, 0x0B,
self._const.PHASE12PERIOD, 0x74,
self._const.CLOCKDIVIDER, 0xD0,
self._const.SETPRECHARGESPEEDA, 0x80,
self._const.SETPRECHARGESPEEDB, 0x80,
self._const.SETPRECHARGESPEEDC, 0x80,
self._const.SETPRECHARGEVOLTAGE, 0x3E,
self._const.SETVVOLTAGE, 0x3E,
self._const.MASTERCURRENTCONTROL, 0x0F)
self.contrast(0xFF)
self.clear()
self.show()
def display(self, image):
"""
Takes a 24-bit RGB :py:mod:`PIL.Image` and dumps it to the SSD1331 OLED
display.
"""
assert(image.mode == self.mode)
assert(image.size == self.size)
image = self.preprocess(image)
self.command(
self._const.SETCOLUMNADDR, 0x00, self._w - 1,
self._const.SETROWADDR, 0x00, self._h - 1)
i = 0
buf = self._buffer
for r, g, b in image.getdata():
# 65K format 1
buf[i] = r & 0xF8 | g >> 5
buf[i + 1] = g << 5 & 0xE0 | b >> 3
i += 2
self.data(buf)
def contrast(self, level):
"""
Switches the display contrast to the desired level, in the range
0-255. Note that setting the level to a low (or zero) value will
not necessarily dim the display to nearly off. In other words,
this method is **NOT** suitable for fade-in/out animation.
:param level: Desired contrast level in the range of 0-255.
:type level: int
"""
assert(level >= 0)
assert(level <= 255)
self.command(self._const.SETCONTRASTA, level,
self._const.SETCONTRASTB, level,
self._const.SETCONTRASTC, level)
class ssd1325(device):
"""
Encapsulates the serial interface to the 4-bit greyscale SSD1325 OLED
display hardware. On creation, an initialization sequence is pumped to the
display to properly configure it. Further control commands can then be
called to affect the brightness and other settings.
"""
def __init__(self, serial_interface=None, width=128, height=64, rotate=0):
super(ssd1325, self).__init__(oled.const.ssd1325, serial_interface)
self.capabilities(width, height, rotate, mode="RGB")
self._buffer = [0] * (self._w * self._h // 2)
if width != 128 or height != 64:
raise oled.error.DeviceDisplayModeError(
"Unsupported display mode: {0} x {1}".format(width, height))
self.command(
self._const.DISPLAYOFF,
self._const.SETCLOCK, 0xF1,
self._const.SETMULTIPLEX, 0x3F,
self._const.SETOFFSET, 0x4C,
self._const.SETSTARTLINE, 0x00,
self._const.MASTERCONFIG, 0x02,
self._const.SETREMAP, 0x50,
self._const.SETCURRENT + 2,
self._const.SETGRAYTABLE, 0x01, 0x11, 0x22, 0x32, 0x43, 0x54, 0x65, 0x76)
self.contrast(0xFF)
self.command(
self._const.SETROWPERIOD, 0x51,
self._const.SETPHASELEN, 0x55,
self._const.SETPRECHARGECOMP, 0x02,
self._const.SETPRECHARGECOMPENABLE, 0x28,
self._const.SETVCOMLEVEL, 0x1C,
self._const.SETVSL, 0x0F,
self._const.NORMALDISPLAY)
self.clear()
self.show()
def display(self, image):
"""
Takes a 24-bit RGB :py:mod:`PIL.Image` and dumps it to the SSD1325 OLED
display, converting the image pixels to 4-bit greyscale using a
simplified Luma calculation, based on *Y'=0.299R'+0.587G'+0.114B'*.
"""
assert(image.mode == self.mode)
assert(image.size == self.size)
image = self.preprocess(image)
self.command(
self._const.SETCOLUMNADDR, 0x00, self._w - 1,
self._const.SETROWADDR, 0x00, self._h - 1)
i = 0
buf = self._buffer
for r, g, b in image.getdata():
# RGB->Greyscale luma calculation into 4-bits
grey = (r * 306 + g * 601 + b * 117) >> 14
if i % 2 == 0:
buf[i // 2] = grey
else:
buf[i // 2] |= (grey << 4)
i += 1
self.data(buf)

Binary file not shown.

View File

@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
import os
import sys
import atexit
import logging
from PIL import Image
from oled.device import device
from oled.serial import noop
logger = logging.getLogger(__name__)
class emulator(device):
"""
Base class for emulated OLED driver classes
"""
def __init__(self, width, height, rotate, mode, transform, scale):
super(emulator, self).__init__(serial_interface=noop())
try:
import pygame
except:
raise RuntimeError("Emulator requires pygame to be installed")
self._pygame = pygame
self.capabilities(width, height, rotate, mode)
self.scale = 1 if transform == "none" else scale
self._transform = getattr(transformer(pygame, width, height, scale),
"none" if scale == 1 else transform)
def cleanup(self):
pass
def to_surface(self, image):
"""
Converts a :py:mod:`PIL.Image` into a :class:`pygame.Surface`,
transforming it according to the ``transform`` and ``scale``
constructor arguments.
"""
im = image.convert("RGB")
mode = im.mode
size = im.size
data = im.tobytes()
del im
surface = self._pygame.image.fromstring(data, size, mode)
return self._transform(surface)
class capture(emulator):
"""
Pseudo-device that acts like an OLED display, except that it writes
the image to a numbered PNG file when the :func:`display` method
is called.
While the capability of an OLED device is monochrome, there is no
limitation here, and hence supports 24-bit color depth.
"""
def __init__(self, width=128, height=64, rotate=0, mode="RGB",
transform="scale2x", scale=2, file_template="oled_{0:06}.png",
**kwargs):
super(capture, self).__init__(width, height, rotate, mode, transform, scale)
self._count = 0
self._file_template = file_template
def display(self, image):
"""
Takes a :py:mod:`PIL.Image` and dumps it to a numbered PNG file.
"""
assert(image.size == self.size)
self._count += 1
filename = self._file_template.format(self._count)
image = self.preprocess(image)
surface = self.to_surface(image)
logger.debug("Writing: {0}".format(filename))
self._pygame.image.save(surface, filename)
class gifanim(emulator):
"""
Pseudo-device that acts like an OLED display, except that it collects
the images when the :func:`display` method is called, and on exit,
assembles them into an animated GIF image.
While the capability of an OLED device is monochrome, there is no
limitation here, and hence supports 24-bit color depth, albeit with
an indexed color palette.
"""
def __init__(self, width=128, height=64, rotate=0, mode="RGB",
transform="scale2x", scale=2, filename="oled_anim.gif",
duration=0.01, loop=0, max_frames=None, **kwargs):
super(gifanim, self).__init__(width, height, rotate, mode, transform, scale)
self._images = []
self._count = 0
self._max_frames = max_frames
self._filename = filename
self._loop = loop
self._duration = duration
atexit.register(self.write_animation)
def display(self, image):
"""
Takes an image, scales it according to the nominated transform, and
stores it for later building into an animated GIF.
"""
assert(image.size == self.size)
image = self.preprocess(image)
surface = self.to_surface(image)
rawbytes = self._pygame.image.tostring(surface, "RGB", False)
im = Image.frombytes("RGB", (self._w * self.scale, self._h * self.scale), rawbytes)
self._images.append(im)
self._count += 1
logger.debug("Recording frame: {0}".format(self._count))
if self._max_frames and self._count >= self._max_frames:
sys.exit(0)
def write_animation(self):
logger.debug("Please wait... building animated GIF")
with open(self._filename, "w+b") as fp:
self._images[0].save(fp, save_all=True, loop=self._loop,
duration=int(self._duration * 1000),
append_images=self._images[1:],
format="GIF")
logger.debug("Wrote {0} frames to file: {1} ({2} bytes)".format(
len(self._images), self._filename, os.stat(self._filename).st_size))
class pygame(emulator):
"""
Pseudo-device that acts like an OLED display, except that it renders
to an displayed window. The frame rate is limited to 60FPS (much faster
than a Raspberry Pi can acheive, but this can be overridden as necessary).
While the capability of an OLED device is monochrome, there is no
limitation here, and hence supports 24-bit color depth.
:mod:`pygame` is used to render the emulated display window, and it's
event loop is checked to see if the ESC key was pressed or the window
was dismissed: if so :func:`sys.exit()` is called.
"""
def __init__(self, width=128, height=64, rotate=0, mode="RGB", transform="scale2x",
scale=2, frame_rate=60, **kwargs):
super(pygame, self).__init__(width, height, rotate, mode, transform, scale)
self._pygame.init()
self._pygame.font.init()
self._pygame.display.set_caption("OLED Emulator")
self._clock = self._pygame.time.Clock()
self._fps = frame_rate
self._screen = self._pygame.display.set_mode((width * self.scale, height * self.scale))
self._screen.fill((0, 0, 0))
self._pygame.display.flip()
def _abort(self):
keystate = self._pygame.key.get_pressed()
return keystate[self._pygame.K_ESCAPE] or self._pygame.event.peek(self._pygame.QUIT)
def display(self, image):
"""
Takes a :py:mod:`PIL.Image` and renders it to a pygame display surface.
"""
assert(image.size == self.size)
image = self.preprocess(image)
self._clock.tick(self._fps)
self._pygame.event.pump()
if self._abort():
self._pygame.quit()
sys.exit()
surface = self.to_surface(image)
self._screen.blit(surface, (0, 0))
self._pygame.display.flip()
class dummy(emulator):
"""
Pseudo-device that acts like an OLED display, except that it does nothing
other than retain a copy of the displayed image. It is mostly useful for
testing. While the capability of an OLED device is monochrome, there is no
limitation here, and hence supports 24-bit color depth.
"""
def __init__(self, width=128, height=64, rotate=0, mode="RGB", transform="scale2x",
scale=2, **kwargs):
super(dummy, self).__init__(width, height, rotate, mode, transform, scale)
self.image = None
def display(self, image):
"""
Takes a :py:mod:`PIL.Image` and makes a copy of it for later
use/inspection.
"""
assert(image.size == self.size)
self.image = self.preprocess(image).copy()
class transformer(object):
"""
Helper class used to dispatch transformation operations.
"""
def __init__(self, pygame, width, height, scale):
self._pygame = pygame
self._output_size = (width * scale, height * scale)
self.scale = scale
def none(self, surface):
"""
No-op transform - used when ``scale`` = 1
"""
return surface
def scale2x(self, surface):
"""
Scales using the AdvanceMAME Scale2X algorithm which does a
'jaggie-less' scale of bitmap graphics.
"""
assert(self.scale == 2)
return self._pygame.transform.scale2x(surface)
def smoothscale(self, surface):
"""
Smooth scaling using MMX or SSE extensions if available
"""
return self._pygame.transform.smoothscale(surface, self._output_size)
def identity(self, surface):
"""
Fast scale operation that does not sample the results
"""
return self._pygame.transform.scale(surface, self._output_size)

Binary file not shown.

View File

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
"""
Exceptions for this library.
"""
class Error(Exception):
"""
Base class for exceptions in this library.
"""
pass
class DeviceNotFoundError(Error):
"""
Exception raised when a device cannot be found.
"""
class DevicePermissionError(Error):
"""
Exception raised when permission to access the device is denied.
"""
class DeviceAddressError(Error):
"""
Exception raised when an invalid device address is detected.
"""
class DeviceDisplayModeError(Error):
"""
Exception raised when an invalid device display mode is detected.
"""

Binary file not shown.

View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
from PIL import Image
class capabilities(object):
def capabilities(self, width, height, rotate, mode="1"):
assert mode in ("1", "RGB", "RGBA")
assert rotate in (0, 1, 2, 3)
self._w = width
self._h = height
self.width = width if rotate % 2 == 0 else height
self.height = height if rotate % 2 == 0 else width
self.size = (self.width, self.height)
self.bounding_box = (0, 0, self.width - 1, self.height - 1)
self.rotate = rotate
self.mode = mode
def clear(self):
"""
Initializes the device memory with an empty (blank) image.
"""
self.display(Image.new(self.mode, self.size))
def preprocess(self, image):
if self.rotate == 0:
return image
angle = self.rotate * -90
return image.rotate(angle, expand=True).crop((0, 0, self._w, self._h))
def display(self, image):
raise NotImplementedError()

Binary file not shown.

View File

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
from PIL import Image, ImageDraw
class canvas(object):
"""
A canvas returns a properly-sized :py:mod:`PIL.ImageDraw` object onto
which the caller can draw upon. As soon as the with-block completes, the
resultant image is flushed onto the device.
By default, any color (other than black) will be treated as white and
displayed on the device. However, this behaviour can be changed by adding
``dither=True`` and the image will be converted from RGB space into a 1-bit
monochrome image where dithering is employed to differentiate colors at the
expense of resolution.
"""
def __init__(self, device, dither=False):
self.draw = None
self.image = Image.new("RGB" if dither else device.mode, device.size)
self.device = device
self.dither = dither
def __enter__(self):
self.draw = ImageDraw.Draw(self.image)
return self.draw
def __exit__(self, type, value, traceback):
if type is None:
if self.dither:
self.image = self.image.convert(self.device.mode)
# do the drawing onto the device
self.device.display(self.image)
del self.draw # Tidy up the resources
return False # Never suppress exceptions

Binary file not shown.

View File

@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
import errno
import oled.error
class i2c(object):
"""
Wrap an `I2C <https://en.wikipedia.org/wiki/I%C2%B2C>`_ interface to
provide data and command methods.
:param bus: I2C bus instance.
:type bus:
:param port: I2C port number.
:type port: int
:param address: I2C address.
:type address:
:raises oled.error.DeviceAddressError: I2C device address is invalid.
:raises oled.error.DeviceNotFoundError: I2C device could not be found.
:raises oled.error.DevicePermissionError: Permission to access I2C device
denied.
.. note::
1. Only one of ``bus`` OR ``port`` arguments should be supplied;
if both are, then ``bus`` takes precedence.
2. If ``bus`` is provided, there is an implicit expectation
that it has already been opened.
"""
def __init__(self, bus=None, port=1, address=0x3C):
import smbus2
self._cmd_mode = 0x00
self._data_mode = 0x40
try:
self._addr = int(str(address), 0)
except ValueError:
raise oled.error.DeviceAddressError(
'I2C device address invalid: {}'.format(address))
try:
self._bus = bus or smbus2.SMBus(port)
except (IOError, OSError) as e:
if e.errno == errno.ENOENT:
# FileNotFoundError
raise oled.error.DeviceNotFoundError(
'I2C device not found: {}'.format(e.filename))
elif e.errno == errno.EPERM or e.errno == errno.EACCES:
# PermissionError
raise oled.error.DevicePermissionError(
'I2C device permission denied: {}'.format(e.filename))
else:
raise
def command(self, *cmd):
"""
Sends a command or sequence of commands through to the I2C address
- maximum allowed is 32 bytes in one go.
"""
assert(len(cmd) <= 32)
self._bus.write_i2c_block_data(self._addr, self._cmd_mode, list(cmd))
def data(self, data):
"""
Sends a data byte or sequence of data bytes through to the I2C
address - maximum allowed in one transaction is 32 bytes, so if
data is larger than this, it is sent in chunks.
"""
i = 0
n = len(data)
write = self._bus.write_i2c_block_data
while i < n:
write(self._addr, self._data_mode, list(data[i:i + 32]))
i += 32
def cleanup(self):
"""
Clean up I2C resources
"""
self._bus.close()
class spi(object):
"""
Wraps an `SPI <https://en.wikipedia.org/wiki/Serial_Peripheral_Interface_Bus>`_
interface to provide data and command methods.
* The DC pin (Data/Command select) defaults to GPIO 24 (BCM).
* The RST pin (Reset) defaults to GPIO 25 (BCM).
:raises oled.error.DeviceNotFoundError: SPI device could not be found.
"""
def __init__(self, spi=None, gpio=None, port=0, device=0,
bus_speed_hz=8000000, bcm_DC=24, bcm_RST=25):
self._gpio = gpio or self.__rpi_gpio__()
self._spi = spi or self.__spidev__()
try:
self._spi.open(port, device)
except (IOError, OSError) as e:
if e.errno == errno.ENOENT:
# FileNotFoundError
raise oled.error.DeviceNotFoundError('SPI device not found')
else:
raise
self._spi.max_speed_hz = bus_speed_hz
self._bcm_DC = bcm_DC
self._bcm_RST = bcm_RST
self._cmd_mode = self._gpio.LOW # Command mode = Hold low
self._data_mode = self._gpio.HIGH # Data mode = Pull high
self._gpio.setmode(self._gpio.BCM)
self._gpio.setup(self._bcm_DC, self._gpio.OUT)
self._gpio.setup(self._bcm_RST, self._gpio.OUT)
self._gpio.output(self._bcm_RST, self._gpio.HIGH) # Keep RESET pulled high
def __rpi_gpio__(self):
# RPi.GPIO _really_ doesn't like being run on anything other than
# a Raspberry Pi... this is imported here so we can swap out the
# implementation for a mock
import RPi.GPIO
return RPi.GPIO
def __spidev__(self):
# spidev cant compile on macOS, so use a similar technique to
# initialize (mainly so the tests run unhindered)
import spidev
return spidev.SpiDev()
def command(self, *cmd):
"""
Sends a command or sequence of commands through to the SPI device.
"""
self._gpio.output(self._bcm_DC, self._cmd_mode)
self._spi.xfer2(list(cmd))
def data(self, data):
"""
Sends a data byte or sequence of data bytes through to the SPI device.
If the data is more than 4Kb in size, it is sent in chunks.
"""
self._gpio.output(self._bcm_DC, self._data_mode)
i = 0
n = len(data)
write = self._spi.xfer2
while i < n:
write(data[i:i + 4096])
i += 4096
def cleanup(self):
"""
Clean up SPI & GPIO resources
"""
self._spi.close()
self._gpio.cleanup()
class noop(object):
"""
Does nothing, used for pseudo-devices / emulators, which dont have a serial
interface.
"""
def command(self, *cmd):
pass
def data(self, data):
pass
def cleanup(self):
pass

Binary file not shown.

View File

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
# Adapted from http://code.activestate.com/recipes/577187-python-thread-pool/
# Attribution: Created by Emilio Monti on Sun, 11 Apr 2010 (MIT License).
from threading import Thread
class worker(Thread):
"""
Thread executing tasks from a given tasks queue
"""
def __init__(self, tasks):
Thread.__init__(self)
self.tasks = tasks
self.daemon = True
self.start()
def run(self):
while True:
func, args, kargs = self.tasks.get()
try:
func(*args, **kargs)
except Exception as e:
print(e)
self.tasks.task_done()
class threadpool:
"""
Pool of threads consuming tasks from a queue
"""
def __init__(self, num_threads):
try:
from Queue import Queue
except ImportError:
from queue import Queue
self.tasks = Queue(num_threads)
for _ in range(num_threads):
worker(self.tasks)
def add_task(self, func, *args, **kargs):
"""
Add a task to the queue
"""
self.tasks.put((func, args, kargs))
def wait_completion(self):
"""
Wait for completion of all the tasks in the queue
"""
self.tasks.join()

View File

@ -0,0 +1,371 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2016 Richard Hull and contributors
# See LICENSE.rst for details.
import time
from PIL import Image, ImageDraw, ImageFont
import oled.mixin as mixin
from oled.threadpool import threadpool
pool = threadpool(4)
def calc_bounds(xy, entity):
"""
For an entity with width and height attributes, determine
the bounding box if were positioned at (x, y).
"""
left, top = xy
right, bottom = left + entity.width, top + entity.height
return [left, top, right, bottom]
def range_overlap(a_min, a_max, b_min, b_max):
"""
Neither range is completely greater than the other
"""
return (a_min < b_max) and (b_min < a_max)
class viewport(mixin.capabilities):
def __init__(self, device, width, height):
self.capabilities(width, height, rotate=0, mode=device.mode)
self._device = device
self._backing_image = Image.new(self.mode, self.size)
self._position = (0, 0)
self._hotspots = []
def display(self, image):
assert(image.mode == self.mode)
assert(image.size == self.size)
self._backing_image.paste(image)
self.refresh()
def set_position(self, xy):
self._position = xy
self.refresh()
def add_hotspot(self, hotspot, xy):
"""
Add the hotspot at (x, y). The hotspot must fit inside the bounds
of the virtual device. If it does not then an AssertError is raised.
"""
(x, y) = xy
assert(0 <= x <= self.width - hotspot.width)
assert(0 <= y <= self.height - hotspot.height)
# TODO: should it check to see whether hotspots overlap each other?
# Is sensible to _allow_ them to overlap?
self._hotspots.append((hotspot, xy))
def remove_hotspot(self, hotspot, xy):
"""
Remove the hotspot at (x, y): Any previously rendered image where the
hotspot was placed is erased from the backing image, and will be
"undrawn" the next time the virtual device is refreshed. If the
specified hotspot is not found (x, y), a ValueError is raised.
"""
self._hotspots.remove((hotspot, xy))
eraser = Image.new(self.mode, hotspot.size)
self._backing_image.paste(eraser, xy)
def is_overlapping_viewport(self, hotspot, xy):
"""
Checks to see if the hotspot at position (x, y)
is (at least partially) visible according to the
position of the viewport
"""
l1, t1, r1, b1 = calc_bounds(xy, hotspot)
l2, t2, r2, b2 = calc_bounds(self._position, self._device)
return range_overlap(l1, r1, l2, r2) and range_overlap(t1, b1, t2, b2)
def refresh(self):
should_wait = False
for hotspot, xy in self._hotspots:
if hotspot.should_redraw() and self.is_overlapping_viewport(hotspot, xy):
pool.add_task(hotspot.paste_into, self._backing_image, xy)
should_wait = True
if should_wait:
pool.wait_completion()
im = self._backing_image.crop(box=self._crop_box())
self._device.display(im)
del im
def _crop_box(self):
(left, top) = self._position
right = left + self._device.width
bottom = top + self._device.height
assert(0 <= left <= right <= self.width)
assert(0 <= top <= bottom <= self.height)
return (left, top, right, bottom)
class hotspot(mixin.capabilities):
"""
A hotspot (`a place of more than usual interest, activity, or popularity`)
is a live display which may be added to a virtual viewport - if the hotspot
and the viewport are overlapping, then the :func:`update` method will be
automatically invoked when the viewport is being refreshed or its position
moved (such that an overlap occurs).
You would either:
* create a ``hotspot`` instance, suppling a render function (taking an
:py:mod:`PIL.ImageDraw` object, ``width`` & ``height`` dimensions. The
render function should draw within a bounding box of (0, 0, width,
height), and render a full frame.
* sub-class ``hotspot`` and override the :func:``should_redraw`` and
:func:`update` methods. This might be more useful for slow-changing
values where it is not necessary to update every refresh cycle, or
your implementation is stateful.
"""
def __init__(self, width, height, draw_fn=None):
self.capabilities(width, height, rotate=0) # TODO: set mode?
self._fn = draw_fn
def paste_into(self, image, xy):
im = Image.new(image.mode, self.size)
draw = ImageDraw.Draw(im)
self.update(draw)
image.paste(im, xy)
del draw
del im
def should_redraw(self):
"""
Override this method to return true or false on some condition
(possibly on last updated member variable) so that for slow changing
hotspots they are not updated too frequently.
"""
return True
def update(self, draw):
if self._fn:
self._fn(draw, self.width, self.height)
class snapshot(hotspot):
"""
A snapshot is a `type of` hotspot, but only updates once in a given
interval, usually much less frequently than the viewport requests refresh
updates.
"""
def __init__(self, width, height, draw_fn=None, interval=1.0):
super(snapshot, self).__init__(width, height, draw_fn)
self.interval = interval
self.last_updated = 0.0
def should_redraw(self):
"""
Only requests a redraw after ``interval`` seconds have elapsed
"""
return time.time() - self.last_updated > self.interval
def paste_into(self, image, xy):
super(snapshot, self).paste_into(image, xy)
self.last_updated = time.time()
class terminal(object):
"""
Provides a terminal-like interface to a device (or a device-like object
that has :class:`mixin.capabilities` characteristics).
"""
def __init__(self, device, font=None, color="white", bgcolor="black", tabstop=4, line_height=None, animate=True):
self._device = device
self.font = font or ImageFont.load_default()
self.color = color
self.bgcolor = bgcolor
self.animate = animate
self.tabstop = tabstop
self._cw, self._ch = (0, 0)
for i in range(32, 128):
w, h = self.font.getsize(chr(i))
self._cw = max(w, self._cw)
self._ch = max(h, self._ch)
self._ch = line_height or self._ch
self.width = device.width // self._cw
self.height = device.height // self._ch
self.size = (self.width, self.height)
self._backing_image = Image.new(self._device.mode, self._device.size, self.bgcolor)
self._canvas = ImageDraw.Draw(self._backing_image)
self.clear()
def clear(self):
"""
Clears the display and resets the cursor position to (0, 0).
"""
self._cx, self._cy = (0, 0)
self._canvas.rectangle(self._device.bounding_box, fill=self.bgcolor)
self.flush()
def println(self, text=""):
"""
Prints the supplied text to the device, scrolling where necessary.
The text is always followed by a newline.
"""
self.puts(text)
self.newline()
def puts(self, text):
"""
Prints the supplied text, handling special character codes for carriage
return (\\r), newline (\\n), backspace (\\b) and tab (\\t).
If the ``animate`` flag was set to True (default), then each character
is flushed to the device, giving the effect of 1970's teletype device.
"""
for line in str(text).split("\n"):
for char in line:
if char == '\r':
self.carriage_return()
elif char == '\n':
self.newline()
elif char == '\b':
self.backspace()
elif char == '\t':
self.tab()
else:
self.putch(char, flush=self.animate)
def putch(self, ch, flush=True):
"""
Prints the specific character, which must be a valid printable ASCII
value in the range 32..127 only.
"""
assert(32 <= ord(ch) <= 127)
w = self.font.getsize(ch)[0]
if self._cx + w >= self._device.width:
self.newline()
self.erase()
self._canvas.text((self._cx, self._cy), text=ch, font=self.font, fill=self.color)
self._cx += w
if flush:
self.flush()
def carriage_return(self):
"""
Returns the cursor position to the left-hand side without advancing