Browse Source

Initial public release of PieTime!

\o/
Tomek Wójcik 3 years ago
commit
0912fd15e8

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+*.pyc
+*.pyo
+*.swp
+.pybuild/
+build/
+dist/
+pie_time.egg-info/

+ 33 - 0
CONTRIBUTING.md

@@ -0,0 +1,33 @@
+# Quick guide to contributing to PieTime
+
+This document describes the process of contributing to PieTime.
+
+## Mailing List
+
+The mailing list is the designated way of discussing anything related to
+PieTime. Use it to report issues, submit patches etc.
+
+Mailing list address: pietime@librelist.com
+
+## Submitting patches
+
+If you've made changes to PieTime source, you're welcome to submit a patch.
+Before doing so, make sure you've updated tests and docs to reflect your
+changes. Additionally, run the *pep8* utility on changed files.
+
+Once you're done, create a patch from your changes and send it to the mailing
+list along with a brief description. If your changes span over multiple
+commits, please squash them into one before submitting the patch.
+
+## License information
+
+PieTime itself is licensed under the MIT license, so any code introduced by
+your patch must be compatible with this license. Note that, if you don't
+explicitly mark a piece of code with "alien" license, it'll automatically be
+licensed under the MIT license.
+
+If your patch contains 3rd party resources, make sure that their license(s)
+allow for redistribution in open source projects.
+
+If your patch contains 3rd party code and/or resources, make sure you update
+the docs and ``debian/copyright`` file accordingly.

+ 19 - 0
LICENSE

@@ -0,0 +1,19 @@
+Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
+
+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.

+ 4 - 0
MANIFEST.in

@@ -0,0 +1,4 @@
+include pie_time/cards/resources/*.ttf
+include README.rst
+include LICENSE
+include requirements.txt

+ 42 - 0
README.rst

@@ -0,0 +1,42 @@
+PieTime
+=======
+
+Desk clock for your Raspberry Pi.
+
+About
+-----
+
+PieTime lets you turn your Raspberry Pi into a desk clock. It's written in
+Python using PyGame framework.
+
+PieTime comes with three modules that allow displaying the clock, weather and
+set of pictures. These modules (also known as *cards*) are highly configurable,
+so you can tweak them to better suit your needs.
+
+Additionally, an API is provided for users who wish to write their own cards.
+Read on for an example of a card.
+
+Features
+--------
+
+With PieTime you can:
+
+* Choose any of the three builtin cards.
+* Configure display aspects (e.g. text color) of the cards.
+* Define card visibility intervals.
+* Set up screen blanking period for power saving.
+* Use the card API to create your own cards.
+
+Author, License and Attributions
+--------------------------------
+
+PieTime has been created and is developed by
+`Tomek Wójcik <https://www.bthlabs.pl/>`_.
+
+PieTime is licensed under the MIT License.
+
+PieTime uses the following 3rd party code and resources:
+
+* Open Sans font by Steve Matteson, (Apache License, Version 2.0),
+* Linea Weather 1.0 font by Dario Ferrando (CC BY 4.0),
+* PT Mono font by Paratype (SIL OFL, Version 1.1).

+ 1 - 0
docs/.gitignore

@@ -0,0 +1 @@
+_build/

+ 177 - 0
docs/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/PiClock.qhcp"
+	@echo "To view the help file:"
+	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PiClock.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/PiClock"
+	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PiClock"
+	@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."

+ 16 - 0
docs/api.rst

@@ -0,0 +1,16 @@
+API
+===
+
+This document describes the interfaces of PieTime.
+
+Application
+-----------
+
+.. autoclass:: pie_time.PieTime
+    :members:
+
+Abstract Card
+-------------
+
+.. autoclass:: pie_time.AbstractCard
+    :members:

+ 22 - 0
docs/builtin_cards.rst

@@ -0,0 +1,22 @@
+Builtin Cards
+=============
+
+This document describes the builtin cards.
+
+ClockCard
+---------
+
+.. autoclass:: pie_time.cards.ClockCard
+    :members:
+
+PictureCard
+-----------
+
+.. autoclass:: pie_time.cards.PictureCard
+    :members:
+
+WeatherCard
+-----------
+
+.. autoclass:: pie_time.cards.WeatherCard
+    :members:

+ 261 - 0
docs/conf.py

@@ -0,0 +1,261 @@
+# -*- coding: utf-8 -*-
+#
+# PieTime documentation build configuration file, created by
+# sphinx-quickstart on Tue Oct 21 16:34:31 2014.
+#
+# 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 sys
+import os
+
+# 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('..'))
+
+# -- 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.viewcode',
+]
+
+# 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'PieTime'
+copyright = u'2014-2016, Tomek Wójcik'
+
+# 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 short X.Y version.
+version = '1.0'
+# The full version, including alpha/beta/rc tags.
+release = '1.0'
+
+# 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 = 'nature'
+
+# 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 = 'PieTimedoc'
+
+
+# -- 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', 'PieTime.tex', u'PieTime Documentation',
+   u'Tomek Wójcik', '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', 'pie_time', u'PieTime Documentation',
+     [u'Tomek Wójcik'], 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', 'PieTime', u'PieTime Documentation',
+   u'Tomek Wójcik', 'PieTime', '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

+ 104 - 0
docs/developer_guide.rst

@@ -0,0 +1,104 @@
+Developer Guide
+===============
+
+This document provides guide to hacking and extending PieTime.
+
+Setup
+-----
+
+To develop PieTime or cards, some additional setup is required. First, it's
+recommended to use a virtual environment, to separate from OS Python and
+extensions. Secondly, use ``requirements-dev.txt`` to install additional
+modules and tools used in development.
+
+Custom card example
+-------------------
+
+.. sourcecode:: python
+
+    from pie_time.card import AbstractCard
+
+    import pygame
+
+    class ExampleCard(AbstractCard):
+        def initialize(self):
+            self.sprite = pygame.surface.Surface((20, 20))
+            self.sprite.fill((255, 0, 0))
+            self.orig_sprite_rect = self.sprite.get_rect()
+            self.orig_speed = [2, 2]
+            self.sprite_rect = self.orig_sprite_rect
+            self.speed = self.orig_speed
+
+        def show(self):
+            self.sprite_rect = self.orig_sprite_rect
+            self.speed = self.orig_speed
+
+        def tick(self):
+            self.sprite_rect = self.sprite_rect.move(self.speed)
+
+            if self.sprite_rect.left < 0 or self.sprite_rect.right > self.width:
+                self.speed[0] = -self.speed[0]
+            if self.sprite_rect.top < 0 or self.sprite_rect.bottom > self.height:
+                self.speed[1] = -self.speed[1]
+
+            self.surface.fill(self.background_color)
+            self.surface.blit(self.sprite, self.sprite_rect)
+
+This example shows how easy it is to create custom cards for use in PieTime
+decks.
+
+Start by creating a custom class that inherits from *AbstractCard*. Then
+implement a few methods to make it display the information you need (in this
+example, a red square moving on the screen). Having done that, you'll be ready to use your custom card in a deck.
+
+Head on to :py:class:`pie_time.AbstractCard` documentation for more
+information about PieTime card API.
+
+Speed considerations
+--------------------
+
+Since PieTime targets the Raspberry Pi, it's important to keep speed in mind.
+When developing cards, take care to do as little work as possible in the
+:py:meth:`pie_time.AbstractCard.tick` method.
+
+For example, WeatherCard redraws its surface only when weather conditions
+change. By doing so, the CPU load is reduced because the only thing PieTime has to do is blit the surface to screen.
+
+Always test the behavior of your card in low FPS. Remember, that PieTime
+targets small GPIO-connected LCD screens. For many of them, 20 FPS will be the best refresh rate. If your card behaves badly in such conditions, users may refrain from using it.
+
+Threading considerations
+------------------------
+
+Sometimes, it's required to perform background tasks during lifetime of the
+card. API exposed by Python's builtin ``threading`` module should come in handy
+is such situations.
+
+Take WeatherCard as an example. Once every 10 minutes, it fetches current
+conditions from the Internet. If you look into the card's code, there's
+``_refresh_conditions`` method. It uses ``threading.Timer`` class to schedule
+fetching of the conditions in a separate thread of control. This way, the
+HTTP request (which may take some time depending on the network conditions)
+won't cause the PieTime application to freeze.
+
+As always with threads, you should be aware of the standard pitfalls - variable
+access synchronization, error handling, the GIL. Also, spawning too many
+threads will impact PieTime's performance. If you use threads in your card,
+make sure to test it on the device to see how it impacts the load.
+
+Handling events
+---------------
+
+In every iteration of the main loop, PieTime reads list of current events from
+PyGame. This list is available for the cards to use through
+:py:attr:`pie_time.PieTime.events`.
+
+The application itself handles the following events:
+
+* ``QUIT`` (e.g. SIGTERM) - if this event appears, the application quits.
+* ``KEYDOWN`` on the key specified by :py:attr:`pie_time.PieTime.KEY_QUIT` -
+  quits the application,
+* ``MOUSEBUTTONDOWN`` anywhere on the screen when it's blanked - 
+  if click to unblank is enabled,
+* ``MOUSEBUTTONDOWN`` in one of the regions used to manually switch bedween
+  cards.

+ 69 - 0
docs/index.rst

@@ -0,0 +1,69 @@
+PieTime
+=======
+
+Desk clock for your Raspberry Pi.
+
+About
+-----
+
+PieTime lets you turn your Raspberry Pi into a desk clock. It's written in
+Python using Pygame framework.
+
+PieTime comes with three modules that allow displaying the clock, weather and
+set of pictures. These modules (also known as *cards*) are highly configurable,
+so you can tweak them to better suit your needs.
+
+Additionally, an API is provided for users who wish to write their own cards.
+Read on for an example of a card.
+
+Features
+--------
+
+With PieTime you can:
+
+* Choose any of the three builtin cards.
+* Configure display aspects (e.g. text color) of the cards.
+* Define card visibility intervals.
+* Set up screen blanking period for power saving.
+* Use the card API to create your own cards.
+
+Author, License and Attributions
+--------------------------------
+
+PieTime has been created and is developed by
+`Tomek Wójcik <https://www.bthlabs.pl/>`_.
+
+PieTime is licensed under the MIT License.
+
+PieTime uses the following 3rd party code and resources:
+
+* Open Sans font by Steve Matteson, (Apache License, Version 2.0),
+* Linea Weather 1.0 font by Dario Ferrando (CC BY 4.0),
+* PT Mono font by Paratype (SIL OFL, Version 1.1).
+
+Source code and issues
+----------------------
+
+Source code is available via
+`public Git repository <https://git.bthlabs.pl/tomekwojcik/pie-time>`_.
+
+If you wish to contribute to the project, see ``CONTRIBUTING.md`` for more
+info.
+
+Contents
+--------
+
+.. toctree::
+    :maxdepth: 2
+
+    requirements_and_installation
+    user_guide
+    builtin_cards
+    developer_guide
+    api
+
+Indices and tables
+------------------
+
+* :ref:`genindex`
+* :ref:`search`

+ 79 - 0
docs/requirements_and_installation.rst

@@ -0,0 +1,79 @@
+.. _requirements_and_installation:
+
+Requirements and installation
+=============================
+
+This document describes the PieTime requirements and installation process.
+
+Requirements
+------------
+
+PieTime requires the following to work:
+
+* Python 2.7,
+* PyGame 1.9.1 (also tested with 1.9.2a0),
+* requests 2.4.1 (should work with newer versions).
+
+Installing on Raspbian Jessie
+-----------------------------
+
+If you're using Raspbian Jessie on your Raspberry Pi, you can install PieTime
+using binary packages by following the guide below.
+
+**Add PieTime APT repository**
+
+Create the file /etc/apt/sources.list.d/pie-time.list and add the following
+line:
+
+.. sourcecode:: text
+
+    deb https://pie-time.bthlabs.pl/repos/apt/ raspbian-jessie main
+
+**Import the repository signing key**
+
+.. sourcecode:: console
+
+    $ wget --quiet -O - https://pie-time.bthlabs.pl/keys/apt.asc | sudo apt-key add -
+
+**Update the package lists and install PieTime**
+
+.. sourcecode:: console
+
+    $ sudo apt-get update
+    $ sudo apt-get install pie-time
+
+Installing on other systems using PyPI
+--------------------------------------
+
+If you wish to install PieTime on system other than Raspbian Jessie (and
+potentially on device other than Raspberry Pi), you can do so using the
+PyPI package by using the guide below.
+
+#. Install PyGame dependencies,
+#. Run ``$ sudo pip install pie_time`` to install PieTime and its dependencies.
+
+**NOTE**: The second step may require installing additional packages from the
+system repository, depending on your current setup.
+
+Installing from the source
+--------------------------
+
+In order to install PieTime, please follow the guide below. Note that this
+guide assumes Unix-like OS and root access.
+
+#. Install PyGame dependencies,
+#. Clone the repository ``$ git clone https://git.bthlabs.pl/tomekwojcik/pie-time.git``,
+#. Enter the PieTime directory: ``$ cd pie-time``,
+#. Install PieTime: ``$ python setup.py install``.
+
+**NOTE**: The fourth step may require installing additional packages from the
+system repository, depending on your current setup.
+
+Installing as non-root user
+---------------------------
+
+If you wish to install PieTime as non-root user, you can do so by installing
+it from the PyPI package or source code using a virtual env.
+
+To learn more about Python virtual envs, see the
+`virtual env documentation <https://virtualenv.readthedocs.org/en/latest/>`_

+ 295 - 0
docs/user_guide.rst

@@ -0,0 +1,295 @@
+.. _user_guide:
+
+User Guide
+==========
+
+This document provides guide to using PieTime.
+
+The application script
+----------------------
+
+In order to use PieTime, you'll have to write a simple Python script to set
+up and (optionally) start the application.
+
+**Short example**
+
+.. sourcecode:: python
+
+    #!/usr/bin/env python2.7
+
+    from datetime import timedelta
+    import os
+    import sys
+
+    if os.getcwd() not in sys.path:
+        sys.path.insert(0, os.getcwd())
+
+    from pie_time import PieTime
+    from pie_time.cards import ClockCard, PictureCard, WeatherCard
+
+    deck = [
+        ClockCard,
+        (
+            WeatherCard, 20, {
+                'api_key': 'Your OpenWeatherMap API KEY',
+                'city': 'Wroclaw,PL'
+            }
+        ),
+        (
+            PictureCard, 10, {
+                'urls': [
+                    'http://lorempixel.com/320/240/city',
+                    'http://lorempixel.com/200/125/technics'
+                ]
+            }
+        )
+    ]
+
+    blanker_schedule = (
+        timedelta(hours=23), timedelta(hours=6)
+    )
+
+    app = PieTime(deck, blanker_schedule=blanker_schedule)
+
+    if __name__ == '__main__':
+        app.run()
+
+This script sets up PieTime application with the following settings:
+
+* Three cards,
+* Clock card set up to display for 60 seconds,
+* Weather card set up to fetch data for the city of Wrocław in Poland and
+  display for 20 seconds,
+* Picture frame card set to display two separate images (fetched from the Net)
+  for 10 seconds each.
+* Blanker schedule set up to blank the screen between 23:00 (11:00 PM) and
+  6:00 AM,
+* Click to unblank interval set to 10 seconds.
+
+The deck
+--------
+
+The first argument passed to :py:class:`pie_time.PieTime` constructor defines
+the deck of cards to be displayed, along with additional information about
+each of the cards.
+
+Example deck could look like this:
+
+.. sourcecode:: python
+
+    deck = [
+        ClockCard,
+        (ClockCard, 30),
+        (ClockCard, 30, {'text_color': (255, 0, 0)}),
+    ]
+
+The first item is just a card class. A card defined this way will display
+for the duration defined by :py:attr:`pie_time.PieTime.CARD_INTERVAL` and
+won't have any additional settings.
+
+The second item is a tuple of card class and number. A card defined this
+way will display for the specified number of seconds and won't have any
+additional settings.
+
+The third item is a tuple of card class, number and dictionary. A card
+defined this way will display for the specified number of seconds and will
+have additional settings as specified by the dictionary.
+
+Blanker schedule
+----------------
+
+The *blanker_schedule* keyword argument defines how the screen should be
+blanked.
+
+Example blanker schedule could look like this:
+
+.. sourcecode:: python
+
+    blanker_schedule = (
+        datetime.timedelta(hours=23), datetime.timedelta(hours=6)
+    )
+
+Such a schedule will make the application blank the screen between 23:00
+(11:00 PM) and 6:00 AM.
+
+When the blanker is active, the screen is filled with color defined in
+:py:attr:`pie_time.PieTime.BLANK_COLOR`.
+
+Blanker also prevents the following actions:
+
+* Transitioning cards,
+* Calling the visible card's ``tick()`` method,
+* Blitting the visible card's surface to the screen.
+
+When the blanker deactivates it transitions to the first card from the deck.
+
+Click to unblank
+----------------
+
+A PieTime application can be set up to allow temporary overriding of the screen
+blanker. In order to do so, set *click_to_unblank_interval* to a non-negative
+number. When the screen is blanked, just click anywhere and PieTime will show
+for number of seconds defined by *click_to_unblank_interval*. Before
+unblanking, PieTime will set the first card from deck as the current card.
+
+Since PieTime uses PyGame, it'll automatically support many input devices. For
+example, a properly configured touch screen for PiTFT-like displays should be
+supported out of the box.
+
+Click to transition
+-------------------
+
+If you wish, you can change the currently visible card manually. In order to do
+so, just click (or tap) the bottom-left or bottom-right corner of the screen.
+The bottom-left corner will switch to the previous card. The bottom-right
+corner will switch to the next card.
+
+This feature is enabled automatically and can be disabled by setting
+*click_to_transition* keyword argument to ``False``.
+
+Other settings
+--------------
+
+PieTime app allows changing other settings. Have a look at
+:py:class:`pie_time.PieTime` class documentation to learn more.
+
+Video drivers and screen size
+-----------------------------
+
+Since PieTime is based on PyGame, it supports the same range ouf output
+devices. As of time of writing this document, PieTime has been tested with
+``x11``, ``fbcon`` and ``Quartz`` video drivers.
+
+You can configure the outut device using SDL environment variables. See the
+`SDL environment variables documentation <https://www.libsdl.org/release/SDL-1.2.15/docs/html/sdlenvvars.html>`_ to learn more.
+
+Since PieTime mostly targets LCD shields, the screen size defaults to
+320x240px. Support for other screen sizes is limited.
+
+Testing the script
+------------------
+
+Once you've created the script to set up the application and chosen the video
+driver, you can start PieTime manually using the following command:
+
+.. sourcecode:: console
+
+    $ python2.7 <path_to_app_script>
+
+In this case, video driver will be chosen automatically. If you get any
+errors, try using a different video driver. Note that framebuffer drivers
+usually require root privileges.
+
+To exit the application, press *[ESC]*.
+
+**NOTE**: If you installed PieTime from the PyPI package or source code in a
+virtual env, make sure it's properly activated before trying to start the
+application.
+
+Creating and using the PieTime INI file
+---------------------------------------
+
+After you've tested your application script and are satisfied with it, it's
+time to create the INI file. This INI file will be used by the *pie_time*
+program to set up and launch your application.
+
+**Short example**
+
+.. sourcecode:: ini
+
+    [PieTime]
+    app_module = examples.customization_example:app
+    log_path = log.txt
+
+    [SDL]
+    VIDEODRIVER = fbcon
+
+**The PieTime section**
+
+The *PieTime* section should contain the following fields:
+
+* ``app_module`` (string) - app module import path,
+* ``log_path`` (string) - optional path to log file (if omitted, standard
+  output will be used).
+
+**The app module import path**
+
+The *app_module* field defines the app module import path. This import path
+will be used to import app script and extract the app object from it. In the
+example, the import path translates to *app attribute in customization_example
+module in examples package*. Note that the import path is related to the
+current working directory.
+
+**The SDL section**
+
+The SDL section allows you to set up SDL environment variables before starting
+the PieTime application. You can specify any environment variable supported by
+SDL. The field names should be specified without the ``SDL_`` prefix, which
+will be added automatically.
+
+Starting PieTime on boot
+------------------------
+
+Since PieTime was designed as a desk clock replacement, it's best to have it
+start automatically on boot. In order to do so, please follow instructions in
+one of the subsections.
+
+**Setting up PieTime installed from APT repository**
+
+If you installed PieTime from APT repository, follow the guide below to set it
+up to start on boot.
+
+#. Edit ``/etc/default/pie-time`` and adjust its contents according to your
+   needs,
+#. Place the INI file in the path specified in the ``/etc/default/pie-time``
+   file,
+#. Place your application script in the path specified in the INI file,
+#. Run ``$ sudo dpkg-reconfigure pie-time`` and answer *Yes* when it asks you
+   about starting on boot,
+#. Either reboot the Raspberry Pi or run
+   ``$ sudo /etc/init.d/pie-time restart`` to start PieTime.
+
+**NOTE**: You can skip the third step, if you replied *Yes* to the question
+when installing the PieTime package.
+
+**The /etc/default/pie-time file**
+
+The ``/etc/default/pie-time`` file contains minimum shell environment required
+to properly start the *pie_time* program.
+
+Supported environment variables:
+
+* ``WORKDIR`` - path to working directory (which will be used to import the app
+  module). Defaults to ``/var/lib/pie-time``.
+* ``INI_PATH`` - path to the INI file. Defaults to ``/etc/pie-time.ini``.
+* ``USER`` - name of the user which will start the app. Defaults to ``root``.
+
+**NOTE**: If you change the ``USER`` field to a non-root user, make sure it can
+acess the selected output device and paths (most notably, the log path).
+
+**Setting up PieTime installed from PyPI package or source code**
+
+If you chose to install PieTime from PyPI package or source code and wish to
+start it on boot, the recommended method is to use
+`supervisor <http://supervisord.org/>`_ to achieve that.
+
+**Example supervisor config for PieTime**
+
+.. sourcecode:: text
+
+    [program:pie_time]
+    command=/usr/bin/python2.7 /home/pi/pie-time/app.py
+    numprocs=1
+    directory=/home/pi/pie-time
+    autostart=true
+    user=root
+    stdout_logfile=/home/pi/pie-time/log/stdout.log
+    stderr_logfile=/home/pi/pie-time/log/stderr.log
+
+Troubleshooting
+---------------
+
+In case of any problems, it's recommend to set the *verbose* keyword to
+argument to ``True`` and start the app again. In verbose mode, the app's logger
+is set to ``DEBUG`` level (as opposed to ``INFO`` in non-verbose mode) and will
+display a lot of useful debugging information.

+ 0 - 0
examples/__init__.py


+ 38 - 0
examples/basic_usage.py

@@ -0,0 +1,38 @@
+#!/usr/bin/env python2.7
+
+from datetime import timedelta
+import os
+import sys
+
+if os.getcwd() not in sys.path:
+    sys.path.insert(0, os.getcwd())
+
+from pie_time import PieTime
+from pie_time.cards import ClockCard, PictureCard, WeatherCard
+
+deck = [
+    ClockCard,
+    (
+        WeatherCard, 20, {
+            'api_key': 'Your OpenWeatherMap API KEY',
+            'city': 'Wroclaw,PL'
+        }
+    ),
+    (
+        PictureCard, 10, {
+            'urls': [
+                'http://lorempixel.com/320/240/city',
+                'http://lorempixel.com/200/125/technics'
+            ]
+        }
+    )
+]
+
+blanker_schedule = (
+    timedelta(hours=23), timedelta(hours=6)
+)
+
+app = PieTime(deck, blanker_schedule=blanker_schedule)
+
+if __name__ == '__main__':
+    app.run()

+ 69 - 0
examples/customization_example.py

@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+from datetime import datetime, timedelta
+import os
+import sys
+import time
+
+if os.getcwd() not in sys.path:
+    sys.path.insert(0, os.getcwd())
+
+from pie_time import PieTime
+from pie_time.cards import ClockCard
+from pie_time.card import AbstractCard
+
+import pygame
+
+
+class ExampleCard(AbstractCard):
+    def initialize(self):
+        self.sprite = pygame.surface.Surface((20, 20))
+        self.sprite.fill((255, 0, 0))
+        self.orig_sprite_rect = self.sprite.get_rect()
+        self.orig_speed = [2, 2]
+        self.sprite_rect = self.orig_sprite_rect
+        self.speed = self.orig_speed
+
+    def show(self):
+        self.sprite_rect = self.orig_sprite_rect
+        self.speed = self.orig_speed
+
+    def tick(self):
+        self.sprite_rect = self.sprite_rect.move(self.speed)
+
+        if self.sprite_rect.left < 0 or self.sprite_rect.right > self.width:
+            self.speed[0] = -self.speed[0]
+        if self.sprite_rect.top < 0 or self.sprite_rect.bottom > self.height:
+            self.speed[1] = -self.speed[1]
+
+        self.surface.fill(self.background_color)
+        self.surface.blit(self.sprite, self.sprite_rect)
+
+
+class CustomApp(PieTime):
+    def __init__(self, *args, **kwargs):
+        super(CustomApp, self).__init__(*args, **kwargs)
+        self._ctt_region_prev = pygame.Rect(0, 0, 160, 240)
+        self._ctt_region_next = pygame.Rect(160, 0, 160, 240)
+
+    def will_blank(self):
+        self.logger.debug('CustomApp.will_blank')
+
+    def will_unblank(self):
+        self.logger.debug('CustomApp.will_unblank')
+
+deck = [
+    (ClockCard, 10),
+    (ExampleCard, 10),
+]
+
+app = CustomApp(
+    deck, verbose=True, fps=40,
+    blanker_schedule=(
+        timedelta(hours=23), timedelta(hours=6)
+    ),
+    click_to_unblank_interval=10
+)
+
+if __name__ == '__main__':
+    app.run()

+ 3 - 0
examples/example.ini

@@ -0,0 +1,3 @@
+[PieTime]
+app_module = examples.customization_example:app
+log_path = log.txt

+ 84 - 0
extra/initscript.debian

@@ -0,0 +1,84 @@
+#!/bin/bash
+### BEGIN INIT INFO
+# Provides:          pie-time
+# Required-Start:    $network
+# Required-Stop:     $network
+# Should-Start:     $local_fs
+# Should-Stop:      $local_fs
+# Default-Start:     2 3 4 5
+# Default-Stop:      0 1 6
+# Short-Description: Desk clock application for the Raspberry Pi
+# Description:       Desk clock application for the Raspberry Pi
+### END INIT INFO
+
+PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
+DAEMON="/usr/bin/pie_time"
+NAME="pie-time"
+DESC="Desk clock application for the Raspberry Pi"
+
+RUNDIR="/var/run/pie-time"
+PIDFILE="/var/run/pie-time/pie-time.pid"
+WORKDIR="/var/lib/pie-time"
+
+CONFIG_PATH="/etc/pie-time.ini"
+USER="root"
+
+if [ -f "/etc/default/pie-time" ];then
+    . "/etc/default/pie-time"
+fi
+
+. /lib/lsb/init-functions
+
+set -e
+
+case "$1" in
+  start)
+        echo -n "Starting $DESC: "
+        mkdir -p $RUNDIR
+        chmod 755 $RUNDIR
+
+        chown $USER $RUNDIR
+
+        if start-stop-daemon --start --quiet --pidfile $PIDFILE --chuid $USER --chdir $WORKDIR -b -m  --exec $DAEMON -- $CONFIG_PATH;then
+            echo "$NAME."
+        else
+            echo "failed"
+        fi
+        ;;
+  stop)
+        echo -n "Stopping $DESC: "
+        if start-stop-daemon --stop --retry forever/TERM/1 --quiet --oknodo --pidfile $PIDFILE;then
+            echo "$NAME."
+            rm -f $PIDFILE
+        else
+            echo "failed"
+        fi
+        ;;
+  restart|force-reload)
+        ${0} stop
+        ${0} start
+        ;;
+  status)
+        STATUS="4"
+        start-stop-daemon --status --pidfile $PIDFILE || STATUS="$?"
+        case $STATUS in
+            0)
+                echo "$NAME: running"
+                ;;
+
+            1|3)
+                echo "$NAME: not running"
+                ;;
+
+            *)
+                echo "$NAME: unable to determine the status"
+                ;;
+        esac
+        ;;
+  *)
+        echo "Usage: /etc/init.d/$NAME {start|stop|restart|force-reload|status}" >&2
+        exit 1
+        ;;
+esac
+
+exit 0

+ 7 - 0
extra/pie-time.ini.example

@@ -0,0 +1,7 @@
+[PieTime]
+app_module = pie_time_app:app
+log_path = /var/log/pie-time/daemon.log
+
+[SDL]
+VIDEODRIVER = fbcon
+FBDEV = /dev/fb0

+ 12 - 0
pie_time/__init__.py

@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+
+__title__ = 'pie_time'
+__version__ = '1.0'
+__author__ = u'Tomek Wójcik'
+__license__ = 'MIT'
+__copyright__ = (
+    u'Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>'
+)
+
+from .application import PieTime
+from .card import AbstractCard

+ 511 - 0
pie_time/application.py

@@ -0,0 +1,511 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
+#
+# 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.
+#
+
+"""
+pie_time.application
+====================
+
+This module implements the PieTime application.
+"""
+
+import datetime
+import argparse
+import imp
+import logging
+import os
+import sys
+
+import pygame
+
+from pie_time import __copyright__ as copyright, __version__ as version
+
+RET_OK = 0
+RET_ERROR = 99
+
+MOTD_PICLOCK_BANNER = u"PieTime v%s by Tomek Wójcik" % (
+    version
+)
+MOTD_LICENSE_BANNER = u"Released under the MIT license"
+
+EVENT_QUIT = 0
+EVENT_CLICK_TO_UNBLANK = 1
+EVENT_CLICK_TO_PREV_CARD = 2
+EVENT_CLICK_TO_NEXT_CARD = 3
+
+
+class Quit(Exception):
+    pass
+
+
+class PieTimeEvent(object):
+    def __init__(self, app, event):
+        self.event = event
+        self.app = app
+
+    def is_quit(self):
+        return (self.event.type == pygame.QUIT)
+
+    def is_key_quit(self):
+        return (
+            self.event.type == pygame.KEYDOWN
+            and self.event.key == self.app.KEY_QUIT
+        )
+
+    def is_click_to_unblank(self):
+        return (
+            self.event.type == pygame.MOUSEBUTTONDOWN
+            and self.app._click_to_unblank_interval is not None
+            and self.app._is_blanked is True
+        )
+
+    def is_click_to_prev_card(self):
+        return (
+            self.event.type == pygame.MOUSEBUTTONDOWN
+            and self.app._click_to_transition is True
+            and self.app._is_blanked is False
+            and self.app._ctt_region_prev.collidepoint(self.event.pos) == 1
+        )
+
+    def is_click_to_next_card(self):
+        return (
+            self.event.type == pygame.MOUSEBUTTONDOWN
+            and self.app._click_to_transition is True
+            and self.app._is_blanked is False
+            and self.app._ctt_region_next.collidepoint(self.event.pos) == 1
+        )
+
+
+class PieTime(object):
+    """
+    The PieTime application.
+
+    :param deck: the deck
+    :param screen_size: tuple of (width, height) to use as the screen size
+    :param fps: number of frames per second to limit rendering to
+    :param blanker_schedule: blanker schedule
+    :param click_to_unblank_interval: time interval for click to unblank
+    :param click_to_transition: boolean defining if click to transition is
+        enabled
+    :param verbose: boolean defining if verbose logging should be on
+    :param log_path: path to log file (if omitted, *stdout* will be used)
+    """
+
+    #: Default background color
+    BACKGROUND_COLOR = (0, 0, 0)
+
+    #: Blanked screen color
+    BLANK_COLOR = (0, 0, 0)
+
+    #: Default card display duration interval
+    CARD_INTERVAL = 60
+
+    #: Defines key which quits the application
+    KEY_QUIT = pygame.K_ESCAPE
+
+    #: Defines size of click to transition region square
+    CLICK_TO_TRANSITION_REGION_SIZE = 30
+
+    _DEFAULT_OUTPUT_STREAM = sys.stdout
+
+    _STREAM_FACTORY = file
+
+    def __init__(self, deck, screen_size=(320, 240), fps=20,
+                 blanker_schedule=None, click_to_unblank_interval=None,
+                 click_to_transition=True, verbose=False, log_path=None):
+        self._deck = deck
+
+        #: The screen surface
+        self.screen = None
+        #: The screen size tuple
+        self.screen_size = screen_size
+        #: List of events captured in this frame
+        self.events = []
+        #: Path to log file. If `None`, *stdout* will be used for logging.
+        self.log_path = log_path
+
+        self._fps = fps
+        self._verbose = verbose
+        self._blanker_schedule = blanker_schedule
+        self._click_to_unblank_interval = click_to_unblank_interval
+        self._click_to_transition = click_to_transition
+        self._clock = None
+        self._cards = []
+        self._is_blanked = False
+        self._current_card_idx = None
+        self._current_card_time = None
+        self._should_quit = False
+        self._internal_events = set()
+        self._ctu_timer = None
+        self._output_stream = None
+
+        self._ctt_region_prev = pygame.Rect(
+            0,
+            self.screen_size[1] - self.CLICK_TO_TRANSITION_REGION_SIZE,
+            self.CLICK_TO_TRANSITION_REGION_SIZE,
+            self.CLICK_TO_TRANSITION_REGION_SIZE
+        )
+
+        self._ctt_region_next = pygame.Rect(
+            self.screen_size[0] - self.CLICK_TO_TRANSITION_REGION_SIZE,
+            self.screen_size[1] - self.CLICK_TO_TRANSITION_REGION_SIZE,
+            self.CLICK_TO_TRANSITION_REGION_SIZE,
+            self.CLICK_TO_TRANSITION_REGION_SIZE
+        )
+
+    def _should_blank(self, now=None):
+        if self._has_click_to_unblank_event() or self._ctu_timer is not None:
+            if self._is_blanked is False and self._ctu_timer is None:
+                self._ctu_timer = None
+                return False
+
+            if self._click_to_unblank_interval is not None:
+                if self._ctu_timer is None:
+                    self._ctu_timer = self._click_to_unblank_interval
+                    return False
+
+                self._ctu_timer -= self._clock.get_time() / 1000.0
+                if self._ctu_timer <= 0:
+                    self._ctu_timer = None
+                    return True
+                else:
+                    return False
+
+        if self._blanker_schedule:
+            delta_blanker_start, delta_blanker_end = self._blanker_schedule
+
+            if now is None:
+                now = datetime.datetime.now()
+
+            midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
+
+            blanker_start = midnight + delta_blanker_start
+            blanker_end = midnight + delta_blanker_end
+
+            if blanker_start > blanker_end:
+                if now.hour < 12:
+                    blanker_start -= datetime.timedelta(days=1)
+                else:
+                    blanker_end += datetime.timedelta(days=1)
+
+            if now >= blanker_start and now < blanker_end:
+                return True
+
+        return False
+
+    def _blank(self):
+        if self._is_blanked is False:
+            self.logger.debug('Blanking the screen!')
+            self.will_blank()
+
+        self._is_blanked = True
+        self.screen.fill(self.BLANK_COLOR)
+
+    def _unblank(self):
+        if self._is_blanked:
+            self.logger.debug('Unblanking the screen!')
+            self.will_unblank()
+
+            self._is_blanked = False
+            self._current_card_idx = 0
+            self._current_card_time = 0
+
+            self._cards[self._current_card_idx][0].show()
+
+    def _transition_cards(self, direction=1, force=False):
+        if self._current_card_idx is None and force is False:
+            self._current_card_idx = 0
+            self._current_card_time = 0
+            self._cards[self._current_card_idx][0].show()
+        elif len(self._cards) > 1:
+            self._current_card_time += self._clock.get_time() / 1000.0
+            card_interval = self._cards[self._current_card_idx][1]
+            if self._current_card_time >= card_interval or force is True:
+                new_card_idx = self._current_card_idx + direction
+                if new_card_idx >= len(self._cards):
+                    new_card_idx = 0
+                elif new_card_idx < 0:
+                    new_card_idx = len(self._cards) - 1
+
+                self.logger.debug('Card transition: %d -> %d' % (
+                    self._current_card_idx, new_card_idx
+                ))
+
+                self._cards[self._current_card_idx][0].hide()
+
+                self._current_card_idx = new_card_idx
+                self._current_card_time = 0
+
+                self._cards[self._current_card_idx][0].show()
+
+    def _get_events(self):
+        self._internal_events = set()
+        self.events = []
+        for event in pygame.event.get():
+            pie_time_event = PieTimeEvent(self, event)
+            if pie_time_event.is_quit():
+                self.logger.debug('_get_events: QUIT')
+                self._internal_events.add(EVENT_QUIT)
+            elif pie_time_event.is_key_quit():
+                self.logger.debug('_get_events: KEY_QUIT')
+                self._internal_events.add(EVENT_QUIT)
+            elif pie_time_event.is_click_to_unblank():
+                self.logger.debug('_get_events: CLICK_TO_UNBLANK')
+                self._internal_events.add(EVENT_CLICK_TO_UNBLANK)
+            elif pie_time_event.is_click_to_prev_card():
+                self.logger.debug('_get_events: CLICK_TO_PREV_CARD')
+                self._internal_events.add(EVENT_CLICK_TO_PREV_CARD)
+            elif pie_time_event.is_click_to_next_card():
+                self.logger.debug('_get_events: CLICK_TO_NEXT_CARD')
+                self._internal_events.add(EVENT_CLICK_TO_NEXT_CARD)
+            else:
+                self.events.append(event)
+
+    def _has_quit_event(self):
+        return (EVENT_QUIT in self._internal_events)
+
+    def _has_click_to_unblank_event(self):
+        return (EVENT_CLICK_TO_UNBLANK in self._internal_events)
+
+    def _start_clock(self):
+        self._clock = pygame.time.Clock()
+
+    def _setup_output_stream(self):
+        if self.log_path is None:
+            self._output_stream = self._DEFAULT_OUTPUT_STREAM
+        else:
+            self._output_stream = self._STREAM_FACTORY(self.log_path, 'a')
+
+    def _setup_logging(self):
+        logger = logging.getLogger('PieTime')
+        requests_logger = logging.getLogger('requests')
+
+        if self._verbose:
+            logger.setLevel(logging.DEBUG)
+        else:
+            logger.setLevel(logging.INFO)
+            requests_logger.setLevel(logging.WARNING)
+
+        handler = logging.StreamHandler(self._output_stream)
+        formatter = logging.Formatter(
+            '%(asctime)s PieTime: %(levelname)s: %(message)s'
+        )
+        handler.setFormatter(formatter)
+        logger.addHandler(handler)
+
+        for requests_handler in requests_logger.handlers:
+            requests_logger.removeHandler(requests_handler)
+
+        requests_logger.addHandler(handler)
+
+    @property
+    def logger(self):
+        """The application-wide :py:class:`logging.Logger` object."""
+        if not hasattr(self, '_logger'):
+            self._logger = logging.getLogger('PieTime')
+
+        return self._logger
+
+    def init_pygame(self):
+        """Initializes PyGame and the internal clock."""
+        self.logger.debug('Initializing PyGame.')
+        pygame.init()
+        pygame.mouse.set_visible(False)
+
+    def quit_pygame(self):
+        """Quits PyGame."""
+        self.logger.debug('Quitting PyGame.')
+        pygame.quit()
+
+        self._clock = None
+
+    def init_cards(self):
+        """
+        Initializes the cards.
+
+        Initialization of a card consits of the following steps:
+
+        * Creating an instance of the card class,
+        * Binding the card with the application
+          (:py:meth:`pie_time.AbstractCard.set_app`),
+        * Setting the card's settings
+          (:py:meth:`pie_time.AbstractCard.set_settings`),
+        * Initializing the card (:py:meth:`pie_time.AbstractCard.initialize`).
+        """
+        self.logger.debug('Initializing cards.')
+        for i in xrange(0, len(self._deck)):
+            card_def = self._deck[i]
+
+            klass = None
+            interval = self.CARD_INTERVAL
+            settings = {}
+
+            if not isinstance(card_def, tuple):
+                klass = card_def
+            elif len(card_def) == 2:
+                klass, interval = card_def
+            elif len(card_def) == 3:
+                klass, interval, settings = card_def
+
+            if klass is not None:
+                card = klass()
+                card.set_app(self)
+                card.set_settings(settings)
+                card.initialize()
+
+                self._cards.append((card, interval))
+            else:
+                self.logger.warning('Invalid deck entry at index: %d' % i)
+
+    def destroy_cards(self):
+        """
+        Destroys the cards.
+
+        Calls the :py:meth:`pie_time.AbstractCard.quit` of each card.
+        """
+        self.logger.debug('Destroying cards.')
+        while len(self._cards) > 0:
+            card, _ = self._cards.pop()
+
+            try:
+                card.quit()
+            except:
+                self.logger.error('ERROR!', exc_info=True)
+
+    def get_screen(self):
+        """Creates and returns the screen screen surface."""
+        self.logger.debug('Creating screen.')
+        return pygame.display.set_mode(self.screen_size)
+
+    def fill_screen(self):
+        """
+        Fills the screen surface with color defined in
+        :py:attr:`pie_time.PieTime.BACKGROUND_COLOR`.
+        """
+        self.screen.fill(self.BACKGROUND_COLOR)
+
+    def run(self, standalone=True):
+        """
+        Runs the application.
+
+        This method contains the app's main loop and it never returns. Upon
+        quitting, this method will call the :py:func:`sys.exit` function with
+        the status code (``99`` if an unhandled exception occurred, ``0``
+        otherwise).
+
+        The application will quit under one of the following conditions:
+
+        * An unhandled exception reached this method,
+        * PyGame requested to quit (e.g. due to closing the window),
+        * Some other code called the :py:meth:`pie_time.PieTime.quit` method on
+          the application.
+
+        Before quitting the :py:meth:`pie_time.PieTime.destroy_cards` and
+        :py:meth:`pie_time.PieTime.quit_pygame` methods will be called to clean
+        up.
+        """
+        result = RET_OK
+
+        self._setup_output_stream()
+        self._setup_logging()
+
+        try:
+            self.logger.info(MOTD_PICLOCK_BANNER)
+            self.logger.info(copyright)
+            self.logger.info(MOTD_LICENSE_BANNER)
+            self.logger.debug('My PID = %d' % os.getpid())
+
+            self.init_pygame()
+            self.screen = self.get_screen()
+
+            self.init_cards()
+
+            self._start_clock()
+
+            while True:
+                self._get_events()
+
+                if self._should_quit or self._has_quit_event():
+                    raise Quit()
+
+                if not self._should_blank():
+                    self._unblank()
+
+                    if EVENT_CLICK_TO_PREV_CARD in self._internal_events:
+                        self._transition_cards(direction=-1, force=True)
+                    elif EVENT_CLICK_TO_NEXT_CARD in self._internal_events:
+                        self._transition_cards(direction=1, force=True)
+                    else:
+                        self._transition_cards()
+
+                    card = self._cards[self._current_card_idx][0]
+
+                    card.tick()
+
+                    self.fill_screen()
+                    self.screen.blit(
+                        card.surface, (0, 0, card.width, card.height)
+                    )
+                else:
+                    self._blank()
+
+                pygame.display.flip()
+
+                self._clock.tick(self._fps)
+        except Exception as exc:
+            if not isinstance(exc, Quit):
+                self.logger.error('ERROR!', exc_info=True)
+                result = RET_ERROR
+        finally:
+            self.destroy_cards()
+            self.quit_pygame()
+
+            if standalone:
+                sys.exit(result)
+            else:
+                return result
+
+    def quit(self):
+        """Tells the application to quit."""
+        self._should_quit = True
+
+    def will_blank(self):
+        """
+        Called before blanking the screen.
+
+        This method can be used to perform additional operations before
+        blanking the screen.
+
+        The default implementation does nothing.
+        """
+        pass
+
+    def will_unblank(self):
+        """
+        Called before unblanking the screen.
+
+        This method can be used to perform additional operations before
+        unblanking the screen.
+
+        The default implementation does nothing.
+        """
+
+        pass

+ 195 - 0
pie_time/card.py

@@ -0,0 +1,195 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
+#
+# 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.
+#
+
+"""
+pie_time.card
+=============
+
+This module contains the AbstractCard class.
+"""
+
+import os
+import sys
+
+import pygame
+
+
+class AbstractCard(object):
+    """
+    The abstract card class.
+
+    All the custom cards **must** inherit from this class.
+
+    **Application binding and settings.**
+
+    The application calls the card's :py:meth:`pie_time.AbstractCard.set_app`
+    and :py:meth:`pie_time.AbstractCard.set_settings` methods during
+    initialization (before calling the
+    :py:meth:`pie_time.AbstractCard.initialize` method).
+
+    The application reference is stored in ``_app`` attribute.
+
+    The settings dictionary is stored in ``_settings`` attribute and defaults
+    to an empty dictionary.
+
+    **Drawing**
+
+    All the drawing on the card's surface should be done in the
+    :py:meth:`pie_time.AbstractCard.tick` method. The method's implementation
+    should be as fast as possible to avoid throttling the FPS down.
+
+    **Resources**
+
+    The :py:meth:`pie_time.AbstractCard.path_for_resource` method can be used
+    to get an absolute path to a resource file. The card's resource folder
+    should be placed along with the module containing the card's class.
+
+    Name of the resource folder can be customized by overriding the
+    :py:attr:`pie_time.AbstractCard.RESOURCE_FOLDER` attribute.
+    """
+    #: Name of the folder containing the resources
+    RESOURCE_FOLDER = 'resources'
+
+    def __init__(self):
+        self._app = None
+        self._settings = {}
+        self._surface = None
+
+    def set_app(self, app):
+        """Binds the card with the *app*."""
+        self._app = app
+
+    def set_settings(self, settings):
+        """Sets *settings* as the card's settings."""
+        self._settings = settings
+
+    @property
+    def width(self):
+        """The card's surface width. Defaults to the app screen's width."""
+        return self._app.screen_size[0]
+
+    @property
+    def height(self):
+        """The card's surface height. Defaults to the app screen's height."""
+        return self._app.screen_size[1]
+
+    @property
+    def surface(self):
+        """
+        The cards surface. The surface width and height are defined by the
+        respective properties of the class.
+        """
+        if self._surface is None:
+            self._surface = pygame.surface.Surface((self.width, self.height))
+
+        return self._surface
+
+    @property
+    def background_color(self):
+        """
+        The background color. Defaults to
+        :py:attr:`pie_time.PieTime.BACKGROUND_COLOR`.
+        """
+        return self._settings.get(
+            'background_color', self._app.BACKGROUND_COLOR
+        )
+
+    def path_for_resource(self, resource, folder=None):
+        """
+        Returns an absolute path for *resource*. The optional *folder*
+        keyword argument allows specifying a subpath.
+        """
+        _subpath = ''
+        if folder:
+            _subpath = folder
+
+        module_path = sys.modules[self.__module__].__file__
+
+        return os.path.join(
+            os.path.abspath(os.path.dirname(module_path)),
+            self.RESOURCE_FOLDER, _subpath, resource
+        )
+
+    def initialize(self):
+        """
+        Initializes the card.
+
+        The application calls this method right after creating an instance of
+        the class.
+
+        This method can be used to perform additional initialization on the
+        card, e.g. loading resources, setting the initial state etc.
+
+        The default implementation does nothing.
+        """
+        pass
+
+    def quit(self):
+        """
+        Initializes the card.
+
+        This method can be used to perform additional cleanup on the
+        card, e.g. stop threads, free resources etc.
+
+        The default implementation does nothing.
+        """
+
+    def show(self):
+        """
+        Shows the card.
+
+        The application calls this method each time the card becomes the
+        current card.
+
+        This method can be used to reset initial state, e.g. sprite positions.
+
+        The default implementation does nothing.
+        """
+        pass
+
+    def hide(self):
+        """
+        Hides the card.
+
+        The application calls this method each time the card resignes the
+        current card.
+
+        This method can be used to e.g. stop threads which aren't supposed to
+        be running when the card isn't being displayed.
+
+        The default implementation does nothing.
+        """
+        pass
+
+    def tick(self):
+        """
+        Ticks the card.
+
+        The application calls this method on the current card in every main
+        loop iteration.
+
+        This method should be used to perform drawing and other operations
+        needed to properly display the card on screen.
+
+        Subclasses **must** override this method.
+        """
+        raise NotImplementedError('TODO')

+ 3 - 0
pie_time/cards/__init__.py

@@ -0,0 +1,3 @@
+from .clock import ClockCard
+from .picture import PictureCard
+from .weather import WeatherCard

+ 154 - 0
pie_time/cards/clock.py

@@ -0,0 +1,154 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
+#
+# 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.
+#
+
+"""
+pie_time.cards.clock
+====================
+
+This module containse the ClockCard class.
+"""
+
+import datetime
+
+import pygame
+
+from pie_time.card import AbstractCard
+
+
+class ClockCard(AbstractCard):
+    """
+    The clock card.
+
+    This card displays a digital clock and date.
+
+    **Settings dictionary keys**:
+
+    * **time_format** (*string*) - time format string (*strftime()*
+      compatible). Defaults to :py:attr:`pie_time.cards.ClockCard.TIME_FORMAT`
+    * **time_blink** (*boolean*) - if set to ``True`` the semicolons will
+      blink. Defaults to ``True``.
+    * **time_color** (*tuple*) - time text color. Defaults to
+      :py:attr:`pie_time.cards.ClockCard.GREEN`
+    * **date_format** (*string*) - date format string (*strftime()*
+      compatible). Defaults to :py:attr:`pie_time.cards.ClockCard.DATE_FORMAT`
+    * **date_color** (*tuple*) - date text color. Defaults to
+      :py:attr:`pie_time.cards.ClockCard.GREEN`
+    """
+
+    #: Green color for text
+    GREEN = (96, 253, 108)
+
+    #: Default time format
+    TIME_FORMAT = '%I:%M %p'
+
+    #: Default date format
+    DATE_FORMAT = '%a, %d %b %Y'
+
+    def initialize(self):
+        self._time_font = pygame.font.Font(
+            self.path_for_resource('PTM55FT.ttf'), 63
+        )
+
+        self._date_font = pygame.font.Font(
+            self.path_for_resource('opensans-light.ttf'), 36
+        )
+
+        self._now = None
+        self._current_interval = 0
+
+    def _render_time(self, now):
+        time_format = self._settings.get('time_format', self.TIME_FORMAT)
+
+        if self._settings.get('time_blink', True) and now.second % 2 == 1:
+            time_format = time_format.replace(':', ' ')
+
+        current_time = now.strftime(time_format)
+
+        text = self._time_font.render(
+            current_time, True, self._settings.get('time_color', self.GREEN)
+        )
+
+        return text
+
+    def _render_date(self, now):
+        date_format = self._settings.get('date_format', self.DATE_FORMAT)
+
+        current_date = now.strftime(date_format)
+
+        text = self._date_font.render(
+            current_date, True, self._settings.get('date_color', self.GREEN)
+        )
+
+        return text
+
+    def _update_now(self):
+        if self._now is None:
+            self._now = datetime.datetime.now()
+            self._current_interval = 0
+
+            return True
+        else:
+            self._current_interval += self._app._clock.get_time()
+
+            if self._current_interval >= 1000:
+                self._now = datetime.datetime.now()
+                self._current_interval = self._current_interval - 1000
+
+                return True
+
+        return False
+
+    def show(self):
+        self._now = None
+
+    def tick(self):
+        now_updated = self._update_now()
+
+        if now_updated:
+            time_text = self._render_time(self._now)
+            date_text = self._render_date(self._now)
+
+            time_text_size = time_text.get_size()
+            date_text_size = date_text.get_size()
+
+            time_text_origin_y = (
+                (self.height - time_text_size[1] - date_text_size[1]) / 2.0
+            )
+
+            time_text_rect = (
+                (self.width - time_text_size[0]) / 2.0,
+                time_text_origin_y,
+                time_text_size[0],
+                time_text_size[1]
+            )
+
+            date_text_rect = (
+                (self.width - date_text_size[0]) / 2.0,
+                time_text_origin_y + time_text_size[1],
+                date_text_size[0],
+                date_text_size[1]
+            )
+
+            self.surface.fill(self.background_color)
+
+            self.surface.blit(time_text, time_text_rect)
+            self.surface.blit(date_text, date_text_rect)

+ 140 - 0
pie_time/cards/picture.py

@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
+#
+# 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.
+#
+
+"""
+pie_time.cards.picture
+======================
+
+This module containse the PictureCard class.
+"""
+
+import cStringIO
+import os
+import urlparse
+
+import pygame
+import requests
+
+from pie_time.card import AbstractCard
+
+
+class PictureCard(AbstractCard):
+    """
+    The picture card.
+
+    This cards displays a picture from list of pre-defined pictures. If more
+    than one picture is defined, it's changed each time the card transitions
+    to current card.
+
+    **Settings dictionary keys**:
+
+    * **urls** (*list*) - **required** list of picture URLs. Currently, only
+      ``file://``, ``http://`` and ``https://`` URL schemes are supported.
+    """
+
+    def initialize(self):
+        self._pictures = []
+        self._current_picture_idx = None
+        self._should_redraw = True
+
+        for url in self._settings['urls']:
+            self._pictures.append(self._load_picture(url))
+
+    def _load_picture(self, url):
+        self._app.logger.debug(
+            'PictureCard: Attempting to load picture: %s' % url
+        )
+
+        parsed_url = urlparse.urlparse(url)
+
+        surface = None
+        try:
+            format = None
+            if parsed_url.scheme == 'file':
+                surface = pygame.image.load(parsed_url.path)
+
+                _, ext = os.path.splitext(parsed_url.path)
+                format = ext.lower()
+            elif parsed_url.scheme.startswith('http'):
+                rsp = requests.get(url)
+                assert rsp.status_code == 200
+
+                format = rsp.headers['Content-Type'].replace('image/', '')
+
+                surface = pygame.image.load(
+                    cStringIO.StringIO(rsp.content), 'picture.%s' % format
+                )
+
+            if surface and format:
+                if format.lower().endswith('png'):
+                    surface = surface.convert_alpha(self._app.screen)
+                else:
+                    surface = surface.convert(self._app.screen)
+        except Exception as exc:
+            self._app.logger.error(
+                'PictureCard: Could not load picture: %s' % url, exc_info=True
+            )
+
+        return surface
+
+    def show(self):
+        if len(self._pictures) == 0:
+            self._current_picture_idx = None
+        elif len(self._pictures) == 1:
+            self._current_picture_idx = 0
+        else:
+            if self._current_picture_idx is None:
+                self._current_picture_idx = 0
+            else:
+                new_picture_idx = self._current_picture_idx + 1
+                if new_picture_idx >= len(self._pictures):
+                    new_picture_idx = 0
+
+                self._app.logger.debug(
+                    'PictureCard: Picture transition %d -> %d' % (
+                        self._current_picture_idx, new_picture_idx
+                    )
+                )
+
+                self._current_picture_idx = new_picture_idx
+
+        self._should_redraw = True
+
+    def tick(self):
+        if self._should_redraw:
+            self.surface.fill(self.background_color)
+
+            if self._current_picture_idx is not None:
+                picture = self._pictures[self._current_picture_idx]
+                picture_size = picture.get_size()
+
+                picture_rect = picture.get_rect()
+                if picture_size != self._app.screen_size:
+                    picture_rect = (
+                        (self.width - picture_size[0]) / 2.0,
+                        (self.height - picture_size[1]) / 2.0,
+                        picture_size[0], picture_size[1]
+                    )
+
+                self.surface.blit(picture, picture_rect)
+
+            self._should_redraw = False

BIN
pie_time/cards/resources/PTM55FT.ttf


BIN
pie_time/cards/resources/linea-weather-10.ttf


BIN
pie_time/cards/resources/opensans-light.ttf


+ 286 - 0
pie_time/cards/weather.py

@@ -0,0 +1,286 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
+#
+# 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.
+#
+
+"""
+pie_time.cards.weather
+======================
+
+This module containse the WeatherCard class.
+"""
+
+from threading import Timer
+
+import pygame
+import requests
+
+from pie_time.card import AbstractCard
+
+URL_TEMPLATE = (
+    'http://api.openweathermap.org/data/2.5/weather?q=%s&units=%s&APPID=%s'
+)
+
+
+class WeatherCard(AbstractCard):
+    """
+    The weather card.
+
+    This cards displays the current weather for a selected city. The weather
+    information is obtained from OpenWeatherMap.
+
+    **Settings dictionary keys**:
+
+    * **api_key** (*string*) - **required** API key.
+    * **city** (*string*) - **required** name of the city.
+    * **units** (*string*) - units name (``metric`` or ``imperial``). Defaults
+      to :py:attr:`pie_time.cards.WeatherCard.UNITS`
+    * **refresh_interval** (*int*) - refresh interval in seconds. Defaults to
+      :py:attr:`pie_time.cards.WeatherCard.REFRESH_INTERVAL`
+    * **city_color** (*tuple*) - city text color. Defaults to
+      :py:attr:`pie_time.cards.WeatherCard.WHITE`
+    * **icon_color** (*tuple*) - icon text color. Defaults to
+      :py:attr:`pie_time.cards.WeatherCard.WHITE`
+    * **temperature_color** (*tuple*) - temperature text color. Defaults to
+      :py:attr:`pie_time.cards.WeatherCard.WHITE`
+    * **conditions_color** (*tuple*) - conditions text color. Defaults to
+      :py:attr:`pie_time.cards.WeatherCard.WHITE`
+    """
+
+    #: Default units
+    UNITS = 'metric'
+
+    #: Default refresh interval
+    REFRESH_INTERVAL = 600
+
+    #: White color for text
+    WHITE = (255, 255, 255)
+
+    WEATHER_CODE_TO_ICON = {
+        '01d': u'',
+        '01n': u'',
+        '02d': u'',
+        '02n': u'',
+        '03d': u'',
+        '03n': u'',
+        '04d': u'',
+        '04n': u'',
+        '09d': u'',
+        '09n': u'',
+        '10d': u'',
+        '10n': u'',
+        '11d': u'',
+        '11n': u'',
+        '13d': u'',
+        '13n': u'',
+        '50d': u'',
+        '50n': u''
+    }
+    ICON_SPACING = 24
+
+    def initialize(self, refresh=True):
+        assert 'api_key' in self._settings,\
+            'Configuration error: missing API key'
+        assert 'city' in self._settings, 'Configuration error: missing city'
+
+        self._text_font = pygame.font.Font(
+            self.path_for_resource('opensans-light.ttf'), 30
+        )
+
+        self._temp_font = pygame.font.Font(
+            self.path_for_resource('opensans-light.ttf'), 72
+        )
+
+        self._icon_font = pygame.font.Font(
+            self.path_for_resource('linea-weather-10.ttf'), 128
+        )
+
+        self._timer = None
+        self._current_conditions = None
+        self._should_redraw = True
+
+        if refresh:
+            self._refresh_conditions()
+
+    def _refresh_conditions(self):
+        self._app.logger.debug('Refreshing conditions.')
+        self._timer = None
+
+        try:
+            rsp = requests.get(
+                URL_TEMPLATE % (
+                    self._settings['city'],
+                    self._settings.get('units', self.UNITS),
+                    self._settings['api_key']
+                )
+            )
+
+            if rsp.status_code != 200:
+                self._app.logger.error(
+                    'WeatherCard: Received HTTP %d' % rsp.status_code
+                )
+            else:
+                try:
+                    payload = rsp.json()
+                    self._current_conditions = {
+                        'conditions': payload['weather'][0]['main'],
+                        'icon': payload['weather'][0].get('icon', None),
+                        'temperature': payload['main']['temp']
+                    }
+                    self._should_redraw = True
+                except:
+                    self._app.logger.error(
+                        'WeatherCard: ERROR!', exc_info=True
+                    )
+        except:
+            self._app.logger.error('WeatherCard: ERROR!', exc_info=True)
+
+        self._timer = Timer(
+            self._settings.get('refresh_interval', self.REFRESH_INTERVAL),
+            self._refresh_conditions
+        )
+        self._timer.start()
+
+    def _render_city(self):
+        city_text = self._text_font.render(
+            self._settings['city'], True,
+            self._settings.get('city_color', self.WHITE)
+        )
+
+        return city_text
+
+    def _render_conditions(self):
+        conditions_text = self._text_font.render(
+            self._current_conditions['conditions'].capitalize(),
+            True, self._settings.get('conditions_color', self.WHITE)
+        )
+
+        return conditions_text
+
+    def _render_icon(self):
+        icon = self._current_conditions['icon']
+        weather_icon = None
+
+        if icon in self.WEATHER_CODE_TO_ICON:
+            weather_icon = self._icon_font.render(
+                self.WEATHER_CODE_TO_ICON[icon],
+                True, self._settings.get('icon_color', self.WHITE)
+            )
+
+        return weather_icon
+
+    def _render_temperature(self):
+        temp_text = self._temp_font.render(
+            u'%d°' % self._current_conditions['temperature'],
+            True,
+            self._settings.get('temperature_color', self.WHITE)
+        )
+
+        return temp_text
+
+    def quit(self):
+        if self._timer is not None:
+            self._timer.cancel()
+
+    def tick(self):
+        if self._should_redraw:
+            self.surface.fill(self.background_color)
+
+            city_text = self._render_city()
+            city_text_size = city_text.get_size()
+            city_text_rect = (
+                (self.width - city_text_size[0]) / 2.0,
+                0,
+                city_text_size[0],
+                city_text_size[1]
+            )
+            self.surface.blit(city_text, city_text_rect)
+
+            if self._current_conditions:
+                conditions_text = self._render_conditions()
+                conditions_text_size = conditions_text.get_size()
+                conditions_text_rect = (
+                    (self.width - conditions_text_size[0]) / 2.0,
+                    self.height - conditions_text_size[1],
+                    conditions_text_size[0],
+                    conditions_text_size[1]
+                )
+                self.surface.blit(conditions_text, conditions_text_rect)
+
+                icon = self._render_icon()
+                has_icon = (icon is not None)
+
+                temp_text = self._render_temperature()
+                temp_text_size = temp_text.get_size()
+
+                if has_icon:
+                    icon_size = icon.get_size()
+                    icon_origin_x = (
+                        (
+                            self.width - (
+                                icon_size[0] + self.ICON_SPACING +
+                                temp_text_size[0]
+                            )
+                        ) / 2.0
+                    )
+                    icon_origin_y = (
+                        city_text_size[1] + (
+                            self.height - conditions_text_size[1] -
+                            city_text_size[1] - icon_size[1]
+                        ) / 2.0
+                    )
+                    icon_rect = (
+                        icon_origin_x,
+                        icon_origin_y,
+                        icon_size[0],
+                        icon_size[1]
+                    )
+
+                    self.surface.blit(icon, icon_rect)
+
+                temp_text_origin_y = (
+                    city_text_size[1] + (
+                        self.height - conditions_text_size[1] -
+                        city_text_size[1] - temp_text_size[1]
+                    ) / 2.0
+                )
+
+                if has_icon:
+                    temp_text_origin_x = (
+                        icon_rect[0] + icon_size[0] +
+                        self.ICON_SPACING
+                    )
+                    temp_text_rect = (
+                        temp_text_origin_x,
+                        temp_text_origin_y,
+                        temp_text_size[0],
+                        temp_text_size[1]
+                    )
+                else:
+                    temp_text_rect = (
+                        (self.width - temp_text_size[0]) / 2.0,
+                        temp_text_origin_y,
+                        temp_text_size[0],
+                        temp_text_size[1]
+                    )
+
+                self.surface.blit(temp_text, temp_text_rect)
+
+            self._should_redraw = False

+ 0 - 0
pie_time/scripts/__init__.py


+ 104 - 0
pie_time/scripts/pie_time.py

@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
+#
+# 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.
+#
+
+import ConfigParser
+import imp
+import os
+import sys
+import traceback
+
+RET_OK = 0
+RET_NO_ARGS = 1
+RET_ERROR = 99
+
+CONFIG_SECTION_PIE_TIME = 'PieTime'
+CONFIG_SECTION_SDL = 'SDL'
+
+SDL_DEFAULTS = {
+    'VIDEODRIVER': None
+}
+
+def _find_module(name, search_path):
+    import_path = name.split('.', 1)
+
+    mod_f, mod_path, mod_desc = imp.find_module(import_path[0], search_path)
+    if mod_desc[2] == imp.PKG_DIRECTORY:
+        return _find_module(
+            import_path[1], [os.path.abspath(mod_path)]
+        )
+    else:
+        return mod_f, mod_path, mod_desc
+
+def main():
+    try:
+        config_file_path = sys.argv[1]
+    except IndexError:
+        print 'usage: %s [CONFIG_FILE]' % sys.argv[0]
+        return RET_NO_ARGS
+
+    config = ConfigParser.SafeConfigParser()
+    config.optionxform = str
+    config.read(config_file_path)
+
+    app_spec = config.get(CONFIG_SECTION_PIE_TIME, 'app_module', True)
+    try:
+        app_module, app_obj = app_spec.split(':')
+    except ValueError:
+        print "%s: failed to find application '%s'" % (
+            sys.argv[0], app_spec
+        )
+        return RET_ERROR
+
+    mod_f = None
+    result = RET_OK
+    try:
+        mod_search_path = [os.getcwd()] + sys.path
+        mod_f, mod_path, mod_desc = _find_module(app_module, mod_search_path)
+
+        mod = imp.load_module(app_module, mod_f, mod_path, mod_desc)
+        app = getattr(mod, app_obj)
+
+        if config.has_option(CONFIG_SECTION_PIE_TIME, 'log_path'):
+            app.log_path = config.get(CONFIG_SECTION_PIE_TIME, 'log_path')
+
+        sdl_config = dict(SDL_DEFAULTS)
+        if config.has_section(CONFIG_SECTION_SDL):
+            sdl_config.update({
+                x[0]: x[1] for x in config.items(CONFIG_SECTION_SDL)
+            })
+
+        for k, v in sdl_config.iteritems():
+            if v:
+                os.environ['SDL_%s' % k] = v
+
+        result = app.run(standalone=False)
+    except:
+        traceback.print_exc()
+        result = RET_ERROR
+    finally:
+        if mod_f:
+            mod_f.close()
+
+    return result
+
+if __name__ == '__main__':
+    sys.exit(main())

+ 3 - 0
requirements-dev.txt

@@ -0,0 +1,3 @@
+mock==1.3.0
+nose==1.3.7
+Sphinx==1.3.5

+ 2 - 0
requirements.txt

@@ -0,0 +1,2 @@
+pygame>=1.9.1
+requests>=2.4.1

+ 73 - 0
setup.py

@@ -0,0 +1,73 @@
+#!/usr/bin/env python2.7
+# -*- coding: utf-8 -*-
+# Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
+#
+# 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.
+#
+
+import codecs
+from setuptools import setup
+
+import pie_time
+
+with codecs.open('README.rst', 'r', 'utf-8') as desc_f:
+    long_description = desc_f.read()
+
+with codecs.open('requirements.txt', 'r', 'utf-8') as requirements_f:
+    requirements = requirements_f.read().split('\n')
+
+SCRIPTS = [
+    'pie_time = pie_time.scripts.pie_time:main'
+]
+
+DOWNLOAD_URL = (
+    'https://git.bthlabs.pl/tomekwojcik/pie-time/archive/v%s.tar.gz' %
+    pie_time.__version__
+)
+
+setup(
+    name="pie_time",
+    version=pie_time.__version__,
+    packages=[
+        'pie_time',
+        'pie_time.cards',
+        'pie_time.scripts'
+    ],
+    include_package_data=True,
+    test_suite='nose.collector',
+    zip_safe=False,
+    platforms='any',
+    tests_require=[
+        'nose',
+    ],
+    author=pie_time.__author__.encode('utf-8'),
+    author_email='tomek@bthlabs.pl',
+    maintainer=pie_time.__author__.encode('utf-8'),
+    maintainer_email='tomek@bthlabs.pl',
+    url='https://pie-time.bthlabs.pl/',
+    download_url=DOWNLOAD_URL,
+    description='Desk clock for your Raspberry Pi.',
+    long_description=long_description,
+    license='https://git.bthlabs.pl/tomekwojcik/pie-time/src/master/LICENSE',
+    classifiers=[],
+    install_requires=requirements,
+    entry_points={
+        'console_scripts': SCRIPTS
+    }
+)

+ 89 - 0
tests/test_abstract_card.py

<
@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+
+import mock
+import os
+import pygame
+
+from pie_time import application as app_module
+from pie_time.application import PieTime
+from pie_time.card import AbstractCard
+
+
+class Test_AbstractCard(object):
+    def _dummy_card(self, **settings):
+        app = PieTime(None)
+        card = AbstractCard()
+        card.set_app(app)
+        card.set_settings(settings)
+
+        return card
+
+    def test_init(self):
+        card = AbstractCard()
+        assert card._app is None
+        assert card._settings == {}
+        assert card._surface is None
+
+    def test_set_app(self):
+        card = AbstractCard()
+        app = mock.Mock(spec=PieTime)
+        card.set_app(app)
+        assert card._app == app
+
+    def test_set_settings(self):