application.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. # -*- coding: utf-8 -*-
  2. # Copyright (c) 2014-2016 Tomek Wójcik <tomek@bthlabs.pl>
  3. #
  4. # Permission is hereby granted, free of charge, to any person obtaining a copy
  5. # of this software and associated documentation files (the "Software"), to deal
  6. # in the Software without restriction, including without limitation the rights
  7. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  8. # copies of the Software, and to permit persons to whom the Software is
  9. # furnished to do so, subject to the following conditions:
  10. #
  11. # The above copyright notice and this permission notice shall be included in
  12. # all copies or substantial portions of the Software.
  13. #
  14. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  15. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  16. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  17. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  18. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  19. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  20. # THE SOFTWARE.
  21. #
  22. """
  23. pie_time.application
  24. ====================
  25. This module implements the PieTime application.
  26. """
  27. import datetime
  28. import argparse
  29. import imp
  30. import logging
  31. import os
  32. import sys
  33. import pygame
  34. from pie_time import __copyright__ as copyright, __version__ as version
  35. RET_OK = 0
  36. RET_ERROR = 99
  37. MOTD_PICLOCK_BANNER = u"PieTime v%s by Tomek Wójcik" % (
  38. version
  39. )
  40. MOTD_LICENSE_BANNER = u"Released under the MIT license"
  41. EVENT_QUIT = 0
  42. EVENT_CLICK_TO_UNBLANK = 1
  43. EVENT_CLICK_TO_PREV_CARD = 2
  44. EVENT_CLICK_TO_NEXT_CARD = 3
  45. class Quit(Exception):
  46. pass
  47. class PieTimeEvent(object):
  48. def __init__(self, app, event):
  49. self.event = event
  50. self.app = app
  51. def is_quit(self):
  52. return (self.event.type == pygame.QUIT)
  53. def is_key_quit(self):
  54. return (
  55. self.event.type == pygame.KEYDOWN
  56. and self.event.key == self.app.KEY_QUIT
  57. )
  58. def is_click_to_unblank(self):
  59. return (
  60. self.event.type == pygame.MOUSEBUTTONDOWN
  61. and self.app._click_to_unblank_interval is not None
  62. and self.app._is_blanked is True
  63. )
  64. def is_click_to_prev_card(self):
  65. return (
  66. self.event.type == pygame.MOUSEBUTTONDOWN
  67. and self.app._click_to_transition is True
  68. and self.app._is_blanked is False
  69. and self.app._ctt_region_prev.collidepoint(self.event.pos) == 1
  70. )
  71. def is_click_to_next_card(self):
  72. return (
  73. self.event.type == pygame.MOUSEBUTTONDOWN
  74. and self.app._click_to_transition is True
  75. and self.app._is_blanked is False
  76. and self.app._ctt_region_next.collidepoint(self.event.pos) == 1
  77. )
  78. class PieTime(object):
  79. """
  80. The PieTime application.
  81. :param deck: the deck
  82. :param screen_size: tuple of (width, height) to use as the screen size
  83. :param fps: number of frames per second to limit rendering to
  84. :param blanker_schedule: blanker schedule
  85. :param click_to_unblank_interval: time interval for click to unblank
  86. :param click_to_transition: boolean defining if click to transition is
  87. enabled
  88. :param verbose: boolean defining if verbose logging should be on
  89. :param log_path: path to log file (if omitted, *stdout* will be used)
  90. """
  91. #: Default background color
  92. BACKGROUND_COLOR = (0, 0, 0)
  93. #: Blanked screen color
  94. BLANK_COLOR = (0, 0, 0)
  95. #: Default card display duration interval
  96. CARD_INTERVAL = 60
  97. #: Defines key which quits the application
  98. KEY_QUIT = pygame.K_ESCAPE
  99. #: Defines size of click to transition region square
  100. CLICK_TO_TRANSITION_REGION_SIZE = 30
  101. _DEFAULT_OUTPUT_STREAM = sys.stdout
  102. _STREAM_FACTORY = file
  103. def __init__(self, deck, screen_size=(320, 240), fps=20,
  104. blanker_schedule=None, click_to_unblank_interval=None,
  105. click_to_transition=True, verbose=False, log_path=None):
  106. self._deck = deck
  107. #: The screen surface
  108. self.screen = None
  109. #: The screen size tuple
  110. self.screen_size = screen_size
  111. #: List of events captured in this frame
  112. self.events = []
  113. #: Path to log file. If `None`, *stdout* will be used for logging.
  114. self.log_path = log_path
  115. self._fps = fps
  116. self._verbose = verbose
  117. self._blanker_schedule = blanker_schedule
  118. self._click_to_unblank_interval = click_to_unblank_interval
  119. self._click_to_transition = click_to_transition
  120. self._clock = None
  121. self._cards = []
  122. self._is_blanked = False
  123. self._current_card_idx = None
  124. self._current_card_time = None
  125. self._should_quit = False
  126. self._internal_events = set()
  127. self._ctu_timer = None
  128. self._output_stream = None
  129. self._ctt_region_prev = pygame.Rect(
  130. 0,
  131. self.screen_size[1] - self.CLICK_TO_TRANSITION_REGION_SIZE,
  132. self.CLICK_TO_TRANSITION_REGION_SIZE,
  133. self.CLICK_TO_TRANSITION_REGION_SIZE
  134. )
  135. self._ctt_region_next = pygame.Rect(
  136. self.screen_size[0] - self.CLICK_TO_TRANSITION_REGION_SIZE,
  137. self.screen_size[1] - self.CLICK_TO_TRANSITION_REGION_SIZE,
  138. self.CLICK_TO_TRANSITION_REGION_SIZE,
  139. self.CLICK_TO_TRANSITION_REGION_SIZE
  140. )
  141. def _should_blank(self, now=None):
  142. if self._has_click_to_unblank_event() or self._ctu_timer is not None:
  143. if self._is_blanked is False and self._ctu_timer is None:
  144. self._ctu_timer = None
  145. return False
  146. if self._click_to_unblank_interval is not None:
  147. if self._ctu_timer is None:
  148. self._ctu_timer = self._click_to_unblank_interval
  149. return False
  150. self._ctu_timer -= self._clock.get_time() / 1000.0
  151. if self._ctu_timer <= 0:
  152. self._ctu_timer = None
  153. return True
  154. else:
  155. return False
  156. if self._blanker_schedule:
  157. delta_blanker_start, delta_blanker_end = self._blanker_schedule
  158. if now is None:
  159. now = datetime.datetime.now()
  160. midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
  161. blanker_start = midnight + delta_blanker_start
  162. blanker_end = midnight + delta_blanker_end
  163. if blanker_start > blanker_end:
  164. if now.hour < 12:
  165. blanker_start -= datetime.timedelta(days=1)
  166. else:
  167. blanker_end += datetime.timedelta(days=1)
  168. if now >= blanker_start and now < blanker_end:
  169. return True
  170. return False
  171. def _blank(self):
  172. if self._is_blanked is False:
  173. self.logger.debug('Blanking the screen!')
  174. self.will_blank()
  175. self._is_blanked = True
  176. self.screen.fill(self.BLANK_COLOR)
  177. def _unblank(self):
  178. if self._is_blanked:
  179. self.logger.debug('Unblanking the screen!')
  180. self.will_unblank()
  181. self._is_blanked = False
  182. self._current_card_idx = 0
  183. self._current_card_time = 0
  184. self._cards[self._current_card_idx][0].show()
  185. def _transition_cards(self, direction=1, force=False):
  186. if self._current_card_idx is None and force is False:
  187. self._current_card_idx = 0
  188. self._current_card_time = 0
  189. self._cards[self._current_card_idx][0].show()
  190. elif len(self._cards) > 1:
  191. self._current_card_time += self._clock.get_time() / 1000.0
  192. card_interval = self._cards[self._current_card_idx][1]
  193. if self._current_card_time >= card_interval or force is True:
  194. new_card_idx = self._current_card_idx + direction
  195. if new_card_idx >= len(self._cards):
  196. new_card_idx = 0
  197. elif new_card_idx < 0:
  198. new_card_idx = len(self._cards) - 1
  199. self.logger.debug('Card transition: %d -> %d' % (
  200. self._current_card_idx, new_card_idx
  201. ))
  202. self._cards[self._current_card_idx][0].hide()
  203. self._current_card_idx = new_card_idx
  204. self._current_card_time = 0
  205. self._cards[self._current_card_idx][0].show()
  206. def _get_events(self):
  207. self._internal_events = set()
  208. self.events = []
  209. for event in pygame.event.get():
  210. pie_time_event = PieTimeEvent(self, event)
  211. if pie_time_event.is_quit():
  212. self.logger.debug('_get_events: QUIT')
  213. self._internal_events.add(EVENT_QUIT)
  214. elif pie_time_event.is_key_quit():
  215. self.logger.debug('_get_events: KEY_QUIT')
  216. self._internal_events.add(EVENT_QUIT)
  217. elif pie_time_event.is_click_to_unblank():
  218. self.logger.debug('_get_events: CLICK_TO_UNBLANK')
  219. self._internal_events.add(EVENT_CLICK_TO_UNBLANK)
  220. elif pie_time_event.is_click_to_prev_card():
  221. self.logger.debug('_get_events: CLICK_TO_PREV_CARD')
  222. self._internal_events.add(EVENT_CLICK_TO_PREV_CARD)
  223. elif pie_time_event.is_click_to_next_card():
  224. self.logger.debug('_get_events: CLICK_TO_NEXT_CARD')
  225. self._internal_events.add(EVENT_CLICK_TO_NEXT_CARD)
  226. else:
  227. self.events.append(event)
  228. def _has_quit_event(self):
  229. return (EVENT_QUIT in self._internal_events)
  230. def _has_click_to_unblank_event(self):
  231. return (EVENT_CLICK_TO_UNBLANK in self._internal_events)
  232. def _start_clock(self):
  233. self._clock = pygame.time.Clock()
  234. def _setup_output_stream(self):
  235. if self.log_path is None:
  236. self._output_stream = self._DEFAULT_OUTPUT_STREAM
  237. else:
  238. self._output_stream = self._STREAM_FACTORY(self.log_path, 'a')
  239. def _setup_logging(self):
  240. logger = logging.getLogger('PieTime')
  241. requests_logger = logging.getLogger('requests')
  242. if self._verbose:
  243. logger.setLevel(logging.DEBUG)
  244. else:
  245. logger.setLevel(logging.INFO)
  246. requests_logger.setLevel(logging.WARNING)
  247. handler = logging.StreamHandler(self._output_stream)
  248. formatter = logging.Formatter(
  249. '%(asctime)s PieTime: %(levelname)s: %(message)s'
  250. )
  251. handler.setFormatter(formatter)
  252. logger.addHandler(handler)
  253. for requests_handler in requests_logger.handlers:
  254. requests_logger.removeHandler(requests_handler)
  255. requests_logger.addHandler(handler)
  256. @property
  257. def logger(self):
  258. """The application-wide :py:class:`logging.Logger` object."""
  259. if not hasattr(self, '_logger'):
  260. self._logger = logging.getLogger('PieTime')
  261. return self._logger
  262. def init_pygame(self):
  263. """Initializes PyGame and the internal clock."""
  264. self.logger.debug('Initializing PyGame.')
  265. pygame.init()
  266. pygame.mouse.set_visible(False)
  267. def quit_pygame(self):
  268. """Quits PyGame."""
  269. self.logger.debug('Quitting PyGame.')
  270. pygame.quit()
  271. self._clock = None
  272. def init_cards(self):
  273. """
  274. Initializes the cards.
  275. Initialization of a card consits of the following steps:
  276. * Creating an instance of the card class,
  277. * Binding the card with the application
  278. (:py:meth:`pie_time.AbstractCard.set_app`),
  279. * Setting the card's settings
  280. (:py:meth:`pie_time.AbstractCard.set_settings`),
  281. * Initializing the card (:py:meth:`pie_time.AbstractCard.initialize`).
  282. """
  283. self.logger.debug('Initializing cards.')
  284. for i in xrange(0, len(self._deck)):
  285. card_def = self._deck[i]
  286. klass = None
  287. interval = self.CARD_INTERVAL
  288. settings = {}
  289. if not isinstance(card_def, tuple):
  290. klass = card_def
  291. elif len(card_def) == 2:
  292. klass, interval = card_def
  293. elif len(card_def) == 3:
  294. klass, interval, settings = card_def
  295. if klass is not None:
  296. card = klass()
  297. card.set_app(self)
  298. card.set_settings(settings)
  299. card.initialize()
  300. self._cards.append((card, interval))
  301. else:
  302. self.logger.warning('Invalid deck entry at index: %d' % i)
  303. def destroy_cards(self):
  304. """
  305. Destroys the cards.
  306. Calls the :py:meth:`pie_time.AbstractCard.quit` of each card.
  307. """
  308. self.logger.debug('Destroying cards.')
  309. while len(self._cards) > 0:
  310. card, _ = self._cards.pop()
  311. try:
  312. card.quit()
  313. except:
  314. self.logger.error('ERROR!', exc_info=True)
  315. def get_screen(self):
  316. """Creates and returns the screen screen surface."""
  317. self.logger.debug('Creating screen.')
  318. return pygame.display.set_mode(self.screen_size)
  319. def fill_screen(self):
  320. """
  321. Fills the screen surface with color defined in
  322. :py:attr:`pie_time.PieTime.BACKGROUND_COLOR`.
  323. """
  324. self.screen.fill(self.BACKGROUND_COLOR)
  325. def run(self, standalone=True):
  326. """
  327. Runs the application.
  328. This method contains the app's main loop and it never returns. Upon
  329. quitting, this method will call the :py:func:`sys.exit` function with
  330. the status code (``99`` if an unhandled exception occurred, ``0``
  331. otherwise).
  332. The application will quit under one of the following conditions:
  333. * An unhandled exception reached this method,
  334. * PyGame requested to quit (e.g. due to closing the window),
  335. * Some other code called the :py:meth:`pie_time.PieTime.quit` method on
  336. the application.
  337. Before quitting the :py:meth:`pie_time.PieTime.destroy_cards` and
  338. :py:meth:`pie_time.PieTime.quit_pygame` methods will be called to clean
  339. up.
  340. """
  341. result = RET_OK
  342. self._setup_output_stream()
  343. self._setup_logging()
  344. try:
  345. self.logger.info(MOTD_PICLOCK_BANNER)
  346. self.logger.info(copyright)
  347. self.logger.info(MOTD_LICENSE_BANNER)
  348. self.logger.debug('My PID = %d' % os.getpid())
  349. self.init_pygame()
  350. self.screen = self.get_screen()
  351. self.init_cards()
  352. self._start_clock()
  353. while True:
  354. self._get_events()
  355. if self._should_quit or self._has_quit_event():
  356. raise Quit()
  357. if not self._should_blank():
  358. self._unblank()
  359. if EVENT_CLICK_TO_PREV_CARD in self._internal_events:
  360. self._transition_cards(direction=-1, force=True)
  361. elif EVENT_CLICK_TO_NEXT_CARD in self._internal_events:
  362. self._transition_cards(direction=1, force=True)
  363. else:
  364. self._transition_cards()
  365. card = self._cards[self._current_card_idx][0]
  366. card.tick()
  367. self.fill_screen()
  368. self.screen.blit(
  369. card.surface, (0, 0, card.width, card.height)
  370. )
  371. else:
  372. self._blank()
  373. pygame.display.flip()
  374. self._clock.tick(self._fps)
  375. except Exception as exc:
  376. if not isinstance(exc, Quit):
  377. self.logger.error('ERROR!', exc_info=True)
  378. result = RET_ERROR
  379. finally:
  380. self.destroy_cards()
  381. self.quit_pygame()
  382. if standalone:
  383. sys.exit(result)
  384. else:
  385. return result
  386. def quit(self):
  387. """Tells the application to quit."""
  388. self._should_quit = True
  389. def will_blank(self):
  390. """
  391. Called before blanking the screen.
  392. This method can be used to perform additional operations before
  393. blanking the screen.
  394. The default implementation does nothing.
  395. """
  396. pass
  397. def will_unblank(self):
  398. """
  399. Called before unblanking the screen.
  400. This method can be used to perform additional operations before
  401. unblanking the screen.
  402. The default implementation does nothing.
  403. """
  404. pass