Browse Source

first commit

Your Name 3 years ago
commit
6253ce8f9d
65 changed files with 4177 additions and 0 deletions
  1. 98 0
      CHANGES.rst
  2. 28 0
      CONTRIBUTING.rst
  3. 23 0
      LICENSE.rst
  4. 13 0
      MANIFEST.in
  5. 109 0
      README.rst
  6. 3 0
      doc/.gitignore
  7. 177 0
      doc/Makefile
  8. 32 0
      doc/api-documentation.rst
  9. 280 0
      doc/conf.py
  10. BIN
      doc/fritzing/3x 7segment.fzz
  11. 45 0
      doc/images/7-segment.svg
  12. BIN
      doc/images/7segment.jpg
  13. BIN
      doc/images/BL-M12A881.png
  14. BIN
      doc/images/IMG_2810.JPG
  15. BIN
      doc/images/block_reorientation.gif
  16. BIN
      doc/images/box_helloworld.jpg
  17. BIN
      doc/images/devices.jpg
  18. BIN
      doc/images/emulator.gif
  19. BIN
      doc/images/level-shifter.jpg
  20. BIN
      doc/images/matrix.jpg
  21. BIN
      doc/images/matrix_cascaded.jpg
  22. BIN
      doc/images/raspi-spi.png
  23. 36 0
      doc/index.rst
  24. 164 0
      doc/install.rst
  25. 33 0
      doc/intro.rst
  26. 39 0
      doc/notes.rst
  27. 478 0
      doc/python-usage.rst
  28. 11 0
      doc/references.rst
  29. BIN
      doc/tech-spec/APA102.pdf
  30. BIN
      doc/tech-spec/MAX7219.pdf
  31. BIN
      doc/tech-spec/TM1637.pdf
  32. BIN
      doc/tech-spec/WS2812.pdf
  33. BIN
      doc/tech-spec/WS2812B.pdf
  34. 56 0
      examples/apa102_demo.py
  35. 44 0
      examples/box_demo.py
  36. 41 0
      examples/issue_108.py
  37. 63 0
      examples/larson_hue.py
  38. 124 0
      examples/matrix_demo.py
  39. 16 0
      examples/neopixel_crawl.py
  40. 220 0
      examples/neopixel_demo.py
  41. 94 0
      examples/neosegment_demo.py
  42. 117 0
      examples/sevensegment_demo.py
  43. 81 0
      examples/silly_clock.py
  44. 41 0
      examples/view_message.py
  45. 8 0
      luma/__init__.py
  46. 10 0
      luma/led_matrix/__init__.py
  47. 13 0
      luma/led_matrix/const.py
  48. 624 0
      luma/led_matrix/device.py
  49. 130 0
      luma/led_matrix/segment_mapper.py
  50. 3 0
      pyproject.toml
  51. 3 0
      pytest.ini
  52. 59 0
      setup.cfg
  53. 7 0
      setup.py
  54. 24 0
      tests/baseline_data.py
  55. 55 0
      tests/helpers.py
  56. 18 0
      tests/reference/data/demo_unicornhathd.json
  57. 18 0
      tests/reference/data/demo_unicornhathd_alphablend.json
  58. BIN
      tests/reference/images/neosegment.png
  59. 53 0
      tests/test_apa102.py
  60. 226 0
      tests/test_max7219.py
  61. 93 0
      tests/test_neosegment.py
  62. 112 0
      tests/test_segment_mapper.py
  63. 60 0
      tests/test_unicornhathd.py
  64. 166 0
      tests/test_ws2812.py
  65. 29 0
      tox.ini

+ 98 - 0
CHANGES.rst

@@ -0,0 +1,98 @@
+ChangeLog
+---------
+
++------------+------------------------------------------------------------------------+------------+
+| Version    | Description                                                            | Date       |
++============+========================================================================+============+
+| **1.5.0**  | * Drop support for Python 2.7, only 3.5 or newer is supported now      | 2020/07/04 |
++------------+------------------------------------------------------------------------+------------+
+| **1.4.1**  | * Make ``contrast`` an optional constructor argument                   | 2019/12/08 |
++------------+------------------------------------------------------------------------+------------+
+| **1.4.0**  | * Rework namespace handling for luma sub-projects                      | 2019/06/16 |
++------------+------------------------------------------------------------------------+------------+
+| **1.3.1**  | * Fix alpha-channel blending for Unicorn Hat HD display                | 2019/05/26 |
++------------+------------------------------------------------------------------------+------------+
+| **1.3.0**  | * Add support for Pimoroni's Unicorn Hat HD                            | 2019/05/26 |
++------------+------------------------------------------------------------------------+------------+
+| **1.2.0**  | * Add option to control if 8x8 blocks are arranged in reverse order    | 2019/04/20 |
+|            | * Add (approximations of) more characters for 7-segment displa         |            |
+|            | * Documentation updates                                                |            |
++------------+------------------------------------------------------------------------+------------+
+| **1.1.1**  | * Fix unicode warning                                                  | 2018/09/26 |
++------------+------------------------------------------------------------------------+------------+
+| **1.1.0**  | * Add degree symbol to segment mapper charmap                          | 2018/09/18 |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.8**  | * Use DMA channel 10 (rather than ch. 5) for WS2812 NeoPixels          | 2018/01/23 |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.7**  | * Use ``extras_require`` in ``setup.py`` for ARM dependencies          | 2017/11/26 |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.6**  | * Version number available as ``luma.led_matrix.__version__`` now      | 2017/11/23 |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.5**  | * Conditionally install WS2812 packages if Linux/ARM7L only            | 2017/10/22 |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.4**  | * Make wheel universal                                                 | 2017/10/22 |
+|            | * Minor documentation fixes                                            |            |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.3**  | * Explicitly state 'UTF-8' encoding in setup when reading files        | 2017/10/18 |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.2**  | * Setup fails due to programmer not understanding basic Python ...     | 2017/08/05 |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.1**  | * Setup on Python 3 fails due to hyphen in package name                | 2017/08/05 |
++------------+------------------------------------------------------------------------+------------+
+| **1.0.0**  | * Stable release (remove all deprecated methods & parameters)          | 2017/07/30 |
++------------+------------------------------------------------------------------------+------------+
+| **0.11.1** | * Add Python3 compatibility for neopixels/neosegments                  | 2017/07/29 |
++------------+------------------------------------------------------------------------+------------+
+| **0.11.0** | * Alternative WS2812 low level implementation                          | 2017/07/21 |
+|            | * Add support for @msurguy's modular NeoSegments                       |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.10.1** | * Add block_orientation=180 option                                     | 2017/05/01 |
++------------+------------------------------------------------------------------------+------------+
+| **0.10.0** | * **BREAKING CHANGE:** Move sevensegment class to                      | 2017/04/22 |
+|            |   ``luma.core.virtual`` package                                        |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.9.0**  | * Add support for APA102 RGB neopixels                                 | 2017/03/30 |
++------------+------------------------------------------------------------------------+------------+
+| **0.8.0**  | * Change MAX7219's block_orientation to support ±90° angle correction  | 2017/03/19 |
+|            | * Deprecate "vertical" and "horizontal" block_orientation              |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.7.0**  | * **BREAKING CHANGE:** Move sevensegment class to                      | 2017/03/04 |
+|            |   ``luma.led_matrix.virtual`` package                                  |            |
+|            | * Documentation updates & corrections                                  |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.6.2**  | * Allow MAX7219 and NeoPixel driver constructors to accept any args    | 2017/03/02 |
++------------+------------------------------------------------------------------------+------------+
+| **0.6.1**  | * Restrict exported Python symbols from ``luma.led_matrix.device``     | 2017/03/02 |
++------------+------------------------------------------------------------------------+------------+
+| **0.6.0**  | * Add support for arbitrary MxN matrices rather than a single chain    | 2017/02/22 |
++------------+------------------------------------------------------------------------+------------+
+| **0.5.3**  | * Huge performance improvements for cascaded MAX7219 devices           | 2017/02/21 |
+|            | * Documentation updates                                                |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.5.2**  | * Add apostrophe representation to seven-segment display               | 2017/02/19 |
+|            | * Deprecate ``luma.led_matrix.legacy`` (moved to ``luma.core.legacy``) |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.4.4**  | * Support both common-row anode and common-row cathode LED matrices    | 2017/02/02 |
++------------+------------------------------------------------------------------------+------------+
+| **0.4.3**  | * Add translation mapping to accomodate Pimoroni's 8x8 Unicorn HAT     | 2017/01/29 |
+|            | * MAX7219 optimizations                                                |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.4.2**  | * Fix bug in neopixel initialization                                   | 2017/01/27 |
+|            | * Improved demo scripts                                                |            |
+|            | * Additional tests                                                     |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.4.0**  | * Add support for WS2812 NeoPixel strips/arrays                        | 2017/01/23 |
++------------+------------------------------------------------------------------------+------------+
+| **0.3.3**  | * Fix for dot muncher: not handling full-stop at line end              | 2017/01/21 |
+|            | * Documentation updates                                                |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.3.2**  | * Replace bytearray with ``mutable_string`` implementation             | 2017/01/20 |
+|            | * More tests                                                           |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.3.1**  | * Python 3 compatibility (fix exception in bytearray creation)         | 2017/01/20 |
+|            | * Begin to add tests & test infrastructure                             |            |
++------------+------------------------------------------------------------------------+------------+
+| **0.3.0**  | * **BREAKING CHANGE:** Package rename to ``luma.led_matrix``           | 2017/01/19 |
++------------+------------------------------------------------------------------------+------------+
+| **0.2.3**  | * Bit-bang version using wiringPi                                      | 2013/01/28 |
++------------+------------------------------------------------------------------------+------------+

+ 28 - 0
CONTRIBUTING.rst

@@ -0,0 +1,28 @@
+Contributing
+------------
+Pull requests (code changes / documentation / typos / feature requests / setup)
+are gladly accepted. If you are intending some large-scale changes, please get
+in touch first to make sure we're on the same page: try and include a docstring
+for any new methods, and try and keep method bodies small, readable and
+PEP8-compliant.
+
+GitHub
+^^^^^^
+The source code is available to clone at: http://github.com/rm-hull/luma.led_matrix
+
+Contributors
+^^^^^^^^^^^^
+* Thijs Triemstra (@thijstriemstra)
+* Jon Carlos (@webmonger)
+* Unattributed (@wkapga)
+* Taras (@tarasius)
+* Brice Parent (@agripo)
+* Thomas De Keulenaer (@twdkeule)
+* Tero Korpela (@terokorp)
+* Qinkang Huang (@pokebox)
+* Shawn Woodford (@swoodford)
+* Phil Howard (@gadgetoid)
+* Petr Kracík (@petrkr)
+* Emlyn Corrin (@emlyn)
+* Bram Verboom (@bramverb)
+* Thanassis Tsiodras (@ttsiodras)

+ 23 - 0
LICENSE.rst

@@ -0,0 +1,23 @@
+The MIT License (MIT)
+---------------------
+
+Copyright (c) 2013-2021 Richard Hull and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+

+ 13 - 0
MANIFEST.in

@@ -0,0 +1,13 @@
+include README.rst CHANGES.rst CONTRIBUTING.rst LICENSE.rst tox.ini setup.cfg pyproject.toml pytest.ini .coveragerc
+
+recursive-include luma *.py
+
+recursive-include doc *
+prune doc/_build
+
+recursive-exclude * __pycache__
+recursive-exclude * *.py[co]
+recursive-exclude * *~
+recursive-exclude * .coverage
+recursive-exclude * .DS_Store
+recursive-exclude * .ropeproject

+ 109 - 0
README.rst

@@ -0,0 +1,109 @@
+`luma.core <https://github.com/rm-hull/luma.core>`__ **|** 
+`luma.docs <https://github.com/rm-hull/luma.docs>`__ **|** 
+`luma.emulator <https://github.com/rm-hull/luma.emulator>`__ **|** 
+`luma.examples <https://github.com/rm-hull/luma.examples>`__ **|** 
+`luma.lcd <https://github.com/rm-hull/luma.lcd>`__ **|** 
+luma.led_matrix **|** 
+`luma.oled <https://github.com/rm-hull/luma.oled>`__ 
+
+Luma.LED_Matrix 
+===============
+**Display drivers for MAX7219, WS2812, APA102**
+
+.. image:: https://github.com/rm-hull/luma.led_matrix/workflows/luma.led_matrix/badge.svg?branch=master
+   :target: https://github.com/rm-hull/luma.led_matrix/actions?workflow=luma.led_matrix
+
+.. image:: https://coveralls.io/repos/github/rm-hull/luma.led_matrix/badge.svg?branch=master
+   :target: https://coveralls.io/github/rm-hull/luma.led_matrix?branch=master
+
+.. image:: https://readthedocs.org/projects/luma-led_matrix/badge/?version=latest
+   :target: http://luma-led-matrix.readthedocs.io/en/latest/?badge=latest
+
+.. image:: https://img.shields.io/pypi/pyversions/luma.led_matrix.svg
+   :target: https://pypi.python.org/pypi/luma.led_matrix
+
+.. image:: https://img.shields.io/pypi/v/luma.led_matrix.svg
+   :target: https://pypi.python.org/pypi/luma.led_matrix
+
+.. image:: https://img.shields.io/pypi/dm/luma.led_matrix
+   :target: https://pypi.python.org/project/luma.led_matrix
+
+.. image:: https://img.shields.io/maintenance/yes/2021.svg?maxAge=2592000
+
+Python 3 library interfacing LED matrix displays with the MAX7219 driver (using
+SPI), WS2812 (NeoPixels, inc Pimoroni Unicorn pHat/Hat and Unicorn Hat HD) and
+APA102 (DotStar) on the Raspberry Pi and other Linux-based single board computers
+- it provides a `Pillow <https://pillow.readthedocs.io/>`_-compatible drawing
+canvas, and other functionality to support:
+
+* multiple cascaded devices
+* LED matrix, seven-segment and NeoPixel variants
+* scrolling/panning capability,
+* terminal-style printing,
+* state management,
+* dithering to monochrome,
+* pygame emulator,
+* Python 3.5 and newer are supported
+
+Documentation
+-------------
+Full documentation with installation instructions and examples can be found on https://luma-led-matrix.readthedocs.io.
+
+.. image:: https://raw.githubusercontent.com/rm-hull/luma.led_matrix/master/doc/images/devices.jpg
+   :alt: max7219 matrix
+
+A LED matrix can be acquired for a few pounds from outlets
+like `Banggood <http://www.banggood.com/MAX7219-Dot-Matrix-Module-DIY-Kit-SCM-Control-Module-For-Arduino-p-72178.html?currency=GBP>`_.
+Likewise 7-segment displays are available from `Ali-Express
+<http://www.aliexpress.com/item/MAX7219-Red-Module-8-Digit-7-Segment-Digital-LED-Display-Tube-For-Arduino-MCU/1449630475.html>`_
+or `Ebay <http://www.ebay.com/itm/-/172317726225>`_.
+
+.. image:: https://raw.githubusercontent.com/rm-hull/luma.led_matrix/master/doc/images/IMG_2810.JPG
+   :alt: max7219 sevensegment
+
+.. image:: https://raw.githubusercontent.com/rm-hull/luma.led_matrix/master/doc/images/matrix_cascaded.jpg
+   :alt: max7219 cascaded
+ 
+.. image:: https://raw.githubusercontent.com/rm-hull/luma.led_matrix/master/doc/images/box_helloworld.jpg
+   :alt: max7219 box
+     
+.. image:: https://raw.githubusercontent.com/rm-hull/luma.led_matrix/master/doc/images/emulator.gif
+   :alt: max7219 emulator
+
+Upgrade
+-------
+Version 0.3.0 was released on 19 January 2017: this came with a rename of the
+github project from **max7219** to **luma.led_matrix** to reflect the changing
+nature of the codebase.
+
+There is no direct migration path, but the old `docs <https://max7219.readthedocs.io>`_
+and `PyPi packages <https://pypi.python.org/pypi/max7219>`_ will remain
+available indefinitely, but that deprecated codebase will no longer recieve 
+updates or fixes.
+
+The consequence is that any existing code that uses the old **max7219** package
+should probably be updated. 
+
+License
+-------
+The MIT License (MIT)
+
+Copyright (c) 2013-2021 Richard Hull & Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 3 - 0
doc/.gitignore

@@ -0,0 +1,3 @@
+_build
+_static
+_templates

+ 177 - 0
doc/Makefile

@@ -0,0 +1,177 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS    =
+SPHINXBUILD   = sphinx-build
+PAPER         =
+BUILDDIR      = _build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4     = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+	@echo "Please use \`make <target>' where <target> is one of"
+	@echo "  html       to make standalone HTML files"
+	@echo "  dirhtml    to make HTML files named index.html in directories"
+	@echo "  singlehtml to make a single large HTML file"
+	@echo "  pickle     to make pickle files"
+	@echo "  json       to make JSON files"
+	@echo "  htmlhelp   to make HTML files and a HTML help project"
+	@echo "  qthelp     to make HTML files and a qthelp project"
+	@echo "  devhelp    to make HTML files and a Devhelp project"
+	@echo "  epub       to make an epub"
+	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
+	@echo "  latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+	@echo "  text       to make text files"
+	@echo "  man        to make manual pages"
+	@echo "  texinfo    to make Texinfo files"
+	@echo "  info       to make Texinfo files and run them through makeinfo"
+	@echo "  gettext    to make PO message catalogs"
+	@echo "  changes    to make an overview of all changed/added/deprecated items"
+	@echo "  xml        to make Docutils-native XML files"
+	@echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
+	@echo "  linkcheck  to check all external links for integrity"
+	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+	rm -rf $(BUILDDIR)/*
+
+html:
+	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+	@echo
+	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+	@echo
+	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+	@echo
+	@echo "Build finished; now you can process the pickle files."
+
+json:
+	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+	@echo
+	@echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+	@echo
+	@echo "Build finished; now you can run HTML Help Workshop with the" \
+	      ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+	@echo
+	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
+	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/luma.led_matrix.qhcp"
+	@echo "To view the help file:"
+	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/luma.led_matrix.qhc"
+
+devhelp:
+	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+	@echo
+	@echo "Build finished."
+	@echo "To view the help file:"
+	@echo "# mkdir -p $$HOME/.local/share/devhelp/luma.led_matrix"
+	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/luma.led_matrix"
+	@echo "# devhelp"
+
+epub:
+	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+	@echo
+	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo
+	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+	@echo "Run \`make' in that directory to run these through (pdf)latex" \
+	      "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through pdflatex..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+	@echo "Running LaTeX files through platex and dvipdfmx..."
+	$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+	@echo
+	@echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+	@echo
+	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo
+	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+	@echo "Run \`make' in that directory to run these through makeinfo" \
+	      "(use \`make info' here to do that automatically)."
+
+info:
+	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+	@echo "Running Texinfo files through makeinfo..."
+	make -C $(BUILDDIR)/texinfo info
+	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+	@echo
+	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+	@echo
+	@echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+	@echo
+	@echo "Link check complete; look for any errors in the above output " \
+	      "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+	@echo "Testing of doctests in the sources finished, look at the " \
+	      "results in $(BUILDDIR)/doctest/output.txt."
+
+xml:
+	$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+	@echo
+	@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+	$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+	@echo
+	@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."

+ 32 - 0
doc/api-documentation.rst

@@ -0,0 +1,32 @@
+API Documentation
+-----------------
+.. automodule:: luma.led_matrix
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+.. inheritance-diagram:: luma.core.device luma.core.mixin luma.core.virtual luma.led_matrix.device
+
+Upgrading
+"""""""""
+.. warning::
+   Version 0.3.0 was released on 19 January 2017: this came with a rename of the
+   project in github from **max7219** to **luma.led_matrix** to reflect the changing
+   nature of the codebase. It introduces a complete rewrite of the codebase to bring
+   it in line with other 'luma' implementations.
+
+   There is no direct migration path, but the old `documentation <https://max7219.readthedocs.io>`_
+   and `PyPi packages <https://pypi.python.org/pypi/max7219>`_ will remain
+   available indefinitely, but that deprecated codebase will no longer recieve 
+   updates or fixes.
+   
+   This breaking change was necessary to be able to add different classes of
+   devices, so that they could reuse core components.
+
+:mod:`luma.led_matrix.device`
+"""""""""""""""""""""""""""""
+.. automodule:: luma.led_matrix.device
+    :members:
+    :inherited-members:
+    :undoc-members:
+    :show-inheritance:

+ 280 - 0
doc/conf.py

@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+#
+# luma.led_matrix documentation build configuration file, created by
+# sphinx-quickstart on Wed Mar 11 23:24:05 2015.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import os
+import sys
+from datetime import datetime
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+
+sys.path.insert(0, os.path.abspath('..'))
+
+from luma.led_matrix import __version__ as version
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+    'sphinx.ext.autodoc',
+    'sphinx.ext.doctest',
+    'sphinx.ext.intersphinx',
+    'sphinx.ext.autosectionlabel',
+    'sphinx.ext.todo',
+    'sphinx.ext.coverage',
+    'sphinx.ext.ifconfig',
+    'sphinx.ext.viewcode',
+    'sphinx.ext.inheritance_diagram',
+    'sphinx.ext.extlinks'
+]
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+#source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'Luma.LED_Matrix: Display driver for MAX7219, WS2812'
+author = u'Richard Hull and contributors'
+copyright = u'2015-{0}, {1}'.format(datetime.now().year, author)
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+
+# The full version, including alpha/beta/rc tags.
+release = version
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all
+# documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+# If true, keep warnings as "system message" paragraphs in the built documents.
+#keep_warnings = False
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+html_theme = 'default'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further.  For a list of options available for each theme, see the
+# documentation.
+#html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents.  If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar.  Default is the same as html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+#html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+# html_static_path = ['_static']
+
+# Add any extra paths that contain custom files (such as robots.txt or
+# .htaccess) here, relative to this directory. These files are copied
+# directly to the root of the documentation.
+#html_extra_path = []
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it.  The value of this option must be the
+# base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'luma.led_matrix_doc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+# The paper size ('letterpaper' or 'a4paper').
+#'papersize': 'letterpaper',
+
+# The font size ('10pt', '11pt' or '12pt').
+#'pointsize': '10pt',
+
+# Additional stuff for the LaTeX preamble.
+#'preamble': '',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title,
+#  author, documentclass [howto, manual, or own class]).
+latex_documents = [
+  ('index', 'luma.led_matrix.tex', u'Luma.LED_Matrix Documentation',
+   author, 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+    ('index', 'luma.led_matrix', u'Luma.LED_Matrix Documentation',
+     [author], 1)
+]
+
+# If true, show URL addresses after external links.
+#man_show_urls = False
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+#  dir menu entry, description, category)
+texinfo_documents = [
+  ('index', 'luma.led_matrix', u'Luma.LED_Matrix Documentation',
+   author, 'luma.led_matrix', 'One line description of project.',
+   'Miscellaneous'),
+]
+
+# Documents to append as an appendix to all manuals.
+#texinfo_appendices = []
+
+# If false, no module index is generated.
+#texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+#texinfo_show_urls = 'footnote'
+
+# If true, do not generate a @detailmenu in the "Top" node's menu.
+#texinfo_no_detailmenu = False
+
+# Configuration for intersphinx
+intersphinx_mapping = {
+    'python': ('https://docs.python.org/3', None),
+    'pillow': ('https://pillow.readthedocs.io/en/latest', None),
+    'luma.core': ('https://luma-core.readthedocs.io/en/latest', None),
+    'luma.emulator': ('https://luma-emulator.readthedocs.io/en/latest', None)
+}

BIN
doc/fritzing/3x 7segment.fzz


+ 45 - 0
doc/images/7-segment.svg

@@ -0,0 +1,45 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"
+xmlns:xlink="http://www.w3.org/1999/xlink"
+width="480" height="400" viewBox="0 0 192 160" style="background-color: #000">
+<g stroke="#333" stroke-width=".25" fill="#F00">
+<g id="y"><g id="x"><g id="w">
+<g id="v"><g id="u"><g id="t"><g id="s">
+<path d="m10,2-1-1h-6l-1,1 1,1h6zl-1,1v6l1,1
+1-1v-6zm0,16-1-1v-6l1-1 1,1v6zl-1-1h-6l-1,1
+1,1h6zm-8-8-1,1v6l1,1 1-1v-6zl-1-1v-6l1-1
+1,1v6zl1,1h6l1-1-1-1h-6z" fill="#222"/></g>
+<use xlink:href="#s" x="12"/>
+<path d="m22,2-1-1h-6l-1,1 1,1h6z"/></g>
+<use xlink:href="#t" x="24"/>
+<path d="m34,2-1,1v6l1,1 1-1v-6zm12,0-1,1v6l1,1 1-1v-6z"/></g>
+<use xlink:href="#u" x="48"/>
+<path d="m58,10-1,1v6l1,1 1-1v-6zm12,0-1,1v6l1,1
+1-1v-6zm12,0-1,1v6l1,1 1-1v-6zm12,0-1,1v6l1,1 1-1v-6z"/></g>
+<use xlink:href="#v" x="96"/>
+<g id="r"><path d="m98,18 1,1h6l1-1-1-1h-6zm12,0
+1,1h6l1-1-1-1h-6zm12,0 1,1h6l1-1-1-1h-6zm12,0
+1,1h6l1-1-1-1h-6z"/></g>
+<use xlink:href="#r" x="48"/></g>
+<use xlink:href="#w" y="20"/>
+<g id="q"><g id="p"><g id="o">
+<path d="m2,30-1,1v6l1,1 1-1v-6zm12,0-1,1v6l1,1 1-1v-6z"/></g>
+<use xlink:href="#o" x="24"/></g>
+<use xlink:href="#p" x="48"/></g>
+<use xlink:href="#q" x="96"/>
+</g><use xlink:href="#x" y="40"/>
+<g id="n"><g id="m"><g id="l"><g id="k">
+<path d="m2,42-1,1v6l1,1 1-1v-6zm12,0-1,1v6l1,1 1-1v-6z"/></g>
+<use xlink:href="#k" y="20"/></g>
+<use xlink:href="#l" x="24"/></g>
+<use xlink:href="#m" x="48"/></g>
+<use xlink:href="#n" x="96"/>
+</g><use xlink:href="#y" y="80"/>
+<g id="j"><g id="i"><g id="h"><g id="g"><g id="f">
+<path d="m2,90 1,1h6l1-1-1-1h-6zm12,0 1,1h6l1-1-1-1h-6z"/></g>
+<use xlink:href="#f" x="24"/></g>
+<use xlink:href="#g" y="20"/></g>
+<use xlink:href="#h" x="48"/></g>
+<use xlink:href="#i" y="40"/></g>
+<use xlink:href="#j" x="96"/>
+</g></svg>

BIN
doc/images/7segment.jpg


BIN
doc/images/BL-M12A881.png


BIN
doc/images/IMG_2810.JPG


BIN
doc/images/block_reorientation.gif


BIN
doc/images/box_helloworld.jpg


BIN
doc/images/devices.jpg


BIN
doc/images/emulator.gif


BIN
doc/images/level-shifter.jpg


BIN
doc/images/matrix.jpg


BIN
doc/images/matrix_cascaded.jpg


BIN
doc/images/raspi-spi.png


+ 36 - 0
doc/index.rst

@@ -0,0 +1,36 @@
+Luma.LED_Matrix: Display drivers for MAX7219, WS2812, APA102
+============================================================
+
+.. image:: https://github.com/rm-hull/luma.led_matrix/workflows/luma.led_matrix/badge.svg?branch=master
+   :target: https://github.com/rm-hull/luma.led_matrix/actions?workflow=luma.led_matrix
+
+.. image:: https://coveralls.io/repos/github/rm-hull/luma.led_matrix/badge.svg?branch=master
+   :target: https://coveralls.io/github/rm-hull/luma.led_matrix?branch=master
+
+.. image:: https://readthedocs.org/projects/luma-led_matrix/badge/?version=latest
+   :target: http://luma-led-matrix.readthedocs.io/en/latest/?badge=latest
+
+.. image:: https://img.shields.io/pypi/pyversions/luma.led_matrix.svg
+   :target: https://pypi.python.org/pypi/luma.led_matrix
+
+.. image:: https://img.shields.io/pypi/v/luma.led_matrix.svg
+   :target: https://pypi.python.org/pypi/luma.led_matrix
+
+.. image:: https://img.shields.io/pypi/dm/luma.led_matrix
+   :target: https://pypi.python.org/project/luma.led_matrix
+
+.. image:: https://img.shields.io/maintenance/yes/2021.svg?maxAge=2592000
+
+.. toctree::
+   :maxdepth: 2
+
+   intro
+   install
+   python-usage
+   api-documentation
+   notes
+   references
+
+.. include:: ../CONTRIBUTING.rst
+.. include:: ../CHANGES.rst
+.. include:: ../LICENSE.rst

+ 164 - 0
doc/install.rst

@@ -0,0 +1,164 @@
+Installation
+------------
+.. note:: The library has been tested against Python 3.6 and newer.
+
+Pre-requisites
+^^^^^^^^^^^^^^
+
+MAX7219 Devices
+"""""""""""""""
+By default, the SPI kernel driver is **NOT** enabled on a Raspberry Pi Raspbian image.
+You can confirm whether it is enabled using the shell command below::
+
+  $ lsmod | grep -i spi
+  spi_bcm2835             7424  0
+
+Depending on the hardware/kernel version, this may report **spi_bcm2807** rather 
+than **spi_bcm2835** - either should be adequate.
+
+And to verify that the devices are successfully installed in ``/dev``::
+
+  $ ls -l /dev/spi*
+  crw------- 1 root root 153, 0 Jan  1  1970 /dev/spidev0.0
+  crw------- 1 root root 153, 1 Jan  1  1970 /dev/spidev0.1
+
+If you have no ``/dev/spi`` files and nothing is showing using ``lsmod`` then this
+implies the kernel SPI driver is not loaded. Enable the SPI as follows (steps
+taken from https://learn.sparkfun.com/tutorials/raspberry-pi-spi-and-i2c-tutorial#spi-on-pi):
+
+#. Run ``sudo raspi-config``
+#. Use the down arrow to select ``5 Interfacing Options``
+#. Arrow down to ``P4 SPI``
+#. Select **yes** when it asks you to enable SPI
+#. Also select **yes** when it asks about automatically loading the kernel module
+#. Use the right arrow to select the **<Finish>** button
+#. Reboot.
+
+.. image:: images/raspi-spi.png
+
+After rebooting re-check that the ``lsmod | grep -i spi`` command shows whether
+SPI driver is loaded before proceeding. If you are stil experiencing problems, refer to the official 
+Raspberry Pi `SPI troubleshooting guide <https://www.raspberrypi.org/documentation/hardware/raspberrypi/spi/README.md#troubleshooting>`_
+for further details, or ask a `new question <https://github.com/rm-hull/luma.led_matrix/issues/new>`_ - but
+please remember to add as much detail as possible.
+
+GPIO pin-outs
+^^^^^^^^^^^^^
+
+MAX7219 Devices (SPI)
+"""""""""""""""""""""
+The breakout board has two headers to allow daisy-chaining:
+
+============ ====== ============= ========= ====================
+Board Pin    Name   Remarks       RPi Pin   RPi Function
+------------ ------ ------------- --------- --------------------
+1            VCC    +5V Power     2         5V0
+2            GND    Ground        6         GND
+3            DIN    Data In       19        GPIO 10 (MOSI)
+4            CS     Chip Select   24        GPIO 8 (SPI CE0)
+5            CLK    Clock         23        GPIO 11 (SPI CLK)
+============ ====== ============= ========= ====================
+
+.. seealso:: Also see the section for :doc:`cascading/daisy-chaining <python-usage>`, power supply and
+   level-shifting.
+
+WS2812 NeoPixels (DMA)
+""""""""""""""""""""""
+Typically, WS2812 NeoPixels reqire VCC, VSS (GND) and DI pins connecting to the
+Raspberry Pi, where the DI pin is usually connected to a PWM control pin such
+as GPIO 18.
+
+============ ====== ============= ========= ====================
+Board Pin    Name   Remarks       RPi Pin   RPi Function
+------------ ------ ------------- --------- --------------------
+1            DO     Data Out      -         -
+2            DI     Data In       12        GPIO 18 (PWM0)
+3            VCC    +5V Power     2         5V0
+4            NC     Not connected -         -
+5            VDD    Not connected -         -
+6            VSS    Ground        6         GND
+============ ====== ============= ========= ====================
+
+The DO pin should be connected to the DI pin on the next (daisy-chained)
+neopixel, while the VCC and VSS are supplied in-parallel to all LED's.
+WS2812b devices now are becoming more prevalent, and only have 4 pins.
+
+NeoSegments
+"""""""""""
+@msurguy's NeoSegments should be connected as follows:
+
+============ ====== ============= ========= ====================
+Board Pin    Name   Remarks       RPi Pin   RPi Function
+------------ ------ ------------- --------- --------------------
+1            GND    Ground        6         GND
+2            DI     Data In       12        GPIO 18 (PWM0)
+3            VCC    +5V Power     2         5V0
+============ ====== ============= ========= ====================
+
+
+Installing from PyPi
+^^^^^^^^^^^^^^^^^^^^
+Install the dependencies for library first with::
+
+  $ sudo usermod -a -G spi,gpio pi
+  $ sudo apt install build-essential python3-dev python3-pip libfreetype6-dev libjpeg-dev libopenjp2-7 libtiff5
+
+.. warning:: The default ``pip`` and ``setuptools`` bundled with apt on Raspbian are really old,
+   and can cause components to not be installed properly. Make sure they are up to date by upgrading
+   them first::
+   
+      $ sudo -H pip install --upgrade --ignore-installed pip setuptools
+
+Proceed to install latest version of the luma.led_matrix library directly from
+`PyPI <https://pypi.python.org/pypi?:action=display&name=luma.led_matrix>`_::
+
+  $ sudo python3 -m pip install --upgrade luma.led_matrix
+
+Examples
+^^^^^^^^
+Ensure you have followed the installation instructions above.
+Clone the `repo <https://github.com/rm-hull/luma.led_matrix>`__ from github,
+and run the example code as follows::
+
+  $ python examples/matrix_demo.py
+
+The matrix demo accepts optional flags to configure the number of cascaded
+devices and correct the block orientation phase shift when using 4x8x8
+matrices::
+
+    $ python examples/matrix_demo.py -h
+    usage: matrix_demo.py [-h] [--cascaded CASCADED]
+                          [--block-orientation {0,90,-90}] [--rotate {0,1,2,3}]
+                          [--reverse-order REVERSE_ORDER]
+
+    matrix_demo arguments
+
+    optional arguments:
+      -h, --help            show this help message and exit
+      --cascaded CASCADED, -n CASCADED
+                            Number of cascaded MAX7219 LED matrices (default: 1)
+      --block-orientation {0,90,-90}
+                            Corrects block orientation when wired vertically
+                            (default: 0)
+      --rotate {0,1,2,3}    Rotate display 0=0_, 1=90_, 2=180_, 3=270_
+                            (default: 0)
+      --reverse-order REVERSE_ORDER
+                            Set to true if blocks are in reverse order (default:
+                            False)
+
+Similarly, there is a basic demo of the capabilities of the
+:py:class:`luma.led_matrix.virtual.sevensegment` wrapper::
+
+  $ python examples/sevensegment_demo.py
+
+and for the :py:class:`luma.led_matrix.device.neopixel` device::
+
+  $ sudo python examples/neopixel_demo.py
+
+Further examples are available in the `luma.examples
+<https://github.com/rm-hull/luma.examples>`_. git repository. Follow the
+instructions in the README for more details.
+
+A small example application using `ZeroSeg
+<https://thepihut.com/products/zeroseg>`_ to display TOTP secrets can be
+found in https://github.com/rm-hull/zaup.

+ 33 - 0
doc/intro.rst

@@ -0,0 +1,33 @@
+Introduction
+------------
+Python library interfacing LED matrix displays with the MAX7219 driver (using
+SPI) and WS2812 & APA102 NeoPixels (inc Pimoroni Unicorn pHat/Hat and Unicorn
+Hat HD) on the Raspberry Pi and other Linux-based single board computers - it
+provides a Pillow-compatible drawing canvas, and other functionality to
+support:
+
+* multiple cascaded devices
+* LED matrix, seven-segment and NeoPixel variants
+* scrolling/panning capability,
+* terminal-style printing,
+* state management,
+* dithering to monochrome,
+* Python 3.6+ is supported
+
+.. image:: https://raw.githubusercontent.com/rm-hull/luma.led_matrix/master/doc/images/devices.jpg
+   :alt: max7219 matrix
+
+A LED matrix can be acquired for a few pounds from outlets like `Banggood
+<http://www.banggood.com/MAX7219-Dot-Matrix-Module-DIY-Kit-SCM-Control-Module-For-Arduino-p-72178.html?currency=GBP>`_.
+Likewise 7-segment displays are available from `Ali-Express
+<http://www.aliexpress.com/item/MAX7219-Red-Module-8-Digit-7-Segment-Digital-LED-Display-Tube-For-Arduino-MCU/1449630475.html>`_
+or `Ebay <http://www.ebay.com/itm/-/172317726225>`_.
+
+.. seealso::
+   Further technical information for the specific devices can be found in the
+   datasheets below:
+   
+   - :download:`MAX7219 <tech-spec/MAX7219.pdf>`
+   - :download:`WS2812 <tech-spec/WS2812.pdf>`
+   - :download:`WS2812B <tech-spec/WS2812B.pdf>`
+   - :download:`APA102 <tech-spec/APA102.pdf>`

+ 39 - 0
doc/notes.rst

@@ -0,0 +1,39 @@
+Notes
+-----
+
+Cascading, power supply & level shifting
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The MAX7219 chip supports cascading devices by connecting the DIN of one chip
+to the DOUT of another chip. For a long time I was puzzled as to why this didnt
+seem to work properly for me, despite spending a lot of time investigating and
+always assuming it was a bug in code.
+
+- Because the Raspberry PI can only supply a limited amount of power from the
+  5V rail, it is recommended that any LED matrices are powered separately by a
+  5V supply, and grounded with the Raspberry PI. It is possible to power one or
+  two LED matrices directly from a Raspberry PI, but any more is likely to
+  cause intermittent faults & crashes.
+
+- Also because the GPIO ports used for SPI are 3.3V, a simple level shifter (as
+  per the diagram below) should be employed on the DIN, CS and CLK inputs to
+  boost the levels to 5V. Again it is possible to drive them directly by the
+  3.3V GPIO pins, it is just outside tolerance, and will result in intermittent
+  issues.
+
+.. image:: images/level-shifter.jpg
+   :alt: max7219 levelshifter
+
+Despite the above two points, I still had no success getting cascaded matrices
+to work properly. Revisiting the wiring, I had connected the devices in serial
+connecting the out pins of one device to the in pins of another. This just
+produced garbled bit patterns.
+
+Connecting all the CS lines on the input side together and CLK lines on the
+input side all together worked. The same should probably apply to GND and VCC
+respectively: Only the DOUT of one device should be connected to the next
+devices DIN pins.  Connecting through the output side, never worked
+consistently; I can only assume that there is some noise on the clock line, or
+a dry solder joint somewhere.
+
+.. image:: images/matrix_cascaded.jpg
+   :alt: max7219 cascaded

+ 478 - 0
doc/python-usage.rst

@@ -0,0 +1,478 @@
+Python Usage
+------------
+
+8x8 LED Matrices
+^^^^^^^^^^^^^^^^
+For the matrix device, initialize the :py:class:`luma.led_matrix.device.max7219`
+class, as follows:
+
+.. code:: python
+
+   from luma.core.interface.serial import spi, noop
+   from luma.core.render import canvas
+   from luma.led_matrix.device import max7219
+   
+   serial = spi(port=0, device=0, gpio=noop())
+   device = max7219(serial)
+
+The display device should now be configured for use. The specific
+:py:class:`~luma.led_matrix.device.max7219` class  exposes a
+:py:func:`~luma.led_matrix.device.max7219.display` method which takes an image
+with attributes consistent with the capabilities of the configured device's
+capabilities. However, for most cases, for drawing text and graphics primitives,
+the canvas class should be used as follows:
+
+.. code:: python
+
+   from PIL import ImageFont
+
+   font = ImageFont.truetype("examples/pixelmix.ttf", 8)
+ 
+   with canvas(device) as draw:
+       draw.rectangle(device.bounding_box, outline="white", fill="black")
+
+The :py:class:`luma.core.render.canvas` class automatically creates an
+:py:mod:`PIL.ImageDraw` object of the correct dimensions and bit depth suitable
+for the device, so you may then call the usual Pillow methods to draw onto the
+canvas.
+
+As soon as the with scope is ended, the resultant image is automatically
+flushed to the device's display memory and the :mod:`PIL.ImageDraw` object is
+garbage collected.
+
+.. note::
+   The default Pillow font is too big for 8px high devices like the LED matrices
+   here, so the `luma.examples <https://github.com/rm-hull/luma.examples>`_ repo
+   inclues a small TTF pixel font called **pixelmix.ttf** (attribution: 
+   http://www.dafont.com/) which just fits.
+
+   Alternatively, a set of "legacy" fixed-width bitmap fonts are included in
+   the `luma.core <https://github.com/rm-hull/luma.core>`__ codebase and may be
+   used as follows:
+
+   .. code:: python
+
+     from luma.core.legacy import text
+     from luma.core.legacy.font import proportional, CP437_FONT, LCD_FONT
+
+     with canvas(device) as draw:
+         text(draw, (0, 0), "A", fill="white", font=proportional(CP437_FONT))
+
+   The fixed-width fonts can be "converted" on-the-fly to proportionally
+   spaced by wrapping them with the :py:class:`luma.core.legacy.font.proportional` 
+   class.
+
+Scrolling / Virtual viewports
+"""""""""""""""""""""""""""""
+A single 8x8 LED matrix clearly hasn't got a lot of area for displaying useful
+information. Obviously they can be daisy-chained together to provide a longer
+line of text, but as this library extends `luma.core <https://github.com/rm-hull/luma.core>`_,
+then we can use the :py:class:`luma.core.virtual.viewport` class to allow
+scrolling support:
+
+.. code:: python
+
+   import time
+
+   from luma.core.interface.serial import spi, noop
+   from luma.core.render import canvas
+   from luma.core.virtual import viewport
+   from luma.led_matrix.device import max7219
+   
+   serial = spi(port=0, device=0, gpio=noop())
+   device = max7219(serial)
+   
+   virtual = viewport(device, width=200, height=100)
+
+   with canvas(virtual) as draw:
+       draw.rectangle(device.bounding_box, outline="white", fill="black")
+       draw.text((3, 3), "Hello world", fill="white")
+
+   for offset in range(8):
+       virtual.set_position((offset, offset))
+       time.sleep(0.1)
+
+Calling :py:meth:`~luma.core.virtual.viewport.set_position` on a virtual
+viewport, causes the device to render what is visible at that specific
+position; altering the position in a loop refreshes every time it is called,
+and gives an animated scrolling effect.
+
+By altering both the X and Y co-ordinates allows scrolling in any direction,
+not just horizontally.
+
+Color Model
+"""""""""""
+Any of the standard :mod:`PIL.ImageColor` color formats may be used, but since
+the 8x8 LED Matrices are monochrome, only the HTML color names :py:const:`"black"` and
+:py:const:`"white"` values should really be used; in fact, by default, any value
+*other* than black is treated as white. The :py:class:`luma.core.render.canvas`
+constructor does have a :py:attr:`dither` flag which if set to
+:py:const:`True`, will convert color drawings to a dithered monochrome effect.
+
+.. code:: python
+
+  with canvas(device, dither=True) as draw:
+      draw.rectangle(device.bounding_box, outline="white", fill="red")
+
+Landscape / Portrait Orientation
+""""""""""""""""""""""""""""""""
+By default, cascaded matrices will be oriented in landscape mode. Should you
+have an application that requires the display to be mounted in a portrait
+aspect, then add a :py:attr:`rotate=N` parameter when creating the device:
+
+.. code:: python
+
+  from luma.core.interface.serial import spi, noop
+  from luma.core.render import canvas
+  from luma.led_matrix.device import max7219
+
+  serial = spi(port=0, device=0, gpio=noop())
+  device = max7219(serial, rotate=1) 
+
+  # Box and text rendered in portrait mode
+  with canvas(device) as draw:
+      draw.rectangle(device.bounding_box, outline="white", fill="black")
+
+*N* should be a value of 0, 1, 2 or 3 only, where 0 is no rotation, 1 is
+rotate 90° clockwise, 2 is 180° rotation and 3 represents 270° rotation.
+
+The :py:attr:`device.size`, :py:attr:`device.width` and :py:attr:`device.height`
+properties reflect the rotated dimensions rather than the physical dimensions.
+
+Daisy-chaining
+""""""""""""""
+The MAX7219 chipset supports a serial 16-bit register/data buffer which is
+clocked in on pin DIN every time the clock edge falls, and clocked out on DOUT
+16.5 clock cycles later. This allows multiple devices to be chained together.
+
+If you have more than one device and they are daisy-chained together, you can
+initialize the library in one of two ways, either using :py:attr:`cascaded=N` 
+to indicate the number of daisychained devices:
+
+.. code:: python
+
+   from luma.core.interface.serial import spi, noop
+   from luma.core.render import canvas
+   from luma.led_matrix.device import max7219
+
+   serial = spi(port=0, device=0, gpio=noop())
+   device = max7219(serial, cascaded=3)
+
+   with canvas(device) as draw:
+      draw.rectangle(device.bounding_box, outline="white", fill="black")
+
+Using :py:attr:`cascaded=N` implies there are N devices arranged linearly and
+horizontally, running left to right.
+
+Alternatively, the device configuration may configured with :py:attr:`width=W`
+and :py:attr:`height=H`. These dimensions denote the number of LEDs in the all
+the daisychained devices. The width and height *must* both be multiples of 8:
+this has scope for arranging in blocks in, say 3x3 or 5x2 matrices (24x24 or
+40x16 pixels, respectively).
+
+Given 12 daisychained MAX7219's arranged in a 4x3 layout, the simple example
+below,
+
+.. code:: python
+
+   from luma.core.interface.serial import spi, noop
+   from luma.core.render import canvas
+   from luma.core.legacy import text
+   from luma.core.legacy.font import proportional, LCD_FONT
+   from luma.led_matrix.device import max7219
+
+   serial = spi(port=0, device=0, gpio=noop(), block_orientation=-90)
+   device = max7219(serial, width=32, height=24)
+
+   with canvas(device) as draw:
+      draw.rectangle(device.bounding_box, outline="white")
+      text(draw, (2, 2), "Hello", fill="white", font=proportional(LCD_FONT))
+      text(draw, (2, 10), "World", fill="white", font=proportional(LCD_FONT))
+
+displays as:
+
+.. image:: images/box_helloworld.jpg
+   :alt: box helloworld
+
+
+Trouble-shooting / common problems
+""""""""""""""""""""""""""""""""""
+Some online retailers are selling pre-assembled `'4-in-1' LED matrix displays
+<http://www.ebay.co.uk/itm/371306583204>`_, but they appear to be wired 90°
+out-of-phase such that horizontal scrolling appears as below:
+
+.. image:: images/block_reorientation.gif
+   :alt: block alignment
+
+This can be rectified by initializing the :py:class:`~luma.led_matrix.device.max7219`
+device with a parameter of :py:attr:`block_orientation=-90` (or +90, if your device is
+aligned the other way):
+
+.. code:: python
+
+   from luma.core.interface.serial import spi, noop
+   from luma.core.render import canvas
+   from luma.led_matrix.device import max7219
+
+   serial = spi(port=0, device=0, gpio=noop())
+   device = max7219(serial, cascaded=4, block_orientation=-90)
+
+Every time a display render is subsequenly requested, the underlying image
+representation is corrected to reverse the 90° phase shift.
+
+Similarly, in other pre-assembled configurations, the 4-in-1 blocks
+arrange the 8x8 blocks in reverse order. In that case, you need to pass
+a True value to parameter `blocks_arranged_in_reverse_order`, requesting
+an additional pre-processing step that fixes this:
+
+.. code:: python
+
+   ...
+   device = max7219(serial, cascaded=4, block_orientation=-90,
+                    blocks_arranged_in_reverse_order=True)
+
+7-Segment LED Displays
+^^^^^^^^^^^^^^^^^^^^^^
+For the 7-segment device, initialize the :py:class:`luma.core.virtual.sevensegment` 
+class, and wrap it around a previously created :py:class:`~luma.led_matrix.device.max7219`
+device:
+
+.. code:: python
+    
+   from luma.core.interface.serial import spi, noop
+   from luma.core.render import canvas
+   from luma.core.virtual import sevensegment
+   from luma.led_matrix.device import max7219
+
+   serial = spi(port=0, device=0, gpio=noop())
+   device = max7219(serial, cascaded=2)
+   seg = sevensegment(device)
+
+The **seg** instance now has a :py:attr:`~luma.core.virtual.sevensegment.text` 
+property which may be assigned, and when it does will update all digits
+according to the limited alphabet the 7-segment displays support. For example,
+assuming there are 2 cascaded modules, we have 16 character available, and so
+can write:
+
+.. code:: python
+
+   seg.text = "Hello world"
+
+Rather than updating the whole display buffer, it is possible to update
+'slices', as per the below example:
+
+.. code:: python
+
+   seg.text[0:5] = "Goodbye"
+
+This replaces ``Hello`` in the previous example, replacing it with ``Gooobye``.
+The usual python idioms for slicing (inserting / replacing / deleteing) can be
+used here, but note if inserted text exceeds the underlying buffer size, a
+:py:exc:`ValueError` is raised.
+
+Floating point numbers (or text with '.') are handled slightly differently - the
+decimal-place is fused in place on the character immediately preceding it. This
+means that it is technically possible to get more characters displayed than the
+buffer allows, but only because dots are folded into their host character
+
+.. image:: images/IMG_2810.JPG
+   :alt: max7219 sevensegment
+
+WS2812 NeoPixels
+^^^^^^^^^^^^^^^^
+For a strip of neopixels, initialize the :py:class:`luma.led_matrix.device.ws2812`
+class (also aliased to  :py:class:`luma.led_matrix.device.neopixel`), supplying a
+parameter :py:attr:`cascaded=N` where *N* is the number of daisy-chained LEDs.
+
+This script creates a drawing surface 100 pixels long, and lights up three specific 
+pixels, and a contiguous block:
+
+.. code:: python
+
+   from luma.core.render import canvas
+   from luma.led_matrix.device import ws2812
+   
+   device = ws2812(cascaded=100)
+
+   with canvas(device) as draw:
+       draw.point((0,0), fill="white")
+       draw.point((4,0), fill="blue")
+       draw.point((11,0), fill="orange")
+       draw.rectange((20, 0, 40, 0), fill="red")
+
+If you have a device like Pimoroni's `Unicorn pHat <https://shop.pimoroni.com/products/unicorn-phat>`_, 
+initialize the device with :py:attr:`width=N` and :py:attr:`height=N` attributes instead:
+
+.. code:: python
+
+   from luma.core.render import canvas
+   from luma.led_matrix.device import ws2812
+   
+   # Pimoroni's Unicorn pHat is 8x4 neopixels
+   device = ws2812(width=8, height=4)
+
+   with canvas(device) as draw:
+       draw.line((0, 0, 0, device.height), fill="red")
+       draw.line((1, 0, 1, device.height), fill="orange")
+       draw.line((2, 0, 2, device.height), fill="yellow")
+       draw.line((3, 0, 3, device.height), fill="green")
+       draw.line((4, 0, 4, device.height), fill="blue")
+       draw.line((5, 0, 5, device.height), fill="indigo")
+       draw.line((6, 0, 6, device.height), fill="violet")
+       draw.line((7, 0, 7, device.height), fill="white")
+
+.. note::
+   The ws2812 driver uses the `ws2812 <https://pypi.python.org/pypi/ws2812>`_
+   PyPi package to interface to the daisychained LEDs. It uses DMA (direct memory
+   access) via ``/dev/mem`` which means that it has to run in privileged mode
+   (via ``sudo`` root access).
+
+The same viewport, scroll support, portrait/landscape orientation and color model
+idioms provided in luma.core are equally applicable to the ws2812 implementation.
+
+Pimoroni Unicorn HAT
+""""""""""""""""""""
+Pimoroni sells the `Unicorn HAT <https://shop.pimoroni.com/products/unicorn-hat>`_, 
+comprising 64 WS2812b NeoPixels in an 8x8 arrangement. The pixels are cascaded, but
+arranged in a 'snake' layout, rather than a 'scan' layout. In order to accomodate this,
+a translation mapping is required, as follows:
+
+.. code:: python
+
+    import time
+
+    from luma.led_matrix.device import ws2812, UNICORN_HAT
+    from luma.core.render import canvas
+
+    device = ws2812(width=8, height=8, mapping=UNICORN_HAT)
+
+    for y in range(device.height):
+        for x in range(device.width):
+            with canvas(device) as draw:
+                draw.point((x, y), fill="green")
+            time.sleep(0.5)
+
+This should animate a green dot moving left-to-right down each line.
+
+Pimoroni Unicorn HAT HD
+"""""""""""""""""""""""
+Pimoroni sells the `Unicorn HAT HD <https://shop.pimoroni.com/products/unicorn-hat-hd>`_,
+comprising 256 high-intensity RGB LEDs in a 16x16 arrangement. The pixels are driven by an
+ARM STM32F making the display appear as an SPI device:
+
+.. code:: python
+
+    import time
+
+    from luma.led_matrix.device import unicornhathd
+    from luma.core.interface.serial import spi, noop
+    from luma.core.render import canvas
+
+    serial = spi(port=0, device=0, gpio=noop())
+    device = unicornhathd(serial)
+
+    for y in range(device.height):
+        for x in range(device.width):
+            with canvas(device) as draw:
+                draw.point((x, y), fill="green")
+            time.sleep(0.5)
+
+This should animate a green dot moving left-to-right down each line.
+
+NeoSegments (WS2812)
+""""""""""""""""""""
+`@msurguy <https://twitter.com/msurguy?lang=en>`_ has `crowdsourced some WS2812 neopixels <https://www.crowdsupply.com/maksmakes/neosegment>`_ 
+into a modular 3D-printed seven-segment unit. To program these devices:
+
+.. code:: python
+
+    import time
+
+    from luma.led_matrix_device import neosegment
+
+    neoseg = neosegment(width=6)
+    
+    # Defaults to "white" color initially
+    neoseg.text = "NEOSEG"
+    time.sleep(1)
+
+    # Set the first char ('N') to red
+    neoseg.color[0] = "red"
+    time.sleep(1)
+
+    # Set fourth and fifth chars ('S','E') accordingly
+    neoseg.color[3:5] = ["cyan", "blue"]
+    time.sleep(1)
+
+    # Set the entire string to green
+    neoseg.color = "green"
+
+The :py:class:`~luma.led_matrix.device.neosegment` class extends :py:class:`~luma.core.virtual.sevensegment`,
+so the same text assignment (Python slicing paradigms) can be used here as well -
+see the earlier section for further details.
+
+The underlying device is exposed as attribute :py:attr:`device`, so methods
+such as :py:attr:`show`, :py:attr:`hide` and :py:attr:`contrast` are available.
+
+Next-generation APA102 NeoPixels
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+APA102 RGB neopixels are easier to control that WS2812 devices - they are driven
+using SPI rather than precise timings that the WS2812 devices need. Initialize the
+:py:class:`luma.led_matrix.device.apa102` class, supplying a parameter
+:py:attr:`cascaded=N` where *N* is the number of daisy-chained LEDs. 
+
+The following script creates a drawing surface 8 pixels long, and lights up three 
+specific pixels:
+
+.. code:: python
+
+   from luma.core.render import canvas
+   from luma.led_matrix.device import apa102
+   
+   device = apa102(cascaded=8)
+
+   with canvas(device) as draw:
+       draw.point((0,0), fill="white")
+       draw.point((0,1), fill="blue")
+       draw.point((0,2), fill=(0xFF, 0x00, 0x00, 0x80))  # RGBA tuple, alpha controls brightness
+
+APA102 RGB pixels can have their brightness individually controlled: by setting
+the alpha chanel to a translucent value (as per the above example) will set the 
+brightness accordingly.
+
+Emulators
+^^^^^^^^^
+There are various `display emulators <http://github.com/rm-hull/luma.emulator>`_
+available for running code against, for debugging and screen capture functionality:
+
+* The :py:class:`luma.emulator.device.capture` device will persist a numbered
+  PNG file to disk every time its :py:meth:`~luma.emulator.device.capture.display`
+  method is called.
+
+* The :py:class:`luma.emulator.device.gifanim` device will record every image
+  when its :py:meth:`~luma.emulator.device.gifanim.display` method is called,
+  and on program exit (or Ctrl-C), will assemble the images into an animated
+  GIF.
+
+* The :py:class:`luma.emulator.device.pygame` device uses the :py:mod:`pygame`
+  library to render the displayed image to a pygame display surface. 
+
+Invoke the demos with::
+
+  $ python examples/clock.py -d capture --transform=led_matrix
+
+or::
+
+  $ python examples/clock.py -d pygame --transform=led_matrix
+  
+.. note::
+   *Pygame* is required to use any of the emulated devices, but it is **NOT**
+   installed as a dependency by default, and so must be manually installed
+   before using any of these emulation devices (e.g. ``pip install pygame``).
+   See the install instructions in `luma.emulator  <http://github.com/rm-hull/luma.emulator>`_
+   for further details.
+
+
+.. image:: images/emulator.gif
+   :alt: max7219 emulator
+

+ 11 - 0
doc/references.rst

@@ -0,0 +1,11 @@
+References
+----------
+
+- http://hackaday.com/2013/01/06/hardware-spi-with-python-on-a-raspberry-pi/
+- http://gammon.com.au/forum/?id=11516
+- http://louisthiery.com/spi-python-hardware-spi-for-raspi/
+- http://www.brianhensley.net/2012/07/getting-spi-working-on-raspberry-pi.html
+- http://raspi.tv/2013/8-x-8-led-array-driven-by-max7219-on-the-raspberry-pi-via-python
+- http://quick2wire.com/non-root-access-to-spi-on-the-pi
+
+

BIN
doc/tech-spec/APA102.pdf


BIN
doc/tech-spec/MAX7219.pdf


BIN
doc/tech-spec/TM1637.pdf


BIN
doc/tech-spec/WS2812.pdf


BIN
doc/tech-spec/WS2812B.pdf


+ 56 - 0
examples/apa102_demo.py

@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+import time
+
+from luma.led_matrix.device import apa102
+from luma.core.render import canvas
+
+device = apa102(width=8, height=1)
+
+
+def rotate(l):
+    return l[-1:] + l[:-1]
+
+
+def main():
+    colors = [
+        "red",
+        "orange",
+        "yellow",
+        "green",
+        "blue",
+        "indigo",
+        "violet",
+        "white"
+    ]
+
+    for color in colors:
+        with canvas(device) as draw:
+            draw.line(device.bounding_box, fill=color)
+        time.sleep(2)
+
+    device.contrast(0x30)
+    for _ in range(80):
+        with canvas(device) as draw:
+            for x, color in enumerate(colors):
+                draw.point((x, 0), fill=color)
+
+        colors = rotate(colors)
+        time.sleep(0.2)
+
+    time.sleep(4)
+
+    device.contrast(0x80)
+    time.sleep(1)
+    device.contrast(0x10)
+    time.sleep(1)
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except KeyboardInterrupt:
+        pass

+ 44 - 0
examples/box_demo.py

@@ -0,0 +1,44 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+import time
+import argparse
+
+from luma.led_matrix.device import max7219
+from luma.core.interface.serial import spi, noop
+from luma.core.render import canvas
+from luma.core.legacy import text
+from luma.core.legacy.font import proportional, LCD_FONT
+
+
+def demo(w, h, block_orientation, rotate):
+    # create matrix device
+    serial = spi(port=0, device=0, gpio=noop())
+    device = max7219(serial, width=w, height=h, rotate=rotate, block_orientation=block_orientation)
+    print("Created device")
+
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline="white")
+        text(draw, (2, 2), "Hello", fill="white", font=proportional(LCD_FONT))
+        text(draw, (2, 10), "World", fill="white", font=proportional(LCD_FONT))
+
+    time.sleep(300)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description='matrix_demo arguments',
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+
+    parser.add_argument('--width', type=int, default=8, help='Width')
+    parser.add_argument('--height', type=int, default=8, help='height')
+    parser.add_argument('--block-orientation', type=int, default=-90, choices=[0, 90, -90], help='Corrects block orientation when wired vertically')
+    parser.add_argument('--rotate', type=int, default=0, choices=[0, 1, 2, 3], help='Rotation factor')
+
+    args = parser.parse_args()
+
+    try:
+        demo(args.width, args.height, args.block_orientation, args.rotate)
+    except KeyboardInterrupt:
+        pass

+ 41 - 0
examples/issue_108.py

@@ -0,0 +1,41 @@
+#!usr/bin/env python
+
+import time
+import argparse
+
+from luma.led_matrix.device import max7219
+from luma.core.interface.serial import spi, noop
+from luma.core.render import canvas
+from luma.core.legacy import text
+
+print('Press Ctrl-C to quit...')
+
+serial = spi(port=0, device=0, gpio=noop())
+device = max7219(serial, cascaded=5, block_orientation=0)
+
+currentLoop = 0
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description='matrix_demo arguments',
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+
+    parser.add_argument('--cascaded', '-n', type=int, default=5, help='Number of cascaded MAX7219 LED matrices')
+    parser.add_argument('--block-orientation', type=int, default=0, choices=[0, 90, -90], help='Corrects block orientation when wired vertically')
+
+    args = parser.parse_args()
+
+    while True:
+
+        currentLoop = currentLoop + 1
+
+        Tv = str(currentLoop)
+        Tv = Tv.rjust(5, " ")
+
+        with canvas(device) as draw:
+            text(draw, (0, 0), Tv, fill="white")
+
+        print(Tv)
+        time.sleep(1)
+
+        if currentLoop >= 99999:
+            currentLoop = 0

+ 63 - 0
examples/larson_hue.py

@@ -0,0 +1,63 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+#
+# Based on https://github.com/pimoroni/blinkt/blob/master/examples/larson_hue.py
+
+import math
+import time
+import colorsys
+
+from luma.led_matrix.device import apa102
+from luma.core.render import canvas
+
+device = apa102(width=8, height=1)
+
+FALLOFF = 1.9
+SCAN_SPEED = 4
+
+
+def main():
+
+    start_time = time.time()
+
+    while True:
+        delta = (time.time() - start_time)
+
+        # Offset is a sine wave derived from the time delta
+        # we use this to animate both the hue and larson scan
+        # so they are kept in sync with each other
+        offset = (math.sin(delta * SCAN_SPEED) + 1) / 2
+
+        # Use offset to pick the right colour from the hue wheel
+        hue = int(round(offset * 360))
+
+        # Now we generate a value from 0 to 7
+        offset = int(round(offset * 7))
+
+        with canvas(device) as draw:
+            for x in range(8):
+                sat = 1.0
+
+                val = 7 - (abs(offset - x) * FALLOFF)
+                val /= 7.0  # Convert to 0.0 to 1.0
+                val = max(val, 0.0)  # Ditch negative values
+
+                xhue = hue  # Grab hue for this pixel
+                xhue += (1 - val) * 10  # Use the val offset to give a slight colour trail variation
+                xhue %= 360  # Clamp to 0-359
+                xhue /= 360.0  # Convert to 0.0 to 1.0
+
+                r, g, b = [int(c * 255) for c in colorsys.hsv_to_rgb(xhue, sat, val)]
+
+                draw.point((x, 0), fill=(r, g, b, int(val * 256)))
+
+        time.sleep(0.001)
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except KeyboardInterrupt:
+        pass

+ 124 - 0
examples/matrix_demo.py

@@ -0,0 +1,124 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+import re
+import time
+import argparse
+
+from luma.led_matrix.device import max7219
+from luma.core.interface.serial import spi, noop
+from luma.core.render import canvas
+from luma.core.virtual import viewport
+from luma.core.legacy import text, show_message
+from luma.core.legacy.font import proportional, CP437_FONT, TINY_FONT, SINCLAIR_FONT, LCD_FONT
+
+
+def demo(n, block_orientation, rotate, inreverse):
+    # create matrix device
+    serial = spi(port=0, device=0, gpio=noop())
+    device = max7219(serial, cascaded=n or 1, block_orientation=block_orientation,
+                     rotate=rotate or 0, blocks_arranged_in_reverse_order=inreverse)
+    print("Created device")
+
+    # start demo
+    msg = "MAX7219 LED Matrix Demo"
+    print(msg)
+    show_message(device, msg, fill="white", font=proportional(CP437_FONT))
+    time.sleep(1)
+
+    msg = "Fast scrolling: Lorem ipsum dolor sit amet, consectetur adipiscing\
+    elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut\
+    enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut\
+    aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\
+    voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint\
+    occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit\
+    anim id est laborum."
+    msg = re.sub(" +", " ", msg)
+    print(msg)
+    show_message(device, msg, fill="white", font=proportional(LCD_FONT), scroll_delay=0)
+
+    msg = "Slow scrolling: The quick brown fox jumps over the lazy dog"
+    print(msg)
+    show_message(device, msg, fill="white", font=proportional(LCD_FONT), scroll_delay=0.1)
+
+    print("Vertical scrolling")
+    words = [
+        "Victor", "Echo", "Romeo", "Tango", "India", "Charlie", "Alpha",
+        "Lima", " ", "Sierra", "Charlie", "Romeo", "Oscar", "Lima", "Lima",
+        "India", "November", "Golf", " "
+    ]
+
+    virtual = viewport(device, width=device.width, height=len(words) * 8)
+    with canvas(virtual) as draw:
+        for i, word in enumerate(words):
+            text(draw, (0, i * 8), word, fill="white", font=proportional(CP437_FONT))
+
+    for i in range(virtual.height - device.height):
+        virtual.set_position((0, i))
+        time.sleep(0.05)
+
+    msg = "Brightness"
+    print(msg)
+    show_message(device, msg, fill="white")
+
+    time.sleep(1)
+    with canvas(device) as draw:
+        text(draw, (0, 0), "A", fill="white")
+
+    time.sleep(1)
+    for _ in range(5):
+        for intensity in range(16):
+            device.contrast(intensity * 16)
+            time.sleep(0.1)
+
+    device.contrast(0x80)
+    time.sleep(1)
+
+    msg = "Alternative font!"
+    print(msg)
+    show_message(device, msg, fill="white", font=SINCLAIR_FONT)
+
+    time.sleep(1)
+    msg = "Proportional font - characters are squeezed together!"
+    print(msg)
+    show_message(device, msg, fill="white", font=proportional(SINCLAIR_FONT))
+
+    # http://www.squaregear.net/fonts/tiny.shtml
+    time.sleep(1)
+    msg = "Tiny is, I believe, the smallest possible font \
+    (in pixel size). It stands at a lofty four pixels \
+    tall (five if you count descenders), yet it still \
+    contains all the printable ASCII characters."
+    msg = re.sub(" +", " ", msg)
+    print(msg)
+    show_message(device, msg, fill="white", font=proportional(TINY_FONT))
+
+    time.sleep(1)
+    msg = "CP437 Characters"
+    print(msg)
+    show_message(device, msg)
+
+    time.sleep(1)
+    for x in range(256):
+        with canvas(device) as draw:
+            text(draw, (0, 0), chr(x), fill="white")
+            time.sleep(0.1)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description='matrix_demo arguments',
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+
+    parser.add_argument('--cascaded', '-n', type=int, default=1, help='Number of cascaded MAX7219 LED matrices')
+    parser.add_argument('--block-orientation', type=int, default=0, choices=[0, 90, -90], help='Corrects block orientation when wired vertically')
+    parser.add_argument('--rotate', type=int, default=0, choices=[0, 1, 2, 3], help='Rotate display 0=0°, 1=90°, 2=180°, 3=270°')
+    parser.add_argument('--reverse-order', type=bool, default=False, help='Set to true if blocks are in reverse order')
+
+    args = parser.parse_args()
+
+    try:
+        demo(args.cascaded, args.block_orientation, args.rotate, args.reverse_order)
+    except KeyboardInterrupt:
+        pass

+ 16 - 0
examples/neopixel_crawl.py

@@ -0,0 +1,16 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+import time
+
+from luma.led_matrix.device import neopixel
+from luma.core.render import canvas
+
+device = neopixel(cascaded=32)
+
+for i in range(device.cascaded):
+    with canvas(device) as draw:
+        draw.point((i, 0), fill="green")
+    time.sleep(0.5)

+ 220 - 0
examples/neopixel_demo.py

@@ -0,0 +1,220 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+# Portions of this script were adapted from:
+#  https://github.com/pimoroni/unicorn-hat/blob/master/examples/demo.py
+
+import math
+import time
+import colorsys
+
+from luma.led_matrix.device import neopixel
+from luma.core.render import canvas
+from luma.core.legacy import text, show_message
+from luma.core.legacy.font import proportional, TINY_FONT
+
+# create matrix device
+device = neopixel(width=8, height=4)
+
+
+# twisty swirly goodness
+def swirl(x, y, step):
+    x -= (device.width / 2)
+    y -= (device.height / 2)
+
+    dist = math.sqrt(pow(x, 2) + pow(y, 2)) / 2.0
+    angle = (step / 10.0) + (dist * 1.5)
+    s = math.sin(angle)
+    c = math.cos(angle)
+
+    xs = x * c - y * s
+    ys = x * s + y * c
+
+    r = abs(xs + ys)
+    r = r * 64.0
+    r -= 20
+
+    return (r, r + (s * 130), r + (c * 130))
+
+
+# roto-zooming checker board
+def checker(x, y, step):
+    x -= (device.width / 2)
+    y -= (device.height / 2)
+
+    angle = (step / 10.0)
+    s = math.sin(angle)
+    c = math.cos(angle)
+
+    xs = x * c - y * s
+    ys = x * s + y * c
+
+    xs -= math.sin(step / 200.0) * 40.0
+    ys -= math.cos(step / 200.0) * 40.0
+
+    scale = step % 20
+    scale /= 20
+    scale = (math.sin(step / 50.0) / 8.0) + 0.25
+
+    xs *= scale
+    ys *= scale
+
+    xo = abs(xs) - int(abs(xs))
+    yo = abs(ys) - int(abs(ys))
+    l = 0 if (math.floor(xs) + math.floor(ys)) % 2 else 1 if xo > .1 and yo > .1 else .5
+
+    r, g, b = colorsys.hsv_to_rgb((step % 255) / 255.0, 1, l)
+
+    return (r * 255, g * 255, b * 255)
+
+
+# weeee waaaah
+def blues_and_twos(x, y, step):
+    x -= (device.width / 2)
+    y -= (device.height / 2)
+
+#    xs = (math.sin((x + step) / 10.0) / 2.0) + 1.0
+#    ys = (math.cos((y + step) / 10.0) / 2.0) + 1.0
+
+    scale = math.sin(step / 6.0) / 1.5
+    r = math.sin((x * scale) / 1.0) + math.cos((y * scale) / 1.0)
+    b = math.sin(x * scale / 2.0) + math.cos(y * scale / 2.0)
+    g = r - .8
+    g = 0 if g < 0 else g
+
+    b -= r
+    b /= 1.4
+
+    return (r * 255, (b + g) * 255, g * 255)
+
+
+# rainbow search spotlights
+def rainbow_search(x, y, step):
+    xs = math.sin((step) / 100.0) * 20.0
+    ys = math.cos((step) / 100.0) * 20.0
+
+    scale = ((math.sin(step / 60.0) + 1.0) / 5.0) + 0.2
+    r = math.sin((x + xs) * scale) + math.cos((y + xs) * scale)
+    g = math.sin((x + xs) * scale) + math.cos((y + ys) * scale)
+    b = math.sin((x + ys) * scale) + math.cos((y + ys) * scale)
+
+    return (r * 255, g * 255, b * 255)
+
+
+# zoom tunnel
+def tunnel(x, y, step):
+
+    speed = step / 100.0
+    x -= (device.width / 2)
+    y -= (device.height / 2)
+
+    xo = math.sin(step / 27.0) * 2
+    yo = math.cos(step / 18.0) * 2
+
+    x += xo
+    y += yo
+
+    if y == 0:
+        if x < 0:
+            angle = -(math.pi / 2)
+        else:
+            angle = (math.pi / 2)
+    else:
+        angle = math.atan(x / y)
+
+    if y > 0:
+        angle += math.pi
+
+    angle /= 2 * math.pi  # convert angle to 0...1 range
+
+    shade = math.sqrt(math.pow(x, 2) + math.pow(y, 2)) / 2.1
+    shade = 1 if shade > 1 else shade
+
+    angle += speed
+    depth = speed + (math.sqrt(math.pow(x, 2) + math.pow(y, 2)) / 10)
+
+    col1 = colorsys.hsv_to_rgb((step % 255) / 255.0, 1, .8)
+    col2 = colorsys.hsv_to_rgb((step % 255) / 255.0, 1, .3)
+
+    col = col1 if int(abs(angle * 6.0)) % 2 == 0 else col2
+
+    td = .3 if int(abs(depth * 3.0)) % 2 == 0 else 0
+
+    col = (col[0] + td, col[1] + td, col[2] + td)
+
+    col = (col[0] * shade, col[1] * shade, col[2] * shade)
+
+    return (col[0] * 255, col[1] * 255, col[2] * 255)
+
+
+def gfx(device):
+    effects = [tunnel, rainbow_search, checker, swirl]
+
+    step = 0
+    while True:
+        for i in range(500):
+            with canvas(device) as draw:
+                for y in range(device.height):
+                    for x in range(device.width):
+                        r, g, b = effects[0](x, y, step)
+                        if i > 400:
+                            r2, g2, b2 = effects[-1](x, y, step)
+
+                            ratio = (500.00 - i) / 100.0
+                            r = r * ratio + r2 * (1.0 - ratio)
+                            g = g * ratio + g2 * (1.0 - ratio)
+                            b = b * ratio + b2 * (1.0 - ratio)
+                        r = int(max(0, min(255, r)))
+                        g = int(max(0, min(255, g)))
+                        b = int(max(0, min(255, b)))
+                        draw.point((x, y), (r, g, b))
+
+            step += 1
+
+            time.sleep(0.01)
+
+        effect = effects.pop()
+        effects.insert(0, effect)
+
+
+def main():
+    msg = "Neopixel WS2812 LED Matrix Demo"
+    show_message(device, msg, y_offset=-1, fill="green", font=proportional(TINY_FONT))
+    time.sleep(1)
+
+    with canvas(device) as draw:
+        text(draw, (0, -1), txt="A", fill="red", font=TINY_FONT)
+        text(draw, (4, -1), txt="T", fill="green", font=TINY_FONT)
+
+    time.sleep(1)
+
+    with canvas(device) as draw:
+        draw.line((0, 0, 0, device.height), fill="red")
+        draw.line((1, 0, 1, device.height), fill="orange")
+        draw.line((2, 0, 2, device.height), fill="yellow")
+        draw.line((3, 0, 3, device.height), fill="green")
+        draw.line((4, 0, 4, device.height), fill="blue")
+        draw.line((5, 0, 5, device.height), fill="indigo")
+        draw.line((6, 0, 6, device.height), fill="violet")
+        draw.line((7, 0, 7, device.height), fill="white")
+
+    time.sleep(4)
+
+    for _ in range(5):
+        for intensity in range(16):
+            device.contrast(intensity * 16)
+            time.sleep(0.1)
+
+    device.contrast(0x80)
+    time.sleep(1)
+
+    gfx(device)
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except KeyboardInterrupt:
+        pass

+ 94 - 0
examples/neosegment_demo.py

@@ -0,0 +1,94 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+import time
+import random
+import colorsys
+from luma.led_matrix.device import neosegment
+from neopixel_demo import gfx
+
+
+def rainbow(n=1000, saturation=1, value=1):
+    """
+    A generator that yields 'n' hues from the rainbow in the hex format #RRGGBB.
+    By default the saturation and value (from HSV) are both set to 1.
+    """
+    for i in range(n):
+        hue = i / float(n)
+        color = [int(x * 255) for x in colorsys.hsv_to_rgb(hue, saturation, value)]
+        yield ("#%02x%02x%02x" % tuple(color)).upper()
+
+
+def main():
+    neoseg = neosegment(width=6)
+    neoseg.text = "NEOSEG"
+    time.sleep(1)
+    neoseg.color[0] = "yellow"
+    time.sleep(1)
+    neoseg.color[3:5] = ["blue", "orange"]
+    time.sleep(1)
+    neoseg.color = "white"
+    time.sleep(1)
+
+    for _ in range(10):
+        neoseg.device.hide()
+        time.sleep(0.1)
+        neoseg.device.show()
+        time.sleep(0.1)
+
+    time.sleep(1)
+
+    for color in rainbow(200):
+        neoseg.color = color
+        time.sleep(0.01)
+
+    colors = list(rainbow(neoseg.device.width))
+    for _ in range(50):
+        random.shuffle(colors)
+        neoseg.color = colors
+        time.sleep(0.1)
+
+    neoseg.color = "white"
+    time.sleep(3)
+
+    for _ in range(3):
+        for intensity in range(16):
+            neoseg.device.contrast((15 - intensity) * 16)
+            time.sleep(0.1)
+
+        for intensity in range(16):
+            neoseg.device.contrast(intensity * 16)
+            time.sleep(0.1)
+
+    neoseg.text = ""
+    neoseg.device.contrast(0x80)
+    time.sleep(1)
+
+    neoseg.text = "rgb"
+    time.sleep(1)
+    neoseg.color[0] = "red"
+    time.sleep(1)
+    neoseg.color[1] = "green"
+    time.sleep(1)
+    neoseg.color[2] = "blue"
+    time.sleep(5)
+
+    for _ in range(3):
+        for intensity in range(16):
+            neoseg.device.contrast(intensity * 16)
+            time.sleep(0.1)
+
+    neoseg.text = ""
+    neoseg.device.contrast(0x80)
+    time.sleep(1)
+
+    gfx(neoseg.device)
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except KeyboardInterrupt:
+        pass

+ 117 - 0
examples/sevensegment_demo.py

@@ -0,0 +1,117 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+"""
+Example for seven segment displays.
+"""
+
+import time
+from datetime import datetime
+
+from luma.led_matrix.device import max7219
+from luma.core.interface.serial import spi, noop
+from luma.core.virtual import viewport, sevensegment
+
+
+def date(seg):
+    """
+    Display current date on device.
+    """
+    now = datetime.now()
+    seg.text = now.strftime("%y-%m-%d")
+
+
+def clock(seg, seconds):
+    """
+    Display current time on device.
+    """
+    interval = 0.5
+    for i in range(int(seconds / interval)):
+        now = datetime.now()
+        seg.text = now.strftime("%H-%M-%S")
+
+        # calculate blinking dot
+        if i % 2 == 0:
+            seg.text = now.strftime("%H-%M-%S")
+        else:
+            seg.text = now.strftime("%H %M %S")
+
+        time.sleep(interval)
+
+
+def show_message_vp(device, msg, delay=0.1):
+    # Implemented with virtual viewport
+    width = device.width
+    padding = " " * width
+    msg = padding + msg + padding
+    n = len(msg)
+
+    virtual = viewport(device, width=n, height=8)
+    sevensegment(virtual).text = msg
+    for i in reversed(list(range(n - width))):
+        virtual.set_position((i, 0))
+        time.sleep(delay)
+
+
+def show_message_alt(seg, msg, delay=0.1):
+    # Does same as above but does string slicing itself
+    width = seg.device.width
+    padding = " " * width
+    msg = padding + msg + padding
+
+    for i in range(len(msg)):
+        seg.text = msg[i:i + width]
+        time.sleep(delay)
+
+
+def main():
+    # create seven segment device
+    serial = spi(port=0, device=0, gpio=noop())
+    device = max7219(serial, cascaded=1)
+    seg = sevensegment(device)
+
+    print('Simple text...')
+    for _ in range(8):
+        seg.text = "HELLO"
+        time.sleep(0.6)
+        seg.text = " GOODBYE"
+        time.sleep(0.6)
+
+    # Digit slicing
+    print("Digit slicing")
+    seg.text = "_" * seg.device.width
+    time.sleep(1.0)
+
+    for i, ch in enumerate([9, 8, 7, 6, 5, 4, 3, 2]):
+        seg.text[i] = str(ch)
+        time.sleep(0.6)
+
+    for i in range(len(seg.text)):
+        del seg.text[0]
+        time.sleep(0.6)
+
+    # Scrolling Alphabet Text
+    print('Scrolling alphabet text...')
+    show_message_vp(device, "HELLO EVERYONE!")
+    show_message_vp(device, "PI is 3.14159 ... ")
+    show_message_vp(device, "IP is 127.0.0.1 ... ")
+    show_message_alt(seg, "0123456789 abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ")
+
+    # Digit futzing
+    date(seg)
+    time.sleep(5)
+    clock(seg, seconds=10)
+
+    # Brightness
+    print('Brightness...')
+    for x in range(5):
+        for intensity in range(16):
+            seg.device.contrast(intensity * 16)
+            time.sleep(0.1)
+    device.contrast(0x7F)
+
+
+if __name__ == '__main__':
+    main()

+ 81 - 0
examples/silly_clock.py

@@ -0,0 +1,81 @@
+#!/usr/bin/env python
+import time
+from datetime import datetime
+
+from luma.led_matrix.device import max7219
+from luma.core.interface.serial import spi, noop
+from luma.core.render import canvas
+from luma.core.legacy import text, show_message
+from luma.core.legacy.font import proportional, CP437_FONT, TINY_FONT
+
+
+def minute_change(device):
+    '''When we reach a minute change, animate it.'''
+    hours = datetime.now().strftime('%H')
+    minutes = datetime.now().strftime('%M')
+
+    def helper(current_y):
+        with canvas(device) as draw:
+            text(draw, (0, 1), hours, fill="white", font=proportional(CP437_FONT))
+            text(draw, (15, 1), ":", fill="white", font=proportional(TINY_FONT))
+            text(draw, (17, current_y), minutes, fill="white", font=proportional(CP437_FONT))
+        time.sleep(0.1)
+    for current_y in range(1, 9):
+        helper(current_y)
+    minutes = datetime.now().strftime('%M')
+    for current_y in range(9, 1, -1):
+        helper(current_y)
+
+
+def animation(device, from_y, to_y):
+    '''Animate the whole thing, moving it into/out of the abyss.'''
+    hourstime = datetime.now().strftime('%H')
+    mintime = datetime.now().strftime('%M')
+    current_y = from_y
+    while current_y != to_y:
+        with canvas(device) as draw:
+            text(draw, (0, current_y), hourstime, fill="white", font=proportional(CP437_FONT))
+            text(draw, (15, current_y), ":", fill="white", font=proportional(TINY_FONT))
+            text(draw, (17, current_y), mintime, fill="white", font=proportional(CP437_FONT))
+        time.sleep(0.1)
+        current_y += 1 if to_y > from_y else -1
+
+
+def main():
+    # Setup for Banggood version of 4 x 8x8 LED Matrix (https://bit.ly/2Gywazb)
+    serial = spi(port=0, device=0, gpio=noop())
+    device = max7219(serial, cascaded=4, block_orientation=-90, blocks_arranged_in_reverse_order=True)
+    device.contrast(16)
+
+    # The time ascends from the abyss...
+    animation(device, 8, 1)
+
+    toggle = False  # Toggle the second indicator every second
+    while True:
+        toggle = not toggle
+        sec = datetime.now().second
+        if sec == 59:
+            # When we change minutes, animate the minute change
+            minute_change(device)
+        elif sec == 30:
+            # Half-way through each minute, display the complete date/time,
+            # animating the time display into and out of the abyss.
+            full_msg = time.ctime()
+            animation(device, 1, 8)
+            show_message(device, full_msg, fill="white", font=proportional(CP437_FONT))
+            animation(device, 8, 1)
+        else:
+            # Do the following twice a second (so the seconds' indicator blips).
+            # I'd optimize if I had to - but what's the point?
+            # Even my Raspberry PI2 can do this at 4% of a single one of the 4 cores!
+            hours = datetime.now().strftime('%H')
+            minutes = datetime.now().strftime('%M')
+            with canvas(device) as draw:
+                text(draw, (0, 1), hours, fill="white", font=proportional(CP437_FONT))
+                text(draw, (15, 1), ":" if toggle else " ", fill="white", font=proportional(TINY_FONT))
+                text(draw, (17, 1), minutes, fill="white", font=proportional(CP437_FONT))
+            time.sleep(0.5)
+
+
+if __name__ == "__main__":
+    main()

+ 41 - 0
examples/view_message.py

@@ -0,0 +1,41 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Commandline Wrapper
+# Thomas Wenzlaff
+# See LICENSE.rst for details.
+
+import time
+import argparse
+
+from luma.led_matrix.device import max7219
+from luma.core.interface.serial import spi, noop
+from luma.core.legacy import show_message
+from luma.core.legacy.font import proportional, CP437_FONT
+
+
+def output(n, block_orientation, rotate, inreverse, text):
+    # create matrix device
+    serial = spi(port=0, device=0, gpio=noop())
+    device = max7219(serial, cascaded=n or 1, block_orientation=block_orientation,
+                     rotate=rotate or 0, blocks_arranged_in_reverse_order=inreverse)
+    print(text)
+
+    show_message(device, text, fill="white", font=proportional(CP437_FONT), scroll_delay=0.05)
+    time.sleep(1)
+
+
+if __name__ == "__main__":
+    parser = argparse.ArgumentParser(description='view_message arguments',
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+
+    parser.add_argument('--cascaded', '-n', type=int, default=1, help='Number of cascaded MAX7219 LED matrices')
+    parser.add_argument('--block-orientation', type=int, default=0, choices=[0, 90, -90], help='Corrects block orientation when wired vertically')
+    parser.add_argument('--rotate', type=int, default=0, choices=[0, 1, 2, 3], help='Rotate display 0=0°, 1=90°, 2=180°, 3=270°')
+    parser.add_argument('--reverse-order', type=bool, default=False, help='Set to true if blocks are in reverse order')
+    parser.add_argument('--text', '-t', default='>>> No text set', help='Set text message')
+    args = parser.parse_args()
+
+    try:
+        output(args.cascaded, args.block_orientation, args.rotate, args.reverse_order, args.text)
+    except KeyboardInterrupt:
+        pass

+ 8 - 0
luma/__init__.py

@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-2019 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+try:
+    __import__('pkg_resources').declare_namespace(__name__)
+except ImportError:
+    __path__ = __import__('pkgutil').extend_path(__path__, __name__)

+ 10 - 0
luma/led_matrix/__init__.py

@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+"""
+Display drivers for LED Matrices & 7-segment displays (MAX7219) and
+RGB NeoPixels (WS2812 / APA102).
+"""
+
+__version__ = '1.5.0'

+ 13 - 0
luma/led_matrix/const.py

@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+
+class max7219(object):
+    NOOP = 0x00
+    DIGIT_0 = 0x01
+    DECODEMODE = 0x09
+    INTENSITY = 0x0A
+    SCANLIMIT = 0x0B
+    SHUTDOWN = 0x0C
+    DISPLAYTEST = 0x0F

+ 624 - 0
luma/led_matrix/device.py

@@ -0,0 +1,624 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-2020 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+"""
+Collection of serial interfaces to LED matrix devices.
+"""
+
+# Example usage:
+#
+#   from luma.core.interface.serial import spi, noop
+#   from luma.core.render import canvas
+#   from luma.led_matrix.device import max7219
+#
+#   serial = spi(port=0, device=0, gpio=noop())
+#   device = max7219(serial, width=8, height=8)
+#
+#   with canvas(device) as draw:
+#      draw.rectangle(device.bounding_box, outline="white", fill="black")
+#
+# 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 luma.core.error
+import luma.led_matrix.const
+from luma.core.interface.serial import noop
+from luma.core.device import device
+from luma.core.render import canvas
+from luma.core.util import observable
+from luma.core.virtual import sevensegment
+from luma.led_matrix.segment_mapper import dot_muncher, regular
+
+
+__all__ = ["max7219", "ws2812", "neopixel", "neosegment", "apa102", "unicornhathd"]
+
+
+class max7219(device):
+    """
+    Serial interface to a series of 8x8 LED matrixes daisychained together with
+    MAX7219 chips.
+
+    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=8, height=8, cascaded=None, rotate=0,
+                 block_orientation=0, blocks_arranged_in_reverse_order=False, contrast=0x70,
+                 **kwargs):
+        super(max7219, self).__init__(luma.led_matrix.const.max7219, serial_interface)
+
+        # Derive (override) the width and height if a cascaded param supplied
+        if cascaded is not None:
+            width = cascaded * 8
+            height = 8
+
+        self.blocks_arranged_in_reverse_order = blocks_arranged_in_reverse_order
+        self.capabilities(width, height, rotate)
+        self.segment_mapper = dot_muncher
+
+        if width <= 0 or width % 8 != 0 or height <= 0 or height % 8 != 0:
+            raise luma.core.error.DeviceDisplayModeError(
+                f"Unsupported display mode: {width} x {height}")
+
+        assert block_orientation in [0, 90, -90, 180]
+        self._correction_angle = block_orientation
+
+        self.cascaded = cascaded or (width * height) // 64
+        self._offsets = [(y * self._w) + x
+                         for y in range(self._h - 8, -8, -8)
+                         for x in range(self._w - 8, -8, -8)]
+        self._rows = list(range(8))
+
+        self.data([self._const.SCANLIMIT, 7] * self.cascaded)
+        self.data([self._const.DECODEMODE, 0] * self.cascaded)
+        self.data([self._const.DISPLAYTEST, 0] * self.cascaded)
+
+        self.contrast(contrast)
+        self.clear()
+        self.show()
+
+    def preprocess(self, image):
+        """
+        Performs the inherited behviour (if any), and if the LED matrix
+        orientation is declared to need correction, each 8x8 block of pixels
+        is rotated 90° clockwise or counter-clockwise.
+        """
+        image = super(max7219, self).preprocess(image)
+
+        if self._correction_angle != 0:
+            image = image.copy()
+            for y in range(0, self._h, 8):
+                for x in range(0, self._w, 8):
+                    box = (x, y, x + 8, y + 8)
+                    rotated_block = image.crop(box).rotate(self._correction_angle)
+                    image.paste(rotated_block, box)
+        if self.blocks_arranged_in_reverse_order:
+            old_image = image.copy()
+            for y in range(8):
+                for x in range(8):
+                    for i in range(self.cascaded):
+                        image.putpixel((8 * (self.cascaded - 1) - i * 8 + x, y), old_image.getpixel((i * 8 + x, y)))
+
+        return image
+
+    def display(self, image):
+        """
+        Takes a 1-bit :py:mod:`PIL.Image` and dumps it to the LED matrix display
+        via the MAX7219 serializers.
+        """
+        assert(image.mode == self.mode)
+        assert(image.size == self.size)
+
+        image = self.preprocess(image)
+
+        i = 0
+        d0 = self._const.DIGIT_0
+        step = 2 * self.cascaded
+        offsets = self._offsets
+        rows = self._rows
+
+        buf = bytearray(8 * step)
+        pix = list(image.getdata())
+
+        for digit in range(8):
+            for daisychained_device in offsets:
+                byte = 0
+                idx = daisychained_device + digit
+                for y in rows:
+                    if pix[idx] > 0:
+                        byte |= 1 << y
+                    idx += self._w
+
+                buf[i] = digit + d0
+                buf[i + 1] = byte
+                i += 2
+
+        buf = list(buf)
+        for i in range(0, len(buf), step):
+            self.data(buf[i:i + step])
+
+    def contrast(self, value):
+        """
+        Sets the LED intensity to the desired level, in the range 0-255.
+
+        :param level: Desired contrast level in the range of 0-255.
+        :type level: int
+        """
+        assert(0x00 <= value <= 0xFF)
+        self.data([self._const.INTENSITY, value >> 4] * self.cascaded)
+
+    def show(self):
+        """
+        Sets the display mode ON, waking the device out of a prior
+        low-power sleep mode.
+        """
+        self.data([self._const.SHUTDOWN, 1] * self.cascaded)
+
+    def hide(self):
+        """
+        Switches the display mode OFF, putting the device in low-power
+        sleep mode.
+        """
+        self.data([self._const.SHUTDOWN, 0] * self.cascaded)
+
+
+class ws2812(device):
+    """
+    Serial interface to a series of RGB neopixels daisy-chained together with
+    WS281x chips.
+
+    On creation, the array is initialized with the correct number of cascaded
+    devices. Further control commands can then be called to affect the
+    brightness and other settings.
+
+    :param dma_interface: The WS2812 interface to write to (usually omit this
+        parameter and it will default to the correct value - it is only needed
+        for testing whereby a mock implementation is supplied).
+    :param width: The number of pixels laid out horizontally.
+    :type width: int
+    :param height: The number of pixels laid out vertically.
+    :type width: int
+    :param cascaded: The number of pixels in a single strip - if supplied, this
+        will override ``width`` and ``height``.
+    :type cascaded: int
+    :param rotate: Whether the device dimenstions should be rotated in-situ:
+        A value of: 0=0°, 1=90°, 2=180°, 3=270°. If not supplied, zero is
+        assumed.
+    :type rotate: int
+    :param mapping: An (optional) array of integer values that translate the
+        pixel to physical offsets. If supplied, should be the same size as
+        ``width * height``.
+    :type mapping: int[]
+
+    .. versionadded:: 0.4.0
+    """
+    def __init__(self, dma_interface=None, width=8, height=4, cascaded=None,
+                 rotate=0, mapping=None, **kwargs):
+        super(ws2812, self).__init__(const=None, serial_interface=noop)
+
+        # Derive (override) the width and height if a cascaded param supplied
+        if cascaded is not None:
+            width = cascaded
+            height = 1
+
+        self.cascaded = width * height
+        self.capabilities(width, height, rotate, mode="RGB")
+        self._mapping = list(mapping or range(self.cascaded))
+        assert(self.cascaded == len(self._mapping))
+        self._contrast = None
+        self._prev_contrast = 0x70
+
+        ws = self._ws = dma_interface or self.__ws281x__()
+
+        # Create ws2811_t structure and fill in parameters.
+        self._leds = ws.new_ws2811_t()
+
+        pin = 18
+        channel = 0
+        dma = 10
+        freq_hz = 800000
+        brightness = 255
+        strip_type = ws.WS2811_STRIP_GRB
+        invert = False
+
+        # Initialize the channels to zero
+        for channum in range(2):
+            chan = ws.ws2811_channel_get(self._leds, channum)
+            ws.ws2811_channel_t_count_set(chan, 0)
+            ws.ws2811_channel_t_gpionum_set(chan, 0)
+            ws.ws2811_channel_t_invert_set(chan, 0)
+            ws.ws2811_channel_t_brightness_set(chan, 0)
+
+        # Initialize the channel in use
+        self._channel = ws.ws2811_channel_get(self._leds, channel)
+        ws.ws2811_channel_t_count_set(self._channel, self.cascaded)
+        ws.ws2811_channel_t_gpionum_set(self._channel, pin)
+        ws.ws2811_channel_t_invert_set(self._channel, 0 if not invert else 1)
+        ws.ws2811_channel_t_brightness_set(self._channel, brightness)
+        ws.ws2811_channel_t_strip_type_set(self._channel, strip_type)
+
+        # Initialize the controller
+        ws.ws2811_t_freq_set(self._leds, freq_hz)
+        ws.ws2811_t_dmanum_set(self._leds, dma)
+
+        resp = ws.ws2811_init(self._leds)
+        if resp != 0:
+            raise RuntimeError(f'ws2811_init failed with code {resp}')
+
+        self.clear()
+        self.show()
+
+    def __ws281x__(self):
+        import _rpi_ws281x
+        return _rpi_ws281x
+
+    def display(self, image):
+        """
+        Takes a 24-bit RGB :py:mod:`PIL.Image` and dumps it to the daisy-chained
+        WS2812 neopixels.
+        """
+        assert(image.mode == self.mode)
+        assert(image.size == self.size)
+
+        ws = self._ws
+        m = self._mapping
+        for idx, (red, green, blue) in enumerate(image.getdata()):
+            color = (red << 16) | (green << 8) | blue
+            ws.ws2811_led_set(self._channel, m[idx], color)
+
+        self._flush()
+
+    def show(self):
+        """
+        Simulates switching the display mode ON; this is achieved by restoring
+        the contrast to the level prior to the last time hide() was called.
+        """
+        if self._prev_contrast is not None:
+            self.contrast(self._prev_contrast)
+            self._prev_contrast = None
+
+    def hide(self):
+        """
+        Simulates switching the display mode OFF; this is achieved by setting
+        the contrast level to zero.
+        """
+        if self._prev_contrast is None:
+            self._prev_contrast = self._contrast
+            self.contrast(0x00)
+
+    def contrast(self, value):
+        """
+        Sets the LED intensity to the desired level, in the range 0-255.
+
+        :param level: Desired contrast level in the range of 0-255.
+        :type level: int
+        """
+        assert(0x00 <= value <= 0xFF)
+        self._contrast = value
+        self._ws.ws2811_channel_t_brightness_set(self._channel, value)
+        self._flush()
+
+    def _flush(self):
+        resp = self._ws.ws2811_render(self._leds)
+        if resp != 0:
+            raise RuntimeError('ws2811_render failed with code {0}'.format(resp))
+
+    def __del__(self):
+        # Required because Python will complain about memory leaks
+        # However there's no guarantee that "ws" will even be set
+        # when the __del__ method for this class is reached.
+        if self._ws is not None:
+            self.cleanup()
+
+    def cleanup(self):
+        """
+        Attempt to reset the device & switching it off prior to exiting the
+        python process.
+        """
+        self.hide()
+        self.clear()
+
+        if self._leds is not None:
+            self._ws.ws2811_fini(self._leds)
+            self._ws.delete_ws2811_t(self._leds)
+            self._leds = None
+            self._channel = None
+
+
+# Alias for ws2812
+neopixel = ws2812
+
+# 8x8 Unicorn HAT has a 'snake-like' layout, so this translation
+# mapper linearizes that arrangement into a 'scan-like' layout.
+UNICORN_HAT = [
+    7,  6,  5,  4,  3,  2,  1,  0,
+    8,  9,  10, 11, 12, 13, 14, 15,
+    23, 22, 21, 20, 19, 18, 17, 16,
+    24, 25, 26, 27, 28, 29, 30, 31,
+    39, 38, 37, 36, 35, 34, 33, 32,
+    40, 41, 42, 43, 44, 45, 46, 47,
+    55, 54, 53, 52, 51, 50, 49, 48,
+    56, 57, 58, 59, 60, 61, 62, 63
+]
+
+
+class apa102(device):
+    """
+    Serial interface to a series of 'next-gen' RGB DotStar daisy-chained
+    together with APA102 chips.
+
+    On creation, the array is initialized with the correct number of cascaded
+    devices. Further control commands can then be called to affect the brightness
+    and other settings.
+
+    Note that the brightness of individual pixels can be set by altering the
+    alpha channel of the RGBA image that is being displayed.
+
+    :param serial_interface: The serial interface to write to (usually omit this
+        parameter and it will default to the correct value - it is only needed
+        for testing whereby a mock implementation is supplied).
+    :param width: The number of pixels laid out horizontally.
+    :type width: int
+    :param height: The number of pixels laid out vertically.
+    :type width: int
+    :param cascaded: The number of pixels in a single strip - if supplied, this
+        will override ``width`` and ``height``.
+    :type cascaded: int
+    :param rotate: Whether the device dimenstions should be rotated in-situ:
+        A value of: 0=0°, 1=90°, 2=180°, 3=270°. If not supplied, zero is
+        assumed.
+    :type rotate: int
+    :param mapping: An (optional) array of integer values that translate the
+        pixel to physical offsets. If supplied, should be the same size as
+        ``width * height``.
+    :type mapping: int[]
+
+    .. versionadded:: 0.9.0
+    """
+    def __init__(self, serial_interface=None, width=8, height=1, cascaded=None,
+                 rotate=0, mapping=None, **kwargs):
+        super(apa102, self).__init__(luma.core.const.common, serial_interface or self.__bitbang__())
+
+        # Derive (override) the width and height if a cascaded param supplied
+        if cascaded is not None:
+            width = cascaded
+            height = 1
+
+        self.cascaded = width * height
+        self.capabilities(width, height, rotate, mode="RGBA")
+        self._mapping = list(mapping or range(self.cascaded))
+        assert(self.cascaded == len(self._mapping))
+        self._last_image = None
+
+        self.contrast(0x70)
+        self.clear()
+        self.show()
+
+    def __bitbang__(self):
+        from luma.core.interface.serial import bitbang
+        return bitbang(SCLK=24, SDA=23)
+
+    def display(self, image):
+        """
+        Takes a 32-bit RGBA :py:mod:`PIL.Image` and dumps it to the daisy-chained
+        APA102 neopixels. If a pixel is not fully opaque, the alpha channel
+        value is used to set the brightness of the respective RGB LED.
+        """
+        assert(image.mode == self.mode)
+        assert(image.size == self.size)
+        self._last_image = image.copy()
+
+        # Send zeros to reset, then pixel values then zeros at end
+        sz = image.width * image.height * 4
+        buf = bytearray(sz * 3)
+
+        m = self._mapping
+        for idx, (r, g, b, a) in enumerate(image.getdata()):
+            offset = sz + m[idx] * 4
+            brightness = (a >> 4) if a != 0xFF else self._brightness
+            buf[offset] = (0xE0 | brightness)
+            buf[offset + 1] = b
+            buf[offset + 2] = g
+            buf[offset + 3] = r
+
+        self._serial_interface.data(list(buf))
+
+    def show(self):
+        """
+        Not supported
+        """
+        pass
+
+    def hide(self):
+        """
+        Not supported
+        """
+        pass
+
+    def contrast(self, value):
+        """
+        Sets the LED intensity to the desired level, in the range 0-255.
+
+        :param level: Desired contrast level in the range of 0-255.
+        :type level: int
+        """
+        assert(0x00 <= value <= 0xFF)
+        self._brightness = value >> 4
+        if self._last_image is not None:
+            self.display(self._last_image)
+
+
+class neosegment(sevensegment):
+    """
+    Extends the :py:class:`~luma.core.virtual.sevensegment` class specifically
+    for @msurguy's modular NeoSegments. It uses the same underlying render
+    techniques as the base class, but provides additional functionality to be
+    able to adddress individual characters colors.
+
+    :param width: The number of 7-segment elements that are cascaded.
+    :type width: int
+    :param undefined: The default character to substitute when an unrenderable
+        character is supplied to the text property.
+    :type undefined: char
+
+    .. versionadded:: 0.11.0
+    """
+    def __init__(self, width, undefined="_", **kwargs):
+        if width <= 0 or width % 2 == 1:
+            raise luma.core.error.DeviceDisplayModeError(
+                "Unsupported display mode: width={0}".format(width))
+
+        height = 7
+        mapping = [(i % width) * height + (i // width) for i in range(width * height)]
+        self.device = kwargs.get("device") or ws2812(width=width, height=height, mapping=mapping)
+        self.undefined = undefined
+        self._text_buffer = ""
+        self.color = "white"
+
+    @property
+    def color(self):
+        return self._colors
+
+    @color.setter
+    def color(self, value):
+        if not isinstance(value, list):
+            value = [value] * self.device.width
+
+        assert(len(value) == self.device.width)
+        self._colors = observable(value, observer=self._color_chg)
+
+    def _color_chg(self, color):
+        self._flush(self.text, color)
+
+    def _flush(self, text, color=None):
+        data = bytearray(self.segment_mapper(text, notfound=self.undefined)).ljust(self.device.width, b'\0')
+        color = color or self.color
+
+        if len(data) > self.device.width:
+            raise OverflowError(
+                "Device's capabilities insufficient for value '{0}'".format(text))
+
+        with canvas(self.device) as draw:
+            for x, byte in enumerate(data):
+                for y in range(self.device.height):
+                    if byte & 0x01:
+                        draw.point((x, y), fill=color[x])
+                    byte >>= 1
+
+    def segment_mapper(self, text, notfound="_"):
+        for char in regular(text, notfound):
+
+            # Convert from std MAX7219 segment mappings
+            a = char >> 6 & 0x01
+            b = char >> 5 & 0x01
+            c = char >> 4 & 0x01
+            d = char >> 3 & 0x01
+            e = char >> 2 & 0x01
+            f = char >> 1 & 0x01
+            g = char >> 0 & 0x01
+
+            # To NeoSegment positions
+            yield \
+                b << 6 | \
+                a << 5 | \
+                f << 4 | \
+                g << 3 | \
+                c << 2 | \
+                d << 1 | \
+                e << 0
+
+
+class unicornhathd(device):
+    """
+    Display adapter for Pimoroni's Unicorn Hat HD - a dense 16x16 array of
+    high intensity RGB LEDs. Since the board contains a small ARM chip to
+    manage the LEDs, interfacing is very straightforward using SPI. This has
+    the side-effect that the board appears not to be daisy-chainable though.
+    However there a number of undocumented contact pads on the underside of
+    the board which _may_ allow this behaviour.
+
+    Note that the brightness of individual pixels can be set by altering the
+    alpha channel of the RGBA image that is being displayed.
+
+    :param serial_interface: The serial interface to write to.
+    :param rotate: Whether the device dimenstions should be rotated in-situ:
+        A value of: 0=0°, 1=90°, 2=180°, 3=270°. If not supplied, zero is
+        assumed.
+    :type rotate: int
+
+    .. versionadded:: 1.3.0
+    """
+    def __init__(self, serial_interface=None, rotate=0, **kwargs):
+        super(unicornhathd, self).__init__(luma.core.const.common, serial_interface)
+        self.capabilities(16, 16, rotate, mode="RGBA")
+        self._last_image = None
+        self._prev_brightness = None
+        self.contrast(0x70)
+        self.clear()
+        self.show()
+
+    def display(self, image):
+        """
+        Takes a 32-bit RGBA :py:mod:`PIL.Image` and dumps it to the Unicorn HAT HD.
+        If a pixel is not fully opaque, the alpha channel value is used to set the
+        brightness of the respective RGB LED.
+        """
+        assert(image.mode == self.mode)
+        assert(image.size == self.size)
+        self._last_image = image.copy()
+
+        # Send zeros to reset, then pixel values then zeros at end
+        sz = image.width * image.height * 3
+        buf = bytearray(sz)
+        normalized_brightness = self._brightness / 255.0
+
+        for idx, (r, g, b, a) in enumerate(image.getdata()):
+            offset = idx * 3
+            brightness = a / 255.0 if a != 255 else normalized_brightness
+            buf[offset] = int(r * brightness)
+            buf[offset + 1] = int(g * brightness)
+            buf[offset + 2] = int(b * brightness)
+
+        self._serial_interface.data([0x72] + list(buf))   # 0x72 == SOF ... start of frame?
+
+    def show(self):
+        """
+        Simulates switching the display mode ON; this is achieved by restoring
+        the contrast to the level prior to the last time hide() was called.
+        """
+        if self._prev_brightness is not None:
+            self.contrast(self._prev_brightness)
+            self._prev_brightness = None
+
+    def hide(self):
+        """
+        Simulates switching the display mode OFF; this is achieved by setting
+        the contrast level to zero.
+        """
+        if self._prev_brightness is None:
+            self._prev_brightness = self._brightness
+            self.contrast(0x00)
+
+    def contrast(self, value):
+        """
+        Sets the LED intensity to the desired level, in the range 0-255.
+
+        :param level: Desired contrast level in the range of 0-255.
+        :type level: int
+        """
+        assert(0x00 <= value <= 0xFF)
+        self._brightness = value
+        if self._last_image is not None:
+            self.display(self._last_image)

+ 130 - 0
luma/led_matrix/segment_mapper.py

@@ -0,0 +1,130 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+_DIGITS = {
+    ' ': 0x00,
+    '!': 0xa0,
+    '"': 0x22,
+    '#': 0x3f,
+    '$': 0x5b,
+    '%': 0xa5,
+    "'": 0x02,
+    '(': 0x4e,
+    ')': 0x78,
+    '*': 0x49,
+    '+': 0x07,
+    ',': 0x80,
+    '-': 0x01,
+    '.': 0x80,
+    '/': 0x25,
+    '0': 0x7e,
+    '1': 0x30,
+    '2': 0x6d,
+    '3': 0x79,
+    '4': 0x33,
+    '5': 0x5b,
+    '6': 0x5f,
+    '7': 0x70,
+    '8': 0x7f,
+    '9': 0x7b,
+    ':': 0x48,
+    ';': 0x58,
+    '<': 0x0d,
+    '=': 0x09,
+    '>': 0x19,
+    '?': 0xe5,
+    '@': 0x6f,
+    'A': 0x77,
+    'B': 0x7f,
+    'C': 0x4e,
+    'D': 0x7e,
+    'E': 0x4f,
+    'F': 0x47,
+    'G': 0x5e,
+    'H': 0x37,
+    'I': 0x30,
+    'J': 0x38,
+    'K': 0x57,
+    'L': 0x0e,
+    'M': 0x54,
+    'N': 0x76,
+    'O': 0x7e,
+    'P': 0x67,
+    'Q': 0x73,
+    'R': 0x46,
+    'S': 0x5b,
+    'T': 0x0f,
+    'U': 0x3e,
+    'V': 0x3e,
+    'W': 0x2a,
+    'X': 0x37,
+    'Y': 0x3b,
+    'Z': 0x6d,
+    '[': 0x43,
+    '\\': 0x13,
+    ']': 0x61,
+    '^': 0x62,
+    '_': 0x08,
+    '`': 0x20,
+    'a': 0x7d,
+    'b': 0x1f,
+    'c': 0x0d,
+    'd': 0x3d,
+    'e': 0x6f,
+    'f': 0x47,
+    'g': 0x7b,
+    'h': 0x17,
+    'i': 0x10,
+    'j': 0x18,
+    'k': 0x57,
+    'l': 0x06,
+    'm': 0x14,
+    'n': 0x15,
+    'o': 0x1d,
+    'p': 0x67,
+    'q': 0x73,
+    'r': 0x05,
+    's': 0x5b,
+    't': 0x0f,
+    'u': 0x1c,
+    'v': 0x1c,
+    'w': 0x14,
+    'x': 0x37,
+    'y': 0x3b,
+    'z': 0x6d,
+    '{': 0x31,
+    '|': 0x06,
+    '}': 0x07,
+    '~': 0x40,
+    u'°': 0x63,
+    u'\xb0': 0x63,
+}
+
+
+def regular(text, notfound="_"):
+    undefined = _DIGITS[notfound] if notfound is not None else None
+    for char in iter(text):
+        digit = _DIGITS.get(char, undefined)
+        if digit is not None:
+            yield digit
+
+
+def dot_muncher(text, notfound="_"):
+    if not text:
+        return
+
+    undefined = _DIGITS[notfound] if notfound is not None else None
+    last = None
+    for char in iter(text):
+        curr = _DIGITS.get(char, undefined)
+
+        if curr == 0x80:
+            yield curr | (last or 0)
+        elif last != 0x80 and last is not None:
+            yield last
+
+        last = curr
+
+    if curr != 0x80 and curr is not None:
+        yield curr

+ 3 - 0
pyproject.toml

@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools >= 40.6.0", "wheel"]
+build-backend = "setuptools.build_meta"

+ 3 - 0
pytest.ini

@@ -0,0 +1,3 @@
+[pytest]
+addopts = --timeout=10 -v -r wsx
+

+ 59 - 0
setup.cfg

@@ -0,0 +1,59 @@
+[metadata]
+name = luma.led_matrix
+version = attr: luma.led_matrix.__version__
+description = A library to drive a MAX7219 LED serializer (using SPI) and WS2812 NeoPixels (using DMA)
+long_description = file: README.rst, CONTRIBUTING.rst, CHANGES.rst
+long_description_content_type = text/x-rst
+keywords = raspberry pi, rpi, led, max7219, matrix, seven segment, 7 segment, neopixel, neosegment, ws2812, ws281x, apa102, unicorn-phat, unicorn-hat, unicorn-hat-hd
+author = Richard Hull
+author_email = richard.hull@destructuring-bind.org
+url = https://github.com/rm-hull/luma.led_matrix
+license = MIT
+classifiers =
+    License :: OSI Approved :: MIT License
+    Development Status :: 5 - Production/Stable
+    Intended Audience :: Education
+    Intended Audience :: Developers
+    Topic :: Education
+    Topic :: System :: Hardware
+    Topic :: System :: Hardware :: Hardware Drivers
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3.6
+    Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
+    Programming Language :: Python :: 3.9
+
+[options]
+zip_safe = False
+packages = find:
+python_requires = >=3.6, <4
+namespace_packages = luma
+install_requires =
+    luma.core>=2.2.0
+    ws2812; platform_machine=="armv7l" and platform_system=="Linux"
+    rpi_ws281x; platform_machine=="armv7l" and platform_system=="Linux"
+tests_require =
+    pytest
+    pytest-cov
+    pytest-timeout
+
+[options.extras_require]
+docs = sphinx>=1.5.1
+qa = flake8; rstcheck
+test = pytest; pytest-cov; pytest-timeout
+
+[bdist_wheel]
+universal = 1
+
+[flake8]
+ignore = E126, E127, E128, E241, E402, E501, E731, E741
+exclude =
+    .tox,
+    # No need to traverse our git directory
+    .git,
+    .vscode,
+    # There's no value in checking cache directories
+    __pycache__,
+    doc,
+    build,
+    dist

+ 7 - 0
setup.py

@@ -0,0 +1,7 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+import setuptools
+
+if __name__ == "__main__":
+    setuptools.setup()

+ 24 - 0
tests/baseline_data.py

@@ -0,0 +1,24 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-2020 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+"""
+Collection of datasets to prevent regression bugs from creeping in.
+"""
+
+import json
+from pathlib import Path
+
+
+def get_json_data(fname):
+    """
+    Load JSON reference data.
+
+    :param fname: Filename without extension.
+    :type fname: str
+    """
+    base_dir = Path(__file__).resolve().parent
+    fpath = base_dir.joinpath('reference', 'data', fname + '.json')
+    with fpath.open() as f:
+        return json.load(f)

+ 55 - 0
tests/helpers.py

@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-2020 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+from pathlib import Path
+from unittest.mock import Mock
+
+import pytest
+from PIL import ImageChops
+
+import luma.core.error
+
+serial = Mock(unsafe=True)
+
+
+def setup_function(function):
+    """
+    Called after a test finished.
+    """
+    serial.reset_mock()
+    serial.command.side_effect = None
+
+
+def assert_invalid_dimensions(deviceType, serial_interface, width, height):
+    """
+    Assert an invalid resolution raises a
+    :py:class:`luma.core.error.DeviceDisplayModeError`.
+    """
+    with pytest.raises(luma.core.error.DeviceDisplayModeError) as ex:
+        deviceType(serial_interface, width=width, height=height)
+    assert f"Unsupported display mode: {width} x {height}" in str(ex.value)
+
+
+def get_reference_file(fname):
+    """
+    Get absolute path for ``fname``.
+
+    :param fname: Filename.
+    :type fname: str or pathlib.Path
+    :rtype: str
+    """
+    return str(Path(__file__).resolve().parent.joinpath('reference', fname))
+
+
+def get_reference_image(fname):
+    """
+    :param fname: Filename.
+    :type fname: str or pathlib.Path
+    """
+    return get_reference_file(Path('images').joinpath(fname))
+
+
+def assert_identical_image(reference, target):
+    bbox = ImageChops.difference(reference, target).getbbox()
+    assert bbox is None

+ 18 - 0
tests/reference/data/demo_unicornhathd.json

@@ -0,0 +1,18 @@
+[
+    112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 112, 112, 112,
+    112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112, 112
+]

+ 18 - 0
tests/reference/data/demo_unicornhathd_alphablend.json

@@ -0,0 +1,18 @@
+[
+    32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8,
+    32, 16, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 16, 8, 
+    32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8, 32, 16, 8
+]

BIN
tests/reference/images/neosegment.png


+ 53 - 0
tests/test_apa102.py

@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+from luma.led_matrix.device import apa102
+from luma.core.render import canvas
+
+from helpers import serial, setup_function  # noqa: F401
+
+
+def padding(n):
+    return [0] * n * 4
+
+
+def test_init_cascaded():
+    device = apa102(serial, cascaded=7)
+    assert device.width == 7
+    assert device.height == 1
+    serial.data.assert_called_with(padding(7) + [0xE0, 0, 0, 0] * 7 + padding(7))
+
+
+def test_hide():
+    device = apa102(serial, cascaded=5)
+    serial.reset_mock()
+    device.hide()
+    serial.data.assert_not_called()
+
+
+def test_show():
+    device = apa102(serial, cascaded=5)
+    serial.reset_mock()
+    device.show()
+    serial.data.assert_not_called()
+
+
+def test_contrast():
+    device = apa102(serial, cascaded=6)
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline="red")
+    serial.reset_mock()
+    device.contrast(0x6B)
+    serial.data.assert_called_with(padding(6) + [0xE6, 0, 0, 0xFF] * 6 + padding(6))
+
+
+def test_display():
+    device = apa102(serial, width=4, height=1)
+    serial.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline=(0x11, 0x22, 0x33, 0x44))
+
+    serial.data.assert_called_with(padding(4) + [0xE4, 0x33, 0x22, 0x11] * 4 + padding(4))

+ 226 - 0
tests/test_max7219.py

@@ -0,0 +1,226 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+import pytest
+
+from luma.led_matrix.device import max7219
+from luma.core.render import canvas
+
+from helpers import setup_function, serial, assert_invalid_dimensions  # noqa: F401
+from unittest.mock import call
+
+
+def test_init_cascaded():
+    device = max7219(serial, cascaded=4)
+    assert device.width == 32
+    assert device.height == 8
+
+
+def test_init_reversed():
+    device = max7219(serial, cascaded=4, blocks_arranged_in_reverse_order=True)
+    assert device.blocks_arranged_in_reverse_order is True
+
+
+def test_init_8x8():
+    device = max7219(serial)
+    assert device.cascaded == 1
+    serial.data.assert_has_calls([
+        call([11, 7]),
+        call([9, 0]),
+        call([15, 0]),
+        call([10, 7]),
+        call([1, 0]),
+        call([2, 0]),
+        call([3, 0]),
+        call([4, 0]),
+        call([5, 0]),
+        call([6, 0]),
+        call([7, 0]),
+        call([8, 0]),
+        call([12, 1])
+    ])
+
+
+def test_init_16x8():
+    device = max7219(serial, width=16, height=8)
+    assert device.cascaded == 2
+    serial.data.assert_has_calls([
+        call([11, 7, 11, 7]),
+        call([9, 0, 9, 0]),
+        call([15, 0, 15, 0]),
+        call([10, 7, 10, 7]),
+        call([1, 0, 1, 0]),
+        call([2, 0, 2, 0]),
+        call([3, 0, 3, 0]),
+        call([4, 0, 4, 0]),
+        call([5, 0, 5, 0]),
+        call([6, 0, 6, 0]),
+        call([7, 0, 7, 0]),
+        call([8, 0, 8, 0]),
+        call([12, 1, 12, 1])
+    ])
+
+
+def test_init_invalid_dimensions():
+    assert_invalid_dimensions(max7219, serial, 59, 22)
+
+
+def test_hide():
+    device = max7219(serial, cascaded=5)
+    serial.reset_mock()
+    device.hide()
+    serial.data.assert_called_once_with([12, 0] * 5)
+
+
+def test_show():
+    device = max7219(serial, cascaded=3)
+    serial.reset_mock()
+    device.show()
+    serial.data.assert_called_once_with([12, 1] * 3)
+
+
+def test_contrast():
+    device = max7219(serial, cascaded=6)
+    serial.reset_mock()
+    device.contrast(0x6B)
+    serial.data.assert_called_once_with([10, 6] * 6)
+
+
+def test_display_16x8():
+    device = max7219(serial, cascaded=2)
+    serial.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline="white")
+
+    serial.data.assert_has_calls([
+        call([1, 0x81, 1, 0xFF]),
+        call([2, 0x81, 2, 0x81]),
+        call([3, 0x81, 3, 0x81]),
+        call([4, 0x81, 4, 0x81]),
+        call([5, 0x81, 5, 0x81]),
+        call([6, 0x81, 6, 0x81]),
+        call([7, 0x81, 7, 0x81]),
+        call([8, 0xFF, 8, 0x81])
+    ])
+
+
+def test_display_16x16():
+    device = max7219(serial, width=16, height=16)
+    serial.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline="white")
+
+    serial.data.assert_has_calls([
+        call([1, 0x80, 1, 0xFF, 1, 0x01, 1, 0xFF]),
+        call([2, 0x80, 2, 0x80, 2, 0x01, 2, 0x01]),
+        call([3, 0x80, 3, 0x80, 3, 0x01, 3, 0x01]),
+        call([4, 0x80, 4, 0x80, 4, 0x01, 4, 0x01]),
+        call([5, 0x80, 5, 0x80, 5, 0x01, 5, 0x01]),
+        call([6, 0x80, 6, 0x80, 6, 0x01, 6, 0x01]),
+        call([7, 0x80, 7, 0x80, 7, 0x01, 7, 0x01]),
+        call([8, 0xFF, 8, 0x80, 8, 0xFF, 8, 0x01])
+    ])
+
+
+def test_normal_alignment():
+    device = max7219(serial, cascaded=2, block_orientation=0)
+    serial.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle((0, 0, 15, 3), outline="white")
+
+    serial.data.assert_has_calls([
+        call([1, 0x09, 1, 0x0F]),
+        call([2, 0x09, 2, 0x09]),
+        call([3, 0x09, 3, 0x09]),
+        call([4, 0x09, 4, 0x09]),
+        call([5, 0x09, 5, 0x09]),
+        call([6, 0x09, 6, 0x09]),
+        call([7, 0x09, 7, 0x09]),
+        call([8, 0x0F, 8, 0x09])
+    ])
+
+
+def test_block_realignment_minus90():
+    device = max7219(serial, cascaded=2, block_orientation=-90)
+    serial.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle((0, 0, 15, 3), outline="white")
+
+    serial.data.assert_has_calls([
+        call([1, 0x00, 1, 0x00]),
+        call([2, 0x00, 2, 0x00]),
+        call([3, 0x00, 3, 0x00]),
+        call([4, 0x00, 4, 0x00]),
+        call([5, 0xFF, 5, 0xFF]),
+        call([6, 0x80, 6, 0x01]),
+        call([7, 0x80, 7, 0x01]),
+        call([8, 0xFF, 8, 0xFF])
+    ])
+
+
+def test_block_realignment_plus90():
+    device = max7219(serial, cascaded=2, block_orientation=90)
+    serial.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle((0, 0, 15, 3), outline="white")
+
+    serial.data.assert_has_calls([
+        call([1, 0xFF, 1, 0xFF]),
+        call([2, 0x01, 2, 0x80]),
+        call([3, 0x01, 3, 0x80]),
+        call([4, 0xFF, 4, 0xFF]),
+        call([5, 0x00, 5, 0x00]),
+        call([6, 0x00, 6, 0x00]),
+        call([7, 0x00, 7, 0x00]),
+        call([8, 0x00, 8, 0x00])
+    ])
+
+
+def test_block_realignment_plus180():
+    device = max7219(serial, cascaded=2, block_orientation=180)
+    serial.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle((0, 0, 15, 3), outline="white")
+
+    serial.data.assert_has_calls([
+        call([1, 0xF0, 1, 0x90]),
+        call([2, 0x90, 2, 0x90]),
+        call([3, 0x90, 3, 0x90]),
+        call([4, 0x90, 4, 0x90]),
+        call([5, 0x90, 5, 0x90]),
+        call([6, 0x90, 6, 0x90]),
+        call([7, 0x90, 7, 0x90]),
+        call([8, 0x90, 8, 0xF0])
+    ])
+
+
+def test_reversed_max7219():
+    device = max7219(serial, cascaded=4, blocks_arranged_in_reverse_order=True)
+    serial.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle((0, 0, 15, 3), outline="white")
+
+    serial.data.assert_has_calls([
+        call([1, 15, 1, 9, 1, 0, 1, 0]),
+        call([2, 9, 2, 9, 2, 0, 2, 0]),
+        call([3, 9, 3, 9, 3, 0, 3, 0]),
+        call([4, 9, 4, 9, 4, 0, 4, 0]),
+        call([5, 9, 5, 9, 5, 0, 5, 0]),
+        call([6, 9, 6, 9, 6, 0, 6, 0]),
+        call([7, 9, 7, 9, 7, 0, 7, 0]),
+        call([8, 9, 8, 15, 8, 0, 8, 0])
+    ])
+
+
+def test_unknown_block_orientation():
+    with pytest.raises(AssertionError):
+        max7219(serial, cascaded=2, block_orientation="sausages")

+ 93 - 0
tests/test_neosegment.py

@@ -0,0 +1,93 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2017-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+import pytest
+
+from PIL import Image
+
+from luma.led_matrix.device import neosegment
+from luma.core.device import dummy
+from luma.core.render import canvas
+import luma.core.error
+
+from helpers import assert_identical_image, get_reference_image
+
+
+def test_invalid_dimensions():
+    with pytest.raises(luma.core.error.DeviceDisplayModeError) as ex:
+        neosegment(width=3, device=dummy(width=6, height=7))
+    assert "Unsupported display mode: width=3" in str(ex.value)
+
+
+def test_overflow():
+    with pytest.raises(OverflowError) as ex:
+        neoseg = neosegment(width=6, device=dummy(width=6, height=7))
+        neoseg.text = "TooBig!"
+    assert "Device's capabilities insufficient for value 'TooBig!'" in str(ex.value)
+
+
+def test_settext_nocolor():
+    neoseg = neosegment(width=6, device=dummy(width=6, height=7))
+    neoseg.text = "888888"
+    ref = dummy(width=6, height=7)
+    with canvas(ref) as draw:
+        draw.rectangle(ref.bounding_box, fill="white")
+    assert_identical_image(ref.image, neoseg.device.image)
+
+
+def test_settext_singlecolor():
+    neoseg = neosegment(width=6, device=dummy(width=6, height=7))
+    neoseg.text = "888888"
+    neoseg.color = "red"
+    ref = dummy(width=6, height=7)
+    with canvas(ref) as draw:
+        draw.rectangle(ref.bounding_box, fill="red")
+    assert_identical_image(ref.image, neoseg.device.image)
+
+
+def test_settext_charcolor():
+    neoseg = neosegment(width=6, device=dummy(width=6, height=7))
+    neoseg.text = "888888"
+    neoseg.color[2] = "green"
+    ref = dummy(width=6, height=7)
+    with canvas(ref) as draw:
+        draw.rectangle(ref.bounding_box, fill="white")
+        draw.rectangle([2, 0, 2, 6], fill="green")
+    assert_identical_image(ref.image, neoseg.device.image)
+
+
+def test_settext_replacechars():
+    neoseg = neosegment(width=6, device=dummy(width=6, height=7))
+    neoseg.text = "888888"
+    neoseg.text[2:4] = "  "
+    ref = dummy(width=6, height=7)
+    with canvas(ref) as draw:
+        draw.rectangle(ref.bounding_box, fill="white")
+        draw.rectangle([2, 0, 3, 6], fill="black")
+    assert_identical_image(ref.image, neoseg.device.image)
+
+
+def test_segment_mapper():
+    img_path = get_reference_image('neosegment.png')
+
+    with open(img_path, 'rb') as img:
+        reference = Image.open(img)
+        neoseg = neosegment(width=6, device=dummy(width=6, height=7))
+        neoseg.color = ["red", "green", "blue", "yellow", "cyan", "magenta"]
+        neoseg.text = "012345"
+        assert_identical_image(reference, neoseg.device.image)
+
+
+def test_unknown_char():
+    neoseg = neosegment(width=6, device=dummy(width=6, height=7))
+    neoseg.text = "888888"
+    neoseg.text[2:4] = "&\x7f"
+    neoseg.color[2:4] = ["orange", "orange"]
+    ref = dummy(width=6, height=7)
+    with canvas(ref) as draw:
+        draw.rectangle(ref.bounding_box, fill="white")
+        draw.rectangle([2, 0, 3, 6], fill="black")
+        draw.rectangle([2, 1, 3, 1], fill="orange")
+    assert_identical_image(ref.image, neoseg.device.image)

+ 112 - 0
tests/test_segment_mapper.py

@@ -0,0 +1,112 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+
+from luma.core.util import mutable_string
+from luma.led_matrix.segment_mapper import dot_muncher, regular
+
+
+def test_dot_muncher_without_dots():
+    buf = mutable_string("Hello world")
+    results = dot_muncher(buf, notfound='_')
+    assert list(results) == [0x37, 0x6f, 0x06, 0x06, 0x1d, 0x00, 0x14, 0x1d, 0x05, 0x06, 0x3d]
+
+
+def test_dot_muncher_with_dot():
+    buf = mutable_string("3.14159")
+    results = dot_muncher(buf)
+    assert list(results) == [0x79 | 0x80, 0x30, 0x33, 0x30, 0x5b, 0x7b]
+
+
+def test_dot_muncher_with_dot_at_end():
+    buf = mutable_string("  525920")
+    buf[7:] = "0."
+    print(buf)
+    results = dot_muncher(buf)
+    assert list(results) == [0x00, 0x00, 0x5b, 0x6d, 0x5b, 0x7b, 0x6d, 0x7e | 0x80]
+
+
+def test_dot_muncher_with_dot_at_start():
+    buf = mutable_string(".PDF")
+    results = dot_muncher(buf)
+    assert list(results) == [0x80, 0x67, 0x7e, 0x47]
+
+
+def test_dot_muncher_with_multiple_dot():
+    buf = mutable_string("127.0.0.1")
+    results = dot_muncher(buf)
+    assert list(results) == [0x30, 0x6d, 0x70 | 0x80, 0x7e | 0x80, 0x7e | 0x80, 0x30]
+
+
+def test_dot_muncher_with_consecutive_dot():
+    buf = mutable_string("No...")
+    results = dot_muncher(buf)
+    assert list(results) == [0x76, 0x1d | 0x80, 0x80, 0x80]
+
+
+def test_dot_muncher_empty_buf():
+    buf = mutable_string("")
+    results = dot_muncher(buf)
+    assert list(results) == []
+
+
+def test_dot_muncher_skips_unknown():
+    buf = mutable_string("B&B")
+    results = dot_muncher(buf, notfound=None)
+    assert list(results) == [0x7f, 0x7f]
+
+
+def test_dot_muncher_with_notfound():
+    buf = mutable_string("B&B")
+    results = dot_muncher(buf, notfound='_')
+    assert list(results) == [0x7f, 0x08, 0x7f]
+
+
+def test_regular_without_dots():
+    buf = mutable_string("Hello world")
+    results = regular(buf, notfound='_')
+    assert list(results) == [0x37, 0x6f, 0x06, 0x06, 0x1d, 0x00, 0x14, 0x1d, 0x05, 0x06, 0x3d]
+
+
+def test_regular_with_dot():
+    buf = mutable_string("3.14159")
+    results = regular(buf)
+    assert list(results) == [0x79, 0x80, 0x30, 0x33, 0x30, 0x5b, 0x7b]
+
+
+def test_regular_with_multiple_dot():
+    buf = mutable_string("127.0.0.1")
+    results = regular(buf)
+    assert list(results) == [0x30, 0x6d, 0x70, 0x80, 0x7e, 0x80, 0x7e, 0x80, 0x30]
+
+
+def test_regular_empty_buf():
+    buf = mutable_string("")
+    results = regular(buf)
+    assert list(results) == []
+
+
+def test_regular_skips_unknown():
+    buf = mutable_string("B&B")
+    results = regular(buf, notfound=None)
+    assert list(results) == [0x7f, 0x7f]
+
+
+def test_regular_with_notfound():
+    buf = mutable_string("B&B")
+    results = regular(buf, notfound='_')
+    assert list(results) == [0x7f, 0x08, 0x7f]
+
+
+def test_degrees_unicode():
+    buf = mutable_string(u"29.12°C")
+    results = dot_muncher(buf)
+    assert list(results) == [0x6d, 0x7b | 0x80, 0x30, 0x6d, 0x63, 0x4e]
+
+
+def test_degrees_utf8():
+    buf = mutable_string(u"29.12\xb0C")
+    results = dot_muncher(buf)
+    assert list(results) == [0x6d, 0x7b | 0x80, 0x30, 0x6d, 0x63, 0x4e]

+ 60 - 0
tests/test_unicornhathd.py

@@ -0,0 +1,60 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-19 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+from luma.led_matrix.device import unicornhathd
+from luma.core.render import canvas
+
+from helpers import serial
+from baseline_data import get_json_data
+
+
+def test_init():
+    device = unicornhathd(serial)
+    assert device.width == 16
+    assert device.height == 16
+    serial.data.assert_called_once_with([0x72] + [0] * 256 * 3)
+
+
+def test_hide():
+    device = unicornhathd(serial)
+    serial.reset_mock()
+    device.hide()
+    serial.data.assert_called_once_with([0x72] + [0] * 256 * 3)
+
+
+def test_show():
+    device = unicornhathd(serial)
+    device.contrast(0xFF)
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline="white", fill="white")
+    device.hide()
+    serial.reset_mock()
+    device.show()
+    serial.data.assert_called_once_with([0x72] + [0xFF] * 256 * 3)
+
+
+def test_contrast():
+    device = unicornhathd(serial)
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline="white", fill="white")
+    serial.reset_mock()
+    device.contrast(0x6B)
+    serial.data.assert_called_once_with([0x72] + [0x6B] * 256 * 3)
+
+
+def test_display():
+    device = unicornhathd(serial)
+    serial.reset_mock()
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline="white")
+    serial.data.assert_called_once_with([0x72] + get_json_data('demo_unicornhathd'))
+
+
+def test_alpha_blending():
+    device = unicornhathd(serial)
+    serial.reset_mock()
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline=(255, 128, 64, 32))
+    serial.data.assert_called_once_with([0x72] + get_json_data('demo_unicornhathd_alphablend'))

+ 166 - 0
tests/test_ws2812.py

@@ -0,0 +1,166 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-18 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+import pytest
+
+from luma.led_matrix.device import neopixel
+from luma.core.render import canvas
+
+from unittest.mock import Mock, call
+
+
+ws = Mock(unsafe=True)
+chan = "channel"
+leds = "leds"
+
+
+def setup_function(function):
+    ws.reset_mock()
+    ws.command.side_effect = None
+    ws.ws2811_init = Mock(return_value=0)
+    ws.ws2811_render = Mock(return_value=0)
+    ws.ws2811_channel_get = Mock(return_value=chan)
+    ws.ws2811_new_ws2811_t = Mock(return_value=leds)
+
+
+def test_init_cascaded():
+    device = neopixel(ws, cascaded=7)
+    assert device.width == 7
+    assert device.height == 1
+    assert ws.ws2811_channel_t_count_set.called
+    assert ws.ws2811_channel_t_gpionum_set.called
+    assert ws.ws2811_channel_t_invert_set.called
+    assert ws.ws2811_channel_t_brightness_set.called
+    assert ws.ws2811_channel_t_strip_type_set.called
+    assert ws.ws2811_t_freq_set.called
+    assert ws.ws2811_t_dmanum_set.called
+    assert ws.ws2811_init.called
+    ws.ws2811_led_set.assert_has_calls([
+        call(chan, i, 0x000000) for i in range(7)])
+    assert ws.ws2811_render.called
+
+
+def test_init_4x8():
+    device = neopixel(ws)
+    assert device.cascaded == 32
+    assert ws.ws2811_channel_t_count_set.called
+    assert ws.ws2811_channel_t_gpionum_set.called
+    assert ws.ws2811_channel_t_invert_set.called
+    assert ws.ws2811_channel_t_brightness_set.called
+    assert ws.ws2811_channel_t_strip_type_set.called
+    assert ws.ws2811_t_freq_set.called
+    assert ws.ws2811_t_dmanum_set.called
+    assert ws.ws2811_init.called
+    ws.ws2811_led_set.assert_has_calls([
+        call(chan, i, 0x000000) for i in range(32)])
+    assert ws.ws2811_render.called
+
+
+def test_init_fail():
+    ws.reset_mock()
+    ws.ws2811_init = Mock(return_value=-1)
+    with pytest.raises(RuntimeError) as ex:
+        neopixel(ws, cascaded=7)
+    assert "ws2811_init failed with code -1" in str(ex.value)
+
+
+def test_clear():
+    device = neopixel(ws)
+    ws.reset_mock()
+    device.clear()
+    ws.ws2811_led_set.assert_has_calls([
+        call(chan, i, 0x000000) for i in range(32)])
+    assert ws.ws2811_render.called
+
+
+def test_cleanup():
+    device = neopixel(ws)
+    device.cleanup()
+    ws.ws2811_led_set.assert_has_calls([
+        call(chan, i, 0x000000) for i in range(32)])
+    assert ws.ws2811_render.called
+    assert ws.ws2811_fini.called
+    assert ws.delete_ws2811_t.called
+    assert device._leds is None
+    assert device._channel is None
+
+
+def test_hide():
+    device = neopixel(ws, cascaded=5)
+    ws.reset_mock()
+    device.hide()
+    ws.ws2811_led_set.assert_not_called()
+    assert ws.ws2811_render.called
+
+
+def test_show():
+    device = neopixel(ws, cascaded=5)
+    ws.reset_mock()
+    device.hide()
+    device.show()
+    ws.ws2811_led_set.assert_not_called()
+    assert ws.ws2811_render.called
+
+
+def test_contrast():
+    device = neopixel(ws, cascaded=6)
+    ws.reset_mock()
+    device.contrast(0x6B)
+    ws.ws2811_channel_t_brightness_set.assert_called_once_with(chan, 0x6B)
+
+
+def test_display():
+    device = neopixel(ws, width=4, height=4)
+    ws.reset_mock()
+
+    with canvas(device) as draw:
+        draw.rectangle(device.bounding_box, outline="red")
+
+    ws.ws2811_led_set.assert_has_calls([
+        call(chan, 0, 0xFF0000),
+        call(chan, 1, 0xFF0000),
+        call(chan, 2, 0xFF0000),
+        call(chan, 3, 0xFF0000),
+        call(chan, 4, 0xFF0000),
+        call(chan, 5, 0x000000),
+        call(chan, 6, 0x000000),
+        call(chan, 7, 0xFF0000),
+        call(chan, 8, 0xFF0000),
+        call(chan, 9, 0x000000),
+        call(chan, 10, 0x000000),
+        call(chan, 11, 0xFF0000),
+        call(chan, 12, 0xFF0000),
+        call(chan, 13, 0xFF0000),
+        call(chan, 14, 0xFF0000),
+        call(chan, 15, 0xFF0000),
+    ])
+    assert ws.ws2811_render.called
+
+
+def test_display_fail():
+    device = neopixel(ws, cascaded=7)
+    ws.reset_mock()
+    ws.ws2811_render = Mock(return_value=-1)
+
+    with pytest.raises(RuntimeError) as ex:
+        with canvas(device) as draw:
+            draw.rectangle(device.bounding_box, outline="red")
+
+    assert "ws2811_render failed with code -1" in str(ex.value)
+
+
+def test_mapping():
+    num_pixels = 16
+    device = neopixel(ws, cascaded=num_pixels, mapping=reversed(list(range(num_pixels))))
+    ws.reset_mock()
+
+    with canvas(device) as draw:
+        for i in range(device.cascaded):
+            draw.point((i, 0), (i, 0, 0))
+
+    expected = [call(chan, num_pixels - i - 1, i << 16) for i in range(num_pixels)]
+    ws.ws2811_led_set.assert_has_calls(expected)
+
+    assert ws.ws2811_render.called

+ 29 - 0
tox.ini

@@ -0,0 +1,29 @@
+# Copyright (c) 2017-2020 Richard Hull and contributors
+# See LICENSE.rst for details.
+
+[tox]
+envlist = py{36,37,38,39},qa,doc
+skip_missing_interpreters = True
+
+[testenv]
+usedevelop = true
+setenv =
+    PYTHONDEVMODE=1
+commands =
+    coverage erase
+    python setup.py install
+    pytest --cov=luma
+    coverage html
+deps = .[test]
+
+[testenv:qa]
+commands =
+    flake8
+    rstcheck README.rst CHANGES.rst CONTRIBUTING.rst
+deps = .[qa]
+
+[testenv:doc]
+commands =
+    sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
+changedir = doc
+deps = .[docs]