commit 0912fd15e8ef23a8d6a2212259b0b6935f520801 Author: Tomek Wójcik Date: Sun Feb 7 15:41:31 2016 +0100 Initial public release of PieTime! \o/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d121c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.pyc +*.pyo +*.swp +.pybuild/ +build/ +dist/ +pie_time.egg-info/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0700c51 --- /dev/null +++ b/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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cd505c8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2014-2016 Tomek Wójcik + +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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..da92adb --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include pie_time/cards/resources/*.ttf +include README.rst +include LICENSE +include requirements.txt diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..f99516a --- /dev/null +++ b/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 `_. + +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). diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..69fa449 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +_build/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..9fa6ebc --- /dev/null +++ b/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 ' where 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." diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..300c683 --- /dev/null +++ b/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: diff --git a/docs/builtin_cards.rst b/docs/builtin_cards.rst new file mode 100644 index 0000000..db1d43f --- /dev/null +++ b/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: diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..ce335ed --- /dev/null +++ b/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 +# " v 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 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 diff --git a/docs/developer_guide.rst b/docs/developer_guide.rst new file mode 100644 index 0000000..cf1326a --- /dev/null +++ b/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. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..e15e411 --- /dev/null +++ b/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 `_. + +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 `_. + +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` diff --git a/docs/requirements_and_installation.rst b/docs/requirements_and_installation.rst new file mode 100644 index 0000000..b9f00a2 --- /dev/null +++ b/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 `_ diff --git a/docs/user_guide.rst b/docs/user_guide.rst new file mode 100644 index 0000000..898ebcb --- /dev/null +++ b/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 `_ 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 + +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 `_ 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. diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/basic_usage.py b/examples/basic_usage.py new file mode 100644 index 0000000..b8a8492 --- /dev/null +++ b/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() diff --git a/examples/customization_example.py b/examples/customization_example.py new file mode 100644 index 0000000..ae676c6 --- /dev/null +++ b/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() diff --git a/examples/example.ini b/examples/example.ini new file mode 100644 index 0000000..695172c --- /dev/null +++ b/examples/example.ini @@ -0,0 +1,3 @@ +[PieTime] +app_module = examples.customization_example:app +log_path = log.txt diff --git a/extra/initscript.debian b/extra/initscript.debian new file mode 100644 index 0000000..dfe7159 --- /dev/null +++ b/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 diff --git a/extra/pie-time.ini.example b/extra/pie-time.ini.example new file mode 100644 index 0000000..427a697 --- /dev/null +++ b/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 diff --git a/pie_time/__init__.py b/pie_time/__init__.py new file mode 100644 index 0000000..a8f61d7 --- /dev/null +++ b/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 ' +) + +from .application import PieTime +from .card import AbstractCard diff --git a/pie_time/application.py b/pie_time/application.py new file mode 100644 index 0000000..8246a0d --- /dev/null +++ b/pie_time/application.py @@ -0,0 +1,511 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2014-2016 Tomek Wójcik +# +# 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 diff --git a/pie_time/card.py b/pie_time/card.py new file mode 100644 index 0000000..bd4cfe6 --- /dev/null +++ b/pie_time/card.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2014-2016 Tomek Wójcik +# +# 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') diff --git a/pie_time/cards/__init__.py b/pie_time/cards/__init__.py new file mode 100644 index 0000000..02161f3 --- /dev/null +++ b/pie_time/cards/__init__.py @@ -0,0 +1,3 @@ +from .clock import ClockCard +from .picture import PictureCard +from .weather import WeatherCard diff --git a/pie_time/cards/clock.py b/pie_time/cards/clock.py new file mode 100644 index 0000000..1bc18b9 --- /dev/null +++ b/pie_time/cards/clock.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2014-2016 Tomek Wójcik +# +# 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) diff --git a/pie_time/cards/picture.py b/pie_time/cards/picture.py new file mode 100644 index 0000000..a190a7e --- /dev/null +++ b/pie_time/cards/picture.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2014-2016 Tomek Wójcik +# +# 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 diff --git a/pie_time/cards/resources/PTM55FT.ttf b/pie_time/cards/resources/PTM55FT.ttf new file mode 100755 index 0000000..f580123 Binary files /dev/null and b/pie_time/cards/resources/PTM55FT.ttf differ diff --git a/pie_time/cards/resources/linea-weather-10.ttf b/pie_time/cards/resources/linea-weather-10.ttf new file mode 100644 index 0000000..3ffc94a Binary files /dev/null and b/pie_time/cards/resources/linea-weather-10.ttf differ diff --git a/pie_time/cards/resources/opensans-light.ttf b/pie_time/cards/resources/opensans-light.ttf new file mode 100755 index 0000000..0d38189 Binary files /dev/null and b/pie_time/cards/resources/opensans-light.ttf differ diff --git a/pie_time/cards/weather.py b/pie_time/cards/weather.py new file mode 100644 index 0000000..4b40052 --- /dev/null +++ b/pie_time/cards/weather.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2014-2016 Tomek Wójcik +# +# 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 diff --git a/pie_time/scripts/__init__.py b/pie_time/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pie_time/scripts/pie_time.py b/pie_time/scripts/pie_time.py new file mode 100644 index 0000000..e053fd6 --- /dev/null +++ b/pie_time/scripts/pie_time.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2014-2016 Tomek Wójcik +# +# 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()) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..bb6b3d8 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +mock==1.3.0 +nose==1.3.7 +Sphinx==1.3.5 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c792661 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pygame>=1.9.1 +requests>=2.4.1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d83ca91 --- /dev/null +++ b/setup.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python2.7 +# -*- coding: utf-8 -*- +# Copyright (c) 2014-2016 Tomek Wójcik +# +# 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 + } +) diff --git a/tests/test_abstract_card.py b/tests/test_abstract_card.py new file mode 100644 index 0000000..6538358 --- /dev/null +++ b/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): + card = AbstractCard() + settings = {'spam': 'eggs'} + card.set_settings(settings) + assert card._settings == settings + + def test_width(self): + card = self._dummy_card() + assert card.width == card._app.screen_size[0] + + def test_height(self): + card = self._dummy_card() + assert card.height == card._app.screen_size[1] + + def test_surface(self): + fake_surface = mock.Mock(spec=pygame.surface.Surface) + + with mock.patch.object(app_module.pygame.surface, 'Surface', + return_value=fake_surface): + card = self._dummy_card() + assert card.surface == fake_surface + assert card._surface == card.surface + app_module.pygame.surface.Surface.assert_called_with(( + card.width, card.height + )) + + def test_background_color(self): + card = self._dummy_card() + assert card.background_color == card._app.BACKGROUND_COLOR + + def test_background_color_override(self): + card = self._dummy_card(background_color=(255, 255, 255)) + assert card.background_color == (255, 255, 255) + + def test_path_for_resource(self): + card = self._dummy_card() + + spam_path = card.path_for_resource('spam') + assert os.path.isabs(spam_path) + assert card.RESOURCE_FOLDER in spam_path + assert spam_path.endswith('spam') + + spam_eggs_path = card.path_for_resource('spam', folder='eggs') + assert os.path.isabs(spam_eggs_path) + assert card.RESOURCE_FOLDER in spam_eggs_path + assert 'eggs' in spam_eggs_path + assert spam_eggs_path.endswith('spam') + + def test_tick(self): + try: + card = self._dummy_card() + card.tick() + except Exception as exc: + assert isinstance(exc, RuntimeError) + assert exc.args[0] == 'TODO' + else: + assert False, 'Nothing was raised :(' diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..e551376 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,744 @@ +# -*- coding: utf-8 -*- + +import datetime +import logging +import os +import sys + +import mock +import pygame + +from pie_time import application as app_module +from pie_time.application import logging as app_logging +from pie_time.application import EVENT_QUIT, EVENT_CLICK_TO_UNBLANK,\ + EVENT_CLICK_TO_PREV_CARD, EVENT_CLICK_TO_NEXT_CARD, RET_OK, RET_ERROR,\ + PieTime +from pie_time.card import AbstractCard + + +class DummyCard(AbstractCard): + def tick(self): + pass + + def initialize(self): + self._surface = mock.Mock(spec=pygame.surface.Surface) + + +class FakeTimer(object): + def __init__(self, step=1, start=None): + if start is None: + self.n = 0 - step + else: + self.n = start + self._step = step + + def __call__(self): + self.n += self._step + return self.n + + +def _now(*args): + return datetime.datetime(*args) + + +class Test_Application(object): + def _dummy_deck(self): + return [mock.Mock(spec=DummyCard)] + + def _dummy_blanker_schedule(self): + return (datetime.timedelta(hours=1), datetime.timedelta(hours=2)) + + def _mocked_app(self, **kwargs): + mocked_app = PieTime(self._dummy_deck(), **kwargs) + mocked_app.init_pygame = mock.Mock() + mocked_app.get_screen = mock.Mock( + side_effect=lambda: mock.Mock(spec=pygame.Surface) + ) + mocked_app._should_blank = mock.Mock(return_value=False) + mocked_app._blank = mock.Mock() + mocked_app._unblank = mock.Mock() + mocked_app.fill_screen = mock.Mock() + mocked_app._clock = mock.Mock(spec=pygame.time.Clock) + mocked_app.destroy_cards = mock.Mock() + mocked_app.quit_pygame = mock.Mock() + mocked_app._start_clock = mock.Mock() + mocked_app._setup_output_stream = mock.Mock() + mocked_app._setup_logging = mock.Mock() + mocked_app._logger = mock.Mock(spec=logging.Logger) + + def init_cards(*args, **kwargs): + PieTime.init_cards(mocked_app) + + mocked_app.init_cards = mock.Mock(side_effect=init_cards) + + def transition_cards(*args, **kwargs): + mocked_app._current_card_idx = 0 + mocked_app._current_card_time = 0 + + mocked_app._transition_cards = mock.Mock( + side_effect=transition_cards + ) + + return mocked_app + + def _make_app(self, *args, **kwargs): + new_app = PieTime(*args, **kwargs) + new_app._logger = mock.Mock(spec=logging.Logger) + + return new_app + + def test_init_default_settings(self): + deck = self._dummy_deck() + app = self._make_app(deck) + + assert app._deck == deck + assert app.screen is None + assert app.screen_size == (320, 240) + assert app.events == [] + assert app.log_path is None + assert app._fps == 20 + assert app._verbose is False + assert app._blanker_schedule is None + assert app._click_to_unblank_interval is None + assert app._click_to_transition is True + assert app._clock is None + assert app._cards == [] + assert app._is_blanked is False + assert app._current_card_idx is None + assert app._current_card_time is None + assert app._should_quit is False + assert len(app._internal_events) == 0 + assert app._ctu_timer is None + assert app._output_stream is None + + assert app._ctt_region_prev.x == 0 + assert app._ctt_region_prev.y == 210 + assert app._ctt_region_prev.width == 30 + assert app._ctt_region_prev.height == 30 + + assert app._ctt_region_next.x == 290 + assert app._ctt_region_next.y == 210 + assert app._ctt_region_next.width == 30 + assert app._ctt_region_next.height == 30 + + def test_init_override_settings(self): + deck = self._dummy_deck() + blanker_schedule = self._dummy_blanker_schedule() + + app = self._make_app( + deck, screen_size=(640, 480), fps=60, verbose=True, + blanker_schedule=blanker_schedule, + click_to_unblank_interval=10, click_to_transition=False, + log_path='/path/to/log_file.txt' + ) + + assert app.screen_size == (640, 480) + assert app.log_path == '/path/to/log_file.txt' + assert app._fps == 60 + assert app._verbose is True + assert app._blanker_schedule == blanker_schedule + assert app._click_to_unblank_interval == 10 + assert app._click_to_transition is False + + def test_should_blank_scheduled(self): + blanker_schedule = self._dummy_blanker_schedule() + + app = self._make_app(self._dummy_deck()) + assert app._should_blank(now=_now(2014, 10, 15, 9)) is False + + app = self._make_app( + self._dummy_deck(), blanker_schedule=blanker_schedule + ) + assert app._should_blank(now=_now(2014, 10, 15, 0, 0, 0)) is False + assert app._should_blank(now=_now(2014, 10, 15, 1, 0, 0)) is True + assert app._should_blank(now=_now(2014, 10, 15, 1, 30, 0)) is True + assert app._should_blank(now=_now(2014, 10, 15, 1, 59, 59)) is True + assert app._should_blank(now=_now(2014, 10, 15, 2, 0, 0)) is False + assert app._should_blank(now=_now(2014, 10, 15, 9, 0, 0)) is False + + blanker_schedule = ( + datetime.timedelta(hours=23), datetime.timedelta(hours=6) + ) + app = self._make_app( + self._dummy_deck(), blanker_schedule=blanker_schedule + ) + assert app._should_blank(now=_now(2014, 10, 15, 0, 0, 0)) is True + assert app._should_blank(now=_now(2014, 10, 15, 5, 59, 59)) is True + assert app._should_blank(now=_now(2014, 10, 15, 6, 0, 0)) is False + assert app._should_blank(now=_now(2014, 10, 15, 12, 0, 0)) is False + assert app._should_blank(now=_now(2014, 10, 15, 22, 59, 59)) is False + assert app._should_blank(now=_now(2014, 10, 15, 23, 0, 0)) is True + + def test_should_blank_ctu(self): + ctu = 10 + + app = self._make_app(self._dummy_deck()) + app._has_click_to_unblank_event = mock.Mock(return_value=True) + assert app._should_blank() is False + assert app._ctu_timer is None + + app = self._make_app( + self._dummy_deck(), + blanker_schedule=self._dummy_blanker_schedule(), + click_to_unblank_interval=ctu + ) + app._is_blanked = True + app._has_click_to_unblank_event = mock.Mock(return_value=True) + assert app._should_blank(now=_now(2014, 10, 15, 1, 0, 0)) is False + assert app._ctu_timer == ctu + + app = self._make_app( + self._dummy_deck(), + blanker_schedule=self._dummy_blanker_schedule(), + click_to_unblank_interval=ctu + ) + app._is_blanked = True + app._ctu_timer = ctu + app._has_click_to_unblank_event = mock.Mock(return_value=False) + + app._clock = mock.Mock(spec=pygame.time.Clock) + app._clock.get_time = mock.Mock(return_value=1 * 1000) + + assert app._should_blank(now=_now(2014, 10, 15, 1, 0, 0)) is False + assert app._ctu_timer == ctu - 1 + + app = self._make_app( + self._dummy_deck(), + blanker_schedule=self._dummy_blanker_schedule(), + click_to_unblank_interval=ctu + ) + app._is_blanked = True + app._ctu_timer = ctu + app._has_click_to_unblank_event = mock.Mock(return_value=False) + + app._clock = mock.Mock(spec=pygame.time.Clock) + app._clock.get_time = mock.Mock(return_value=ctu * 1000) + + assert app._should_blank(now=_now(2014, 10, 15, 1, 0, 0)) is True + assert app._ctu_timer is None + + def test_blank(self): + app = self._make_app(self._dummy_deck()) + app.screen = mock.Mock(spec=pygame.surface.Surface) + app.will_blank = mock.Mock() + + app._blank() + assert app._is_blanked is True + assert app.will_blank.called is True + app.screen.fill.assert_called_with(PieTime.BLANK_COLOR) + + def test_unblank(self): + app = self._make_app(self._dummy_deck()) + app.init_cards() + app._is_blanked = True + app.will_unblank = mock.Mock() + + app._unblank() + assert app._is_blanked is False + assert app.will_unblank.called is True + assert app._cards[0][0].show.call_count == 1 + + def test_transition_cards(self): + deck = [ + (mock.Mock(spec=DummyCard), 1), + (mock.Mock(spec=DummyCard), 1) + ] + + app = self._make_app(self._dummy_deck()) + app.init_cards() + app._transition_cards() + + assert app._current_card_idx == 0 + assert not app._cards[0][0].hide.called + assert app._cards[0][0].show.call_count == 1 + + app = self._make_app(deck) + app._clock = mock.Mock(spec=pygame.time.Clock) + + timer = FakeTimer(step=2, start=0) + app._clock.get_time = mock.Mock( + side_effect=lambda: timer() * 1000.0 + ) + + app.init_cards() + app._current_card_idx = 0 + app._current_card_time = 0 + + app._transition_cards() + assert app._current_card_idx == 1 + assert app._cards[0][0].hide.call_count == 1 + assert app._cards[1][0].show.call_count == 1 + + app._transition_cards() + assert app._current_card_idx == 0 + assert app._cards[0][0].show.call_count == 1 + assert app._cards[1][0].hide.call_count == 1 + + def test_transition_cards_forced(self): + deck = [ + (mock.Mock(spec=DummyCard), 1), + (mock.Mock(spec=DummyCard), 1) + ] + + app = self._make_app(deck) + app._clock = mock.Mock(spec=pygame.time.Clock) + + timer = FakeTimer(step=2, start=0) + app._clock.get_time = mock.Mock( + side_effect=lambda: timer() + ) + + app.init_cards() + app._current_card_idx = 0 + app._current_card_time = 0 + + app._transition_cards(direction=1, force=True) + assert app._current_card_idx == 1 + + app._transition_cards(direction=1, force=True) + assert app._current_card_idx == 0 + + app._transition_cards(direction=-1, force=True) + assert app._current_card_idx == 1 + + app._transition_cards(direction=-1, force=True) + assert app._current_card_idx == 0 + + def test_get_events_quit_pygame(self): + new_event_get = mock.Mock(return_value=[ + pygame.event.Event(pygame.QUIT) + ]) + + app = self._make_app(self._dummy_deck()) + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + app._get_events() + assert app._internal_events == set([EVENT_QUIT]) + + def test_get_events_quit_key(self): + new_event_get = mock.Mock(return_value=[ + pygame.event.Event(pygame.KEYDOWN, key=PieTime.KEY_QUIT) + ]) + + app = self._make_app(self._dummy_deck()) + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + app._get_events() + assert app._internal_events == set([EVENT_QUIT]) + + def test_get_events_click_to_unblank(self): + new_event_get = mock.Mock(return_value=[ + pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=(160, 120)) + ]) + + app = self._make_app(self._dummy_deck(), click_to_unblank_interval=10) + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + app._is_blanked = False + app._get_events() + assert app._internal_events == set() + + app._is_blanked = True + app._get_events() + assert app._internal_events == set([EVENT_CLICK_TO_UNBLANK]) + + def test_get_events_click_to_prev_card(self): + new_event_get = mock.Mock(return_value=[ + pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=(10, 230)) + ]) + + app = self._make_app(self._dummy_deck(), click_to_transition=True) + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + app._is_blanked = False + app._get_events() + assert app._internal_events == set([EVENT_CLICK_TO_PREV_CARD]) + + app._is_blanked = True + app._get_events() + assert EVENT_CLICK_TO_PREV_CARD not in app._internal_events + + app._click_to_transition = False + app._is_blanked = False + app._get_events() + assert EVENT_CLICK_TO_PREV_CARD not in app._internal_events + + def test_get_events_click_to_next_card(self): + new_event_get = mock.Mock(return_value=[ + pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=(310, 230)) + ]) + + app = self._make_app(self._dummy_deck(), click_to_transition=True) + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + app._is_blanked = False + app._get_events() + assert app._internal_events == set([EVENT_CLICK_TO_NEXT_CARD]) + + app._is_blanked = True + app._get_events() + assert EVENT_CLICK_TO_NEXT_CARD not in app._internal_events + + app._click_to_transition = False + app._is_blanked = False + app._get_events() + assert EVENT_CLICK_TO_NEXT_CARD not in app._internal_events + + def test_get_events_other_events(self): + events = [ + pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=(160, 120)), + pygame.event.Event(pygame.KEYDOWN, key=pygame.K_RETURN) + ] + new_event_get = mock.Mock(return_value=events) + + app = self._make_app(self._dummy_deck(), click_to_transition=True) + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + app._is_blanked = False + app._get_events() + assert app._internal_events == set() + assert app.events == events + + def test_has_quit_event(self): + app = self._make_app(self._dummy_deck()) + assert app._has_quit_event() is False + + app._internal_events.add(EVENT_QUIT) + assert app._has_quit_event() is True + + def test_has_click_to_unblank_event(self): + app = self._make_app(self._dummy_deck(), click_to_unblank_interval=10) + assert app._has_click_to_unblank_event() is False + + app._internal_events.add(EVENT_CLICK_TO_UNBLANK) + assert app._has_click_to_unblank_event() is True + + def test_start_clock(self): + app = self._make_app(self._dummy_deck()) + + fake_clock = mock.Mock(spec=pygame.time.Clock) + with mock.patch.object(app_module.pygame.time, 'Clock', + return_value=fake_clock): + app._start_clock() + + assert app_module.pygame.time.Clock.called is True + assert app._clock == fake_clock + + def test_setup_output_stream_no_log_path(self): + deck = self._dummy_deck() + + with mock.patch.object(PieTime, '_STREAM_FACTORY'): + with mock.patch.object(PieTime, '_setup_logging'): + app = self._make_app(deck, log_path=None) + app._setup_output_stream() + + assert app._output_stream == PieTime._DEFAULT_OUTPUT_STREAM + assert PieTime._STREAM_FACTORY.called is False + + def test_setup_output_stream_with_log_path(self): + deck = self._dummy_deck() + fake_file = mock.Mock(spec=file) + + with mock.patch.object(PieTime, '_STREAM_FACTORY', new=fake_file): + with mock.patch.object(PieTime, '_setup_logging'): + app = self._make_app(deck, log_path='/path/to/log_file.txt') + app._setup_output_stream() + + PieTime._STREAM_FACTORY.assert_called_with(app.log_path, 'a') + assert app._output_stream != fake_file + + def test_setup_logging_silent(self): + deck = self._dummy_deck() + + fake_logger = mock.Mock(spec=logging.Logger) + fake_requests_logger = mock.Mock(spec=logging.Logger) + fake_handler = mock.Mock(spec=logging.StreamHandler) + fake_formatter = mock.Mock(spec=logging.Formatter) + + fake_requests_logger.handlers = ['spam', 'eggs'] + fake_requests_logger.removeHandler = mock.Mock() + + def fake_getLogger(name): + if name == 'PieTime': + return fake_logger + elif name == 'requests': + return fake_requests_logger + else: + return None + + with mock.patch.object(app_logging, 'getLogger', + side_effect=fake_getLogger): + with mock.patch.object(app_logging, + 'StreamHandler', return_value=fake_handler): + with mock.patch.object(app_logging, + 'Formatter', + return_value=fake_formatter): + app = self._make_app(deck, verbose=False) + app._output_stream = 'spam' + app._setup_logging() + + fake_logger.setLevel.assert_called_with(logging.INFO) + fake_requests_logger.setLevel.assert_called_with( + logging.WARNING + ) + + app_logging.StreamHandler.assert_called_with( + app._output_stream + ) + + assert app_logging.Formatter.called is True + + fake_handler.setFormatter.assert_called_with( + fake_formatter + ) + + fake_logger.addHandler.assert_called_with(fake_handler) + + assert fake_requests_logger.removeHandler.call_count == 2 + fake_requests_logger.removeHandler.assert_any_call('spam') + fake_requests_logger.removeHandler.assert_any_call('eggs') + + fake_requests_logger.addHandler.assert_called_with( + fake_handler + ) + + def test_setup_logging_verbose(self): + deck = self._dummy_deck() + + fake_logger = mock.Mock(spec=logging.Logger) + + fake_requests_logger = mock.Mock(spec=logging.Logger) + fake_requests_logger.handlers = [] + + def fake_getLogger(name): + if name == 'PieTime': + return fake_logger + elif name == 'requests': + return fake_requests_logger + else: + return None + + with mock.patch.object(app_logging, 'getLogger', + side_effect=fake_getLogger): + with mock.patch.object(app_logging, 'StreamHandler'): + with mock.patch.object(app_logging, 'Formatter'): + app = self._make_app(deck, verbose=True) + app._output_stream = 'spam' + app._setup_logging() + + fake_logger.setLevel.assert_called_with(logging.DEBUG) + assert fake_requests_logger.setLevel.called is False + + def test_logger(self): + app = self._make_app(self._dummy_deck()) + assert app.logger is not None + + def test_init_pygame(self): + app = self._make_app(self._dummy_deck()) + + with mock.patch.object(app_module.pygame, 'init'): + with mock.patch.object(app_module.pygame.mouse, 'set_visible'): + app.init_pygame() + assert app_module.pygame.init.called + app_module.pygame.mouse.set_visible.assert_called_with(False) + assert app._clock is None + + def test_quit_pygame(self): + app = self._make_app(self._dummy_deck()) + + with mock.patch.object(app_module.pygame, 'quit'): + app.quit_pygame() + assert app_module.pygame.quit.called + assert app._clock is None + + def test_init_cards(self): + deck = [ + mock.Mock(spec=DummyCard), + (mock.Mock(spec=DummyCard), 10), + (mock.Mock(spec=DummyCard), 20, {'spam': 'eggs'}) + ] + + app = self._make_app(deck) + app.init_cards() + + assert len(app._cards) == 3 + + app._cards[0][0].set_app.assert_called_with(app) + app._cards[0][0].set_settings.assert_called_with({}) + assert app._cards[0][0].initialize.called + assert not app._cards[0][0].show.called + assert app._cards[0][1] == PieTime.CARD_INTERVAL + + app._cards[1][0].set_app.assert_called_with(app) + app._cards[1][0].set_settings.assert_called_with({}) + assert app._cards[1][0].initialize.called + assert not app._cards[1][0].show.called + assert app._cards[1][1] == 10 + + app._cards[2][0].set_app.assert_called_with(app) + app._cards[2][0].set_settings.assert_called_with({'spam': 'eggs'}) + assert app._cards[2][0].initialize.called + assert not app._cards[2][0].show.called + assert app._cards[2][1] == 20 + + assert app._current_card_idx is None + assert app._current_card_time is None + + def test_destroy_cards(self): + with mock.patch.object(DummyCard, 'quit'): + app = self._make_app([DummyCard]) + + app.init_cards() + app.destroy_cards() + + assert len(app._cards) == 0 + assert DummyCard.quit.called + + def test_get_screen(self): + with mock.patch.object(app_module.pygame.display, 'set_mode', + return_value='spam'): + app = self._make_app(self._dummy_deck()) + screen = app.get_screen() + + assert screen == 'spam' + app_module.pygame.display.set_mode.\ + assert_called_with(app.screen_size) + + def test_fill_screen(self): + app = self._make_app(self._dummy_deck()) + app.screen = mock.Mock(pygame.Surface) + + app.fill_screen() + app.screen.fill.assert_called_with(PieTime.BACKGROUND_COLOR) + + def test_run(self): + app = self._mocked_app() + + def new_start_clock(*args, **kwargs): + app._clock.tick = mock.Mock(side_effect=lambda x: app.quit()) + app._start_clock = mock.Mock(side_effect=new_start_clock) + + new_event_get = mock.Mock(return_value=[]) + + with mock.patch.object(app_module.sys, 'exit'): + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + with mock.patch.object(app_module.pygame.display, 'flip'): + app.run() + + assert app._setup_output_stream.called is True + assert app._setup_logging.called is True + + assert app.init_pygame.called + assert app.get_screen.called + assert app.screen is not None + assert app.init_cards.called + assert app._start_clock.called + assert app_module.pygame.event.get.called + assert app._should_blank.called + assert app._unblank.called + assert not app._blank.called + assert app._transition_cards.called + assert app._cards[0][0].tick.called + assert app.fill_screen.called + app.screen.blit.assert_called_with( + app._cards[0][0].surface, + (0, 0, app._cards[0][0].width, app._cards[0][0].height) + ) + assert pygame.display.flip.called + app._clock.tick.assert_called_with(app._fps) + assert app.destroy_cards.called + assert app.quit_pygame.called + app_module.sys.exit.assert_called_with(RET_OK) + + def test_run_handling_exception(self): + app = self._mocked_app() + app._clock.tick = mock.Mock(side_effect=RuntimeError('spam')) + + new_event_get = mock.Mock(return_value=[]) + + with mock.patch.object(app_module.sys, 'exit'): + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + with mock.patch.object(app_module.pygame.display, 'flip'): + app.run() + sys.exit.assert_called_with(RET_ERROR) + + def test_run_handling_quit_event(self): + app = self._mocked_app() + app._get_events = mock.Mock() + app._has_quit_event = mock.Mock(return_value=True) + + with mock.patch.object(app_module.sys, 'exit'): + with mock.patch.object(app_module.pygame.display, 'flip'): + app.run() + sys.exit.assert_called_with(RET_OK) + + def test_run_blanking(self): + app = self._mocked_app() + + timer = FakeTimer() + + def should_blank(*args, **kwargs): + return (timer.n % 2 == 1) + + app._should_blank = mock.Mock(side_effect=should_blank) + + def clock_tick(*args, **kwargs): + timer() + if timer.n == 3: + app.quit() + + app._clock.tick = mock.Mock(side_effect=clock_tick) + + new_event_get = mock.Mock(return_value=[]) + + with mock.patch.object(app_module.sys, 'exit'): + with mock.patch.object(app_module.pygame.event, 'get', + new=new_event_get): + with mock.patch.object(app_module.pygame.display, 'flip'): + app.run() + + assert app._unblank.call_count == 2 + assert app._blank.call_count == 2 + + def test_run_handling_click_to_prev_event(self): + app = self._mocked_app() + app._internal_events = set([EVENT_CLICK_TO_PREV_CARD]) + + def clock_tick(*args, **kwargs): + app.quit() + + app._clock.tick = mock.Mock(side_effect=clock_tick) + app._get_events = mock.Mock() + + with mock.patch.object(app_module.sys, 'exit'): + with mock.patch.object(app_module.pygame.display, 'flip'): + app.run() + + app._transition_cards.assert_called_with( + direction=-1, force=True + ) + + def test_run_handling_click_to_next_event(self): + app = self._mocked_app() + app._internal_events = set([EVENT_CLICK_TO_PREV_CARD]) + + def clock_tick(*args, **kwargs): + app.quit() + + app._clock.tick = mock.Mock(side_effect=clock_tick) + app._get_events = mock.Mock() + + with mock.patch.object(app_module.sys, 'exit'): + with mock.patch.object(app_module.pygame.display, 'flip'): + app.run() + + app._transition_cards.assert_called_with( + direction=-1, force=True + ) + + def test_quit(self): + app = self._mocked_app() + app.quit() + assert app._should_quit is True diff --git a/tests/test_clock_card.py b/tests/test_clock_card.py new file mode 100644 index 0000000..a0662bd --- /dev/null +++ b/tests/test_clock_card.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- + +import datetime + +import mock +import pygame + +from pie_time import application as app_module +from pie_time.application import PieTime +from pie_time.cards import ClockCard + + +class Test_ClockCard(object): + def _dummy_card(self, **settings): + app = PieTime(None, screen_size=(320, 240)) + app.path_for_resource = mock.Mock( + side_effect=lambda resource: resource + ) + card = ClockCard() + card.set_app(app) + card.set_settings(settings) + + return card + + def test_initialize(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + card = self._dummy_card() + card.initialize() + assert card._time_font is not None + assert card._date_font is not None + + def test_render_time(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + card = self._dummy_card() + card.initialize() + + now = datetime.datetime.now().replace(second=0) + card._render_time(now) + card._time_font.render.assert_called_with( + now.strftime(card.TIME_FORMAT), True, card.GREEN + ) + + now = datetime.datetime.now().replace(second=1) + card._render_time(now) + card._time_font.render.assert_called_with( + now.strftime(card.TIME_FORMAT).replace(':', ' '), True, + card.GREEN + ) + + def test_render_time_override_format(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + time_format = '%H:%M' + card = self._dummy_card(time_format=time_format) + card.initialize() + + now = datetime.datetime.now().replace(second=0) + card._render_time(now) + card._time_font.render.assert_called_with( + now.strftime(time_format), True, card.GREEN + ) + + def test_render_time_override_color(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + time_color = (255, 255, 255) + card = self._dummy_card(time_color=time_color) + card.initialize() + + now = datetime.datetime.now().replace(second=0) + card._render_time(now) + card._time_font.render.assert_called_with( + now.strftime(card.TIME_FORMAT), True, (255, 255, 255) + ) + + def test_render_time_override_blink(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + card = self._dummy_card(time_blink=False) + card.initialize() + + now = datetime.datetime.now().replace(second=1) + card._render_time(now) + card._time_font.render.assert_called_with( + now.strftime(card.TIME_FORMAT), True, card.GREEN + ) + + def test_render_date(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + card = self._dummy_card() + card.initialize() + + now = datetime.datetime.now() + card._render_date(now) + card._date_font.render.assert_called_with( + now.strftime(card.DATE_FORMAT), True, card.GREEN + ) + + def test_render_date_override_format(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + date_format = '%Y-%M-%D' + card = self._dummy_card(date_format=date_format) + card.initialize() + + now = datetime.datetime.now().replace(second=0) + card._render_date(now) + card._date_font.render.assert_called_with( + now.strftime(date_format), True, card.GREEN + ) + + def test_render_date_override_color(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + date_color = (255, 255, 255) + card = self._dummy_card(date_color=date_color) + card.initialize() + + now = datetime.datetime.now().replace(second=0) + card._render_date(now) + card._date_font.render.assert_called_with( + now.strftime(card.DATE_FORMAT), True, (255, 255, 255) + ) + + def test_update_now(self): + now = datetime.datetime.now() + current_delta = 0 + + fake_datetime = mock.Mock(spec=datetime.datetime) + + def fake_datetime_now(*args, **kwargs): + return now + datetime.timedelta(seconds=current_delta) + + fake_datetime.now = mock.Mock(side_effect=fake_datetime_now) + + with mock.patch.object(app_module.datetime, 'datetime', + new=fake_datetime): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + card = self._dummy_card() + card.initialize() + + card._app = mock.Mock(spec=PieTime) + card._app._clock = mock.Mock(spec=pygame.time.Clock) + card._app._clock.get_time = mock.Mock( + side_effect=lambda: current_delta + ) + + assert card._update_now() is True + assert card._now == now + assert card._current_interval == 0 + + current_delta = 500 + assert card._update_now() is False + assert card._now == now + assert card._current_interval == current_delta + + current_delta = 530 + assert card._update_now() is True + assert card._now > now + assert card._current_interval == 30 + + def test_show(self): + card = self._dummy_card() + card._now = datetime.datetime.now() + + card.show() + assert card._now is None + + def test_tick_redraw(self): + now = datetime.datetime.now() + + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + card = self._dummy_card() + card.initialize() + + card._now = now + card._surface = mock.Mock(spec=pygame.surface.Surface) + card._update_now = mock.Mock(return_value=True) + + time_surface = mock.Mock(spec=pygame.surface.Surface) + time_surface.get_size = mock.Mock(return_value=(120, 80)) + card._render_time = mock.Mock(return_value=time_surface) + + date_surface = mock.Mock(spec=pygame.surface.Surface) + date_surface.get_size = mock.Mock(return_value=(200, 60)) + card._render_date = mock.Mock(return_value=date_surface) + + card.tick() + assert card._update_now.called + card._render_time.assert_called_with(now) + card._render_date.assert_called_with(now) + + assert time_surface.get_size.called + assert date_surface.get_size.called + + card.surface.fill.assert_called_with(card.background_color) + card.surface.blit.assert_any_call( + time_surface, (100, 50, 120, 80) + ) + card.surface.blit.assert_any_call( + date_surface, (60, 130, 200, 60) + ) + + def test_tick_dont_redraw(self): + with mock.patch.object(app_module.pygame.font, 'Font', + spec=pygame.font.Font): + card = self._dummy_card() + card.initialize() + + card._surface = mock.Mock(spec=pygame.surface.Surface) + card._update_now = mock.Mock(return_value=False) + + card._render_time = mock.Mock() + card._render_date = mock.Mock() + + card.tick() + + assert card._update_now.called + + assert card._render_time.called is False + assert card._render_date.called is False + + assert card.surface.fill.called is False + assert card.surface.blit.called is False diff --git a/tests/test_picture_card.py b/tests/test_picture_card.py new file mode 100644 index 0000000..c11b016 --- /dev/null +++ b/tests/test_picture_card.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- + +import cStringIO + +import mock +import pygame +import requests + +from pie_time.application import PieTime +from pie_time.cards import picture as card_module +from pie_time.cards import PictureCard + + +class Test_PictureCard(object): + def _dummy_card(self, **settings): + app = PieTime(None, screen_size=(320, 240)) + app.screen = mock.Mock(spec=pygame.surface.Surface) + app._logger = mock.Mock() + + app.path_for_resource = mock.Mock( + side_effect=lambda resource: resource + ) + + card = PictureCard() + card.set_app(app) + card.set_settings(settings) + + return card + + def _dummy_initialized_card(self, **settings): + card = self._dummy_card(**settings) + card._load_picture = mock.Mock( + side_effect=lambda x: mock.Mock(spec=pygame.surface.Surface) + ) + + card.initialize() + return card + + def test_initialize(self): + urls = ['spam', 'eggs'] + + card = self._dummy_card(urls=urls) + card._load_picture = mock.Mock() + + card.initialize() + assert len(card._pictures) == len(urls) + assert card._current_picture_idx is None + + assert card._load_picture.call_count == len(urls) + card._load_picture.assert_any_call(urls[0]) + card._load_picture.assert_any_call(urls[1]) + + def test_load_picture_file(self): + fake_image = mock.Mock(spec=pygame.surface.Surface) + + with mock.patch.object(card_module.pygame.image, 'load', + return_value=fake_image): + card = self._dummy_card() + result = card._load_picture('file:///spam') + assert result == fake_image + + card_module.pygame.image.load.assert_called_with('/spam') + + def test_load_picture_file_load_error(self): + with mock.patch.object(card_module.pygame.image, 'load', + side_effect=RuntimeError('ERROR')): + card = self._dummy_card() + result = card._load_picture('file:///spam') + assert result is None + + def test_load_picture_net(self): + fake_image = mock.Mock(spec=pygame.surface.Surface) + + fake_response = mock.Mock(spec=requests.Response) + fake_response.status_code = 200 + fake_response.headers = {'Content-Type': ''} + fake_response.content = 'HERE IMAGE DATA BE' + + fake_stringio = mock.Mock(spec=cStringIO.StringIO) + + with mock.patch.object(card_module.pygame.image, 'load', + return_value=fake_image): + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + with mock.patch.object(card_module.cStringIO, 'StringIO', + return_value=fake_stringio): + url = 'http://spam.com/eggs' + card = self._dummy_card() + + result = card._load_picture(url) + assert result == fake_image + + card_module.requests.get.assert_called_with(url) + + card_module.cStringIO.StringIO.assert_called_with( + fake_response.content + ) + + card_module.pygame.image.load.assert_called_with( + fake_stringio, 'picture.' + ) + + def test_load_picture_net_requests_error(self): + def _get(*args, **kwargs): + raise RuntimeError('ERROR') + + new_get = mock.Mock(side_effect=_get) + + with mock.patch.object(card_module.requests, 'get', + side_effect=RuntimeError('TODO')): + url = 'http://spam.com/eggs' + card = self._dummy_card() + + result = card._load_picture(url) + assert result is None + + def test_load_picture_net_bad_response(self): + fake_response = mock.Mock(spec=requests.Response) + fake_response.status_code = 404 + + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + url = 'http://spam.com/eggs' + card = self._dummy_card() + + result = card._load_picture(url) + assert result is None + + def test_load_picture_net_load_error(self): + fake_response = mock.Mock(spec=requests.Response) + fake_response.status_code = 200 + fake_response.headers = {'Content-Type': ''} + fake_response.content = 'HERE IMAGE DATA BE' + + with mock.patch.object(card_module.pygame.image, 'load', + side_effect=RuntimeError('ERROR')): + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + url = 'http://spam.com/eggs' + card = self._dummy_card() + + result = card._load_picture(url) + assert result is None + + def test_load_picture_convert(self): + fake_image = mock.Mock(spec=pygame.surface.Surface) + fake_image.convert = mock.Mock(return_value=fake_image) + + with mock.patch.object(card_module.pygame.image, 'load', + return_value=fake_image): + card = self._dummy_card() + result = card._load_picture('file:///spam.jpg') + assert result == fake_image + + fake_image.convert.assert_called_with(card._app.screen) + + def test_load_picture_convert_alpha(self): + fake_image = mock.Mock(spec=pygame.surface.Surface) + fake_image.convert_alpha = mock.Mock(return_value=fake_image) + + with mock.patch.object(card_module.pygame.image, 'load', + return_value=fake_image): + card = self._dummy_card() + result = card._load_picture('file:///spam.png') + assert result == fake_image + + fake_image.convert_alpha.assert_called_with(card._app.screen) + + def test_show_no_pictures(self): + card = self._dummy_initialized_card(urls=[]) + card.show() + assert card._current_picture_idx is None + assert card._should_redraw is True + + def test_show_one_picture(self): + card = self._dummy_initialized_card(urls=['http://spam.com/eggs.png']) + + card.show() + assert card._current_picture_idx == 0 + assert card._should_redraw is True + + card.show() + assert card._current_picture_idx == 0 + assert card._should_redraw is True + + def test_show_many_pictures(self): + card = self._dummy_initialized_card( + urls=[ + 'http://spam.com/eggs.png', 'http://spam.com/spam.png', + 'http://spam.com/spameggs.png' + ] + ) + + card.show() + assert card._current_picture_idx == 0 + assert card._should_redraw is True + + card.show() + assert card._current_picture_idx == 1 + assert card._should_redraw is True + + card.show() + assert card._current_picture_idx == 2 + assert card._should_redraw is True + + card.show() + assert card._current_picture_idx == 0 + assert card._should_redraw is True + + def test_tick_no_pictures(self): + card = self._dummy_initialized_card(urls=[]) + card._surface = mock.Mock(spec=pygame.surface.Surface) + card.show() + + card.tick() + assert card._should_redraw is False + + card.surface.fill.assert_called_with(card.background_color) + + def test_tick_with_pictures(self): + card = self._dummy_initialized_card( + urls=['http://spam.com/eggs.png', 'http://spam.com/eggs.png'] + ) + + card._surface = mock.Mock(spec=pygame.surface.Surface) + + card._pictures[0].get_size = mock.Mock(return_value=(320, 240)) + card._pictures[0].get_rect = mock.Mock( + return_value=(0, 0, 320, 240) + ) + + card._pictures[1].get_size = mock.Mock(return_value=(240, 180)) + card._pictures[1].get_rect = mock.Mock( + return_value=(0, 0, 240, 180) + ) + + card.show() + card.tick() + assert card._should_redraw is False + card.surface.fill.assert_called_with(card.background_color) + card.surface.blit.assert_called_with( + card._pictures[0], (0, 0, 320, 240) + ) + + card.show() + card.tick() + assert card._should_redraw is False + card.surface.fill.assert_called_with(card.background_color) + card.surface.blit.assert_called_with( + card._pictures[1], (40, 30, 240, 180) + ) + + def test_tick_dont_redraw(self): + card = self._dummy_initialized_card( + urls=['http://spam.com/eggs.png', 'http://spam.com/eggs.png'] + ) + + card._surface = mock.Mock(spec=pygame.surface.Surface) + card.show() + card._should_redraw = False + + card.tick() + assert card._should_redraw is False + + assert card.surface.fill.called is False + assert card.surface.blit.called is False diff --git a/tests/test_weather_card.py b/tests/test_weather_card.py new file mode 100644 index 0000000..b2b286b --- /dev/null +++ b/tests/test_weather_card.py @@ -0,0 +1,697 @@ +# -*- coding: utf-8 -*- + +import threading + +import mock +import pygame +import requests + +from pie_time.application import PieTime +from pie_time.cards import weather as card_module + + +class Test_WeatherCard(object): + def _dummy_card(self, city='Wroclaw,PL', api_key='API key', **settings): + app = PieTime(None, screen_size=(320, 240)) + app._logger = mock.Mock() + app.path_for_resource = mock.Mock( + side_effect=lambda resource: resource + ) + card = card_module.WeatherCard() + card.set_app(app) + + if city: + settings['city'] = city + + if api_key: + settings['api_key'] = api_key + + card.set_settings(settings) + + return card + + def _ok_payload(self): + return { + "base": "cmc stations", + "clouds": { + "all": 20 + }, + "cod": 200, + "coord": { + "lat": 51.1, + "lon": 17.03 + }, + "dt": 1413815400, + "id": 3081368, + "main": { + "humidity": 59, + "pressure": 1013, + "temp": 17, + "temp_max": 17, + "temp_min": 17 + }, + "name": "Wroclaw", + "sys": { + "country": "PL", + "id": 5375, + "message": 0.1316, + "sunrise": 1413782666, + "sunset": 1413820124, + "type": 1 + }, + "weather": [ + { + "description": "few clouds", + "icon": "02d", + "id": 801, + "main": "Clouds" + } + ], + "wind": { + "deg": 280, + "speed": 6.2 + } + } + + def test_initialize_no_city(self): + card = self._dummy_card(city=None) + + try: + card.initialize() + except Exception as exc: + assert isinstance(exc, AssertionError) + assert exc.args[0] == 'Configuration error: missing city' + else: + assert False, 'Nothing was raised :(' + + def test_initialize_no_api_key(self): + card = self._dummy_card(api_key=None) + + try: + card.initialize() + except Exception as exc: + assert isinstance(exc, AssertionError) + assert exc.args[0] == 'Configuration error: missing API key' + else: + assert False, 'Nothing was raised :(' + + def test_initialize(self): + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card = self._dummy_card() + card._refresh_conditions = mock.Mock() + + card.initialize() + + assert card._text_font is not None + assert card._temp_font is not None + assert card._icon_font is not None + assert card._timer is None + assert card._current_conditions is None + assert card._refresh_conditions.called + + def test_refresh_conditions_requests_error(self): + fake_timer = mock.Mock(spec=threading.Timer) + fake_timer.start = mock.Mock() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + with mock.patch.object(card_module.requests, 'get', + side_effect=RuntimeError('TODO')): + with mock.patch.object(card_module, 'Timer', + return_value=fake_timer): + card = self._dummy_card() + card.initialize(refresh=False) + card._refresh_conditions() + + assert card_module.requests.get.called + assert card._current_conditions is None + + assert card._timer is not None + card_module.Timer.assert_called_with( + card.REFRESH_INTERVAL, card._refresh_conditions + ) + assert card._timer.start.called + + def test_refresh_conditions_bad_status_code(self): + fake_response = mock.Mock(spec=requests.Response) + fake_response.status_code = 404 + + fake_timer = mock.Mock(spec=threading.Timer) + fake_timer.start = mock.Mock() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + with mock.patch.object(card_module, 'Timer', + return_value=fake_timer): + card = self._dummy_card() + card.initialize(refresh=False) + card._refresh_conditions() + + assert card_module.requests.get.called + assert card._current_conditions is None + + assert card._timer is not None + card_module.Timer.assert_called_with( + card.REFRESH_INTERVAL, card._refresh_conditions + ) + assert card._timer.start.called + + def test_refresh_conditions_missing_weather_object(self): + payload = self._ok_payload() + payload['weather'] = [] + + fake_response = mock.Mock(spec=requests.Response) + fake_response.json = mock.Mock(return_value=payload) + fake_response.status_code = 200 + + fake_timer = mock.Mock(spec=threading.Timer) + fake_timer.start = mock.Mock() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + with mock.patch.object(card_module, 'Timer', + return_value=fake_timer): + card = self._dummy_card() + card.initialize(refresh=False) + card._refresh_conditions() + + assert card_module.requests.get.called + assert card._current_conditions is None + + assert card._timer is not None + card_module.Timer.assert_called_with( + card.REFRESH_INTERVAL, card._refresh_conditions + ) + assert card._timer.start.called + + def test_refresh_conditions_missing_conditions(self): + payload = self._ok_payload() + payload['weather'][0].pop('main') + + fake_response = mock.Mock(spec=requests.Response) + fake_response.json = mock.Mock(return_value=payload) + fake_response.status_code = 200 + + fake_timer = mock.Mock(spec=threading.Timer) + fake_timer.start = mock.Mock() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + with mock.patch.object(card_module, 'Timer', + return_value=fake_timer): + card = self._dummy_card() + card.initialize(refresh=False) + card._refresh_conditions() + + assert card_module.requests.get.called + assert card._current_conditions is None + + assert card._timer is not None + card_module.Timer.assert_called_with( + card.REFRESH_INTERVAL, card._refresh_conditions + ) + assert card._timer.start.called + + def test_refresh_conditions_missing_temp(self): + payload = self._ok_payload() + payload['main'].pop('temp') + + fake_response = mock.Mock(spec=requests.Response) + fake_response.json = mock.Mock(return_value=payload) + fake_response.status_code = 200 + + fake_timer = mock.Mock(spec=threading.Timer) + fake_timer.start = mock.Mock() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + with mock.patch.object(card_module, 'Timer', + return_value=fake_timer): + card = self._dummy_card() + card.initialize(refresh=False) + card._refresh_conditions() + + assert card_module.requests.get.called + assert card._current_conditions is None + + assert card._timer is not None + card_module.Timer.assert_called_with( + card.REFRESH_INTERVAL, card._refresh_conditions + ) + assert card._timer.start.called + + def test_refresh_conditions(self): + payload = self._ok_payload() + + fake_response = mock.Mock(spec=requests.Response) + fake_response.json = mock.Mock(return_value=payload) + fake_response.status_code = 200 + + fake_timer = mock.Mock(spec=threading.Timer) + fake_timer.start = mock.Mock() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + with mock.patch.object(card_module, 'Timer', + return_value=fake_timer): + card = self._dummy_card() + card.initialize(refresh=False) + card._refresh_conditions() + + assert card_module.requests.get.called + assert card._current_conditions == { + 'conditions': payload['weather'][0]['main'], + 'icon': payload['weather'][0]['icon'], + 'temperature': payload['main']['temp'], + } + + assert card._timer is not None + card_module.Timer.assert_called_with( + card.REFRESH_INTERVAL, card._refresh_conditions + ) + assert card._timer.start.called + + def test_refresh_conditions_no_icon(self): + payload = self._ok_payload() + payload['weather'][0].pop('icon') + + fake_response = mock.Mock(spec=requests.Response) + fake_response.json = mock.Mock(return_value=payload) + fake_response.status_code = 200 + + fake_timer = mock.Mock(spec=threading.Timer) + fake_timer.start = mock.Mock() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + with mock.patch.object(card_module.requests, 'get', + return_value=fake_response): + with mock.patch.object(card_module, 'Timer', + return_value=fake_timer): + card = self._dummy_card() + card.initialize(refresh=False) + card._refresh_conditions() + + assert card_module.requests.get.called + assert card._current_conditions == { + 'conditions': payload['weather'][0]['main'], + 'icon': None, + 'temperature': payload['main']['temp'], + } + + assert card._timer is not None + card_module.Timer.assert_called_with( + card.REFRESH_INTERVAL, card._refresh_conditions + ) + assert card._timer.start.called + + def test_render_city(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_city() + + card._text_font.render.assert_called_with( + card._settings['city'], True, card.WHITE + ) + assert isinstance(surface, pygame.surface.Surface) + + def test_render_city_override_color(self): + card = self._dummy_card(city_color=(255, 0, 0)) + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_city() + + card._text_font.render.assert_called_with( + card._settings['city'], True, (255, 0, 0) + ) + assert isinstance(surface, pygame.surface.Surface) + + def test_render_conditions(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + card._current_conditions = { + 'conditions': 'clouds' + } + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_conditions() + + card._text_font.render.assert_called_with( + 'Clouds', True, card.WHITE + ) + assert isinstance(surface, pygame.surface.Surface) + + def test_render_conditions_override_color(self): + card = self._dummy_card(conditions_color=(255, 0, 0)) + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + card._current_conditions = { + 'conditions': 'clouds' + } + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_conditions() + + card._text_font.render.assert_called_with( + 'Clouds', True, (255, 0, 0) + ) + assert isinstance(surface, pygame.surface.Surface) + + def test_render_icon(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + icon_code = '01d' + card._current_conditions = {'icon': icon_code} + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_icon() + + card._icon_font.render.assert_called_with( + card.WEATHER_CODE_TO_ICON[icon_code], True, card.WHITE + ) + assert isinstance(surface, pygame.surface.Surface) + + def test_render_icon_override_color(self): + card = self._dummy_card(icon_color=(255, 0, 0)) + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + icon_code = '01d' + card._current_conditions = {'icon': icon_code} + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_icon() + + card._icon_font.render.assert_called_with( + card.WEATHER_CODE_TO_ICON[icon_code], True, (255, 0, 0) + ) + assert isinstance(surface, pygame.surface.Surface) + + def test_render_icon_no_icon(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + icon_code = None + card._current_conditions = {'icon': icon_code} + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_icon() + + assert card._icon_font.render.called is False + assert surface is None + + def test_render_icon_unknown_icon(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + icon_code = 'spam' + card._current_conditions = {'icon': icon_code} + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_icon() + + assert card._icon_font.render.called is False + assert surface is None + + def test_render_temperature(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + card._current_conditions = { + 'temperature': 17 + } + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_temperature() + + card._text_font.render.assert_called_with( + u'17°', True, card.WHITE + ) + assert isinstance(surface, pygame.surface.Surface) + + def test_render_temperature_override_color(self): + card = self._dummy_card(temperature_color=(255, 0, 0)) + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + card._current_conditions = { + 'temperature': 17 + } + + card._text_font.render = mock.Mock( + return_value=mock.Mock(spec=pygame.surface.Surface) + ) + + surface = card._render_temperature() + + card._text_font.render.assert_called_with( + u'17°', True, (255, 0, 0) + ) + assert isinstance(surface, pygame.surface.Surface) + + def test_quit(self): + card = self._dummy_card() + card._timer = None + card.quit() + + card = self._dummy_card() + card._timer = mock.Mock(spec=threading.Timer) + card._timer.cancel = mock.Mock() + + card.quit() + assert card._timer.cancel.called + + def test_tick_redraw(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + card._current_conditions = { + 'conditions': 'Clouds', + 'icon': '01d', + 'temperature': 17 + } + + card._surface = mock.Mock(spec=pygame.surface.Surface) + + city_surface = mock.Mock(spec=pygame.surface.Surface) + city_surface.get_size = mock.Mock(return_value=(120, 40)) + card._render_city = mock.Mock(return_value=city_surface) + + conditions_surface = mock.Mock(spec=pygame.surface.Surface) + conditions_surface.get_size = mock.Mock(return_value=(200, 40)) + card._render_conditions = mock.Mock( + return_value=conditions_surface + ) + + icon_surface = mock.Mock(spec=pygame.surface.Surface) + icon_surface.get_size = mock.Mock(return_value=(128, 128)) + card._render_icon = mock.Mock(return_value=icon_surface) + + temperature_surface = mock.Mock(spec=pygame.surface.Surface) + temperature_surface.get_size = mock.Mock(return_value=(100, 82)) + card._render_temperature = mock.Mock( + return_value=temperature_surface + ) + + card.tick() + + card.surface.blit.assert_any_call( + city_surface, (100, 0, 120, 40) + ) + + card.surface.blit.assert_any_call( + conditions_surface, (60, 200, 200, 40) + ) + + card.surface.blit.assert_any_call( + icon_surface, (34, 56, 128, 128) + ) + + card.surface.blit.assert_any_call( + temperature_surface, (186, 79, 100, 82) + ) + + assert card._should_redraw is False + + def test_tick_dont_redraw(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + card._should_redraw = False + + card._surface = mock.Mock(spec=pygame.surface.Surface) + + city_surface = mock.Mock(spec=pygame.surface.Surface) + city_surface.get_size = mock.Mock(return_value=(120, 40)) + card._render_city = mock.Mock(return_value=city_surface) + + conditions_surface = mock.Mock(spec=pygame.surface.Surface) + conditions_surface.get_size = mock.Mock(return_value=(200, 40)) + card._render_conditions = mock.Mock( + return_value=conditions_surface + ) + + icon_surface = mock.Mock(spec=pygame.surface.Surface) + icon_surface.get_size = mock.Mock(return_value=(128, 128)) + card._render_icon = mock.Mock(return_value=icon_surface) + + temperature_surface = mock.Mock(spec=pygame.surface.Surface) + temperature_surface.get_size = mock.Mock(return_value=(100, 82)) + card._render_temperature = mock.Mock( + return_value=temperature_surface + ) + + card.tick() + + assert card._render_city.called is False + assert card._render_conditions.called is False + assert card._render_icon.called is False + assert card._render_temperature.called is False + assert card.surface.blit.called is False + + assert card._should_redraw is False + + def test_tick_without_icon(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + card._current_conditions = { + 'conditions': 'Clouds', + 'icon': '01d', + 'temperature': 17 + } + + card._surface = mock.Mock(spec=pygame.surface.Surface) + + city_surface = mock.Mock(spec=pygame.surface.Surface) + city_surface.get_size = mock.Mock(return_value=(120, 40)) + card._render_city = mock.Mock(return_value=city_surface) + + conditions_surface = mock.Mock(spec=pygame.surface.Surface) + conditions_surface.get_size = mock.Mock(return_value=(200, 40)) + card._render_conditions = mock.Mock( + return_value=conditions_surface + ) + + card._render_icon = mock.Mock(return_value=None) + + temperature_surface = mock.Mock(spec=pygame.surface.Surface) + temperature_surface.get_size = mock.Mock(return_value=(100, 82)) + card._render_temperature = mock.Mock( + return_value=temperature_surface + ) + + card.tick() + + card.surface.blit.assert_any_call( + city_surface, (100, 0, 120, 40) + ) + + card.surface.blit.assert_any_call( + conditions_surface, (60, 200, 200, 40) + ) + + card.surface.blit.assert_any_call( + temperature_surface, (110, 79, 100, 82) + ) + + assert card._should_redraw is False + + def test_tick_without_conditions(self): + card = self._dummy_card() + + with mock.patch.object(card_module.pygame.font, 'Font', + spec=pygame.font.Font): + card.initialize(refresh=False) + + card._surface = mock.Mock(spec=pygame.surface.Surface) + + city_surface = mock.Mock(spec=pygame.surface.Surface) + city_surface.get_size = mock.Mock(return_value=(120, 40)) + card._render_city = mock.Mock(return_value=city_surface) + + card._render_conditions = mock.Mock() + card._render_icon = mock.Mock() + card._render_temperature = mock.Mock() + + card.tick() + + assert card.surface.blit.call_count == 1 + card.surface.blit.assert_called_with( + city_surface, (100, 0, 120, 40) + ) + + assert card._render_conditions.called is False + assert card._render_icon.called is False + assert card._render_temperature.called is False + + assert card._should_redraw is False