omegalines 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. import HTMLParser
  4. import datetime as dt
  5. import dateutil.parser
  6. import dateutil.tz
  7. import json
  8. import os
  9. import socket
  10. import sys
  11. import time
  12. import urllib2
  13. import yaml
  14. from OmegaExpansion import oledExp
  15. DEFAULT_CONFIG_PATHS = [
  16. os.path.join(os.path.expanduser('~'), '.omegalines'),
  17. os.path.join(os.sep, 'etc', 'omegalines'),
  18. ]
  19. OLED_DISPLAY_HEIGHT = 8
  20. OLED_DISPLAY_WIDTH = 21
  21. WIENER_LINIEN_DEFAULT_UPDATE_INTERVAL_SECONDS = 10
  22. OEBB_DEFAULT_UPDATE_INTERVAL_SECONDS = 30
  23. # https://openwrt.org/docs/user-guide/system_configuration
  24. OEBB_TIMEZONE = dateutil.tz.tzstr('CET-1CEST,M3.5.0,M10.5.0/3')
  25. REQUEST_TIMEOUT_SECONDS = 10
  26. html_parser = HTMLParser.HTMLParser()
  27. def datetime_now_local():
  28. return dt.datetime.now(dateutil.tz.tzlocal())
  29. def format_timedelta(timedelta):
  30. total_seconds = timedelta.total_seconds()
  31. return '%s%d:%02d' % (
  32. '-' if total_seconds < 0 else '',
  33. int(abs(total_seconds) / 60),
  34. abs(total_seconds) % 60,
  35. )
  36. assert "0:20" == format_timedelta(dt.timedelta(seconds=20))
  37. assert "1:20" == format_timedelta(dt.timedelta(seconds=80))
  38. assert "2:00" == format_timedelta(dt.timedelta(seconds=120))
  39. assert "-0:20" == format_timedelta(dt.timedelta(seconds=-20))
  40. assert "-1:20" == format_timedelta(dt.timedelta(seconds=-80))
  41. assert "-2:00" == format_timedelta(dt.timedelta(seconds=-120))
  42. def parse_oebb_datetime(date_str, time_str):
  43. return dt.datetime.combine(
  44. dt.datetime.strptime(date_str, '%d.%m.%Y').date(),
  45. dt.datetime.strptime(time_str, '%H:%M').time().replace(second=0),
  46. ).replace(tzinfo=OEBB_TIMEZONE)
  47. assert '2018-02-22T09:46:00+01:00' == \
  48. parse_oebb_datetime(u'22.02.2018', u'09:46').isoformat()
  49. def oled_write_line(line):
  50. oledExp.write(
  51. line.ljust(OLED_DISPLAY_WIDTH, ' ')[:OLED_DISPLAY_WIDTH],
  52. )
  53. def oled_encode(text):
  54. return text.replace(u'ä', u'ae') \
  55. .replace(u'ö', u'oe') \
  56. .replace(u'ü', u'ue')
  57. class Departure:
  58. def __init__(self, line, towards, predicted_time):
  59. self.line = line
  60. self.towards = towards
  61. self.predicted_time = predicted_time
  62. @property
  63. def predicted_timedelta(self):
  64. return self.predicted_time - datetime_now_local()
  65. def request_wiener_linien_departures(api_key, rbl):
  66. req = urllib2.Request(
  67. "https://www.wienerlinien.at/ogd_realtime/monitor?sender=%s&rbl=%s"
  68. % (api_key, rbl),
  69. )
  70. req.add_header("Accept", "application/json")
  71. req.add_header("Content-Type", "application/json")
  72. req_time = datetime_now_local()
  73. resp = urllib2.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS)
  74. resp_data = json.loads(resp.read())
  75. # dt.datetime.strptime:
  76. # ValueError: 'z' is a bad directive in format
  77. # '%Y-%m-%dT%H:%M:%S.%f%z'
  78. server_time_delta = req_time - \
  79. dateutil.parser.parse(resp_data['message']['serverTime'])
  80. monitors_data = resp_data['data']['monitors']
  81. if len(monitors_data) == 0:
  82. return []
  83. else:
  84. assert 1 == len(monitors_data), monitors_data
  85. departures = []
  86. for line_data in monitors_data[0]['lines']:
  87. departures.extend(departures_from_wiener_linien_line_data(
  88. line_data=line_data,
  89. server_time_delta=server_time_delta,
  90. ))
  91. return departures
  92. def departures_from_wiener_linien_line_data(line_data, server_time_delta):
  93. assert 1 == len(line_data['departures']), line_data
  94. departures = []
  95. for departure_data in line_data['departures']['departure']:
  96. if 'timeReal' in departure_data['departureTime']:
  97. predicted_time_server = dateutil.parser.parse(
  98. departure_data['departureTime']['timeReal'],
  99. )
  100. else:
  101. predicted_time_server = dateutil.parser.parse(
  102. departure_data['departureTime']['timePlanned'],
  103. ) + dt.timedelta(
  104. minutes=int(departure_data['departureTime']['countdown']),
  105. )
  106. departures.append(Departure(
  107. line=departure_data['vehicle']['name']
  108. if 'vehicle' in departure_data else line_data['name'],
  109. towards=departure_data['vehicle']['towards']
  110. if 'vehicle' in departure_data else line_data['towards'],
  111. predicted_time=predicted_time_server - server_time_delta,
  112. ))
  113. return departures
  114. def request_oebb_departures(eva_id):
  115. req_time = datetime_now_local()
  116. req = urllib2.Request(
  117. 'http://fahrplan.oebb.at/bin/stboard.exe/dn?' + '&'.join([
  118. 'L=vs_scotty.vs_liveticker',
  119. 'evaId=%d' % eva_id,
  120. 'boardType=dep',
  121. 'disableEquivs=yes',
  122. 'outputMode=tickerDataOnly',
  123. 'start=yes',
  124. ]),
  125. )
  126. print('request %s' % req.get_full_url())
  127. resp = urllib2.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS)
  128. resp_data = json.loads(
  129. resp.read().replace('journeysObj = ', ''),
  130. )
  131. departures = []
  132. for departure_data in resp_data.get('journey', []):
  133. """
  134. pr: line (u'R 2323')
  135. lastStop (u'Wr.Neustadt Hbf)
  136. da: planned departure date (u'22.02.2018')
  137. ti: planned departure time (u'09:42')
  138. rt: dict if delayed, otherwise False
  139. rt.status (e.g. u'Ausfall)
  140. rt.dld: estimated departure date
  141. rt.dlt: estimated departure time
  142. """
  143. if departure_data['rt']:
  144. if departure_data['rt']['dlt'] == '': # canceled?
  145. predicted_time = None
  146. else: # delayed
  147. predicted_time = parse_oebb_datetime(
  148. departure_data['rt']['dld'],
  149. departure_data['rt']['dlt'],
  150. )
  151. else: # on time
  152. predicted_time = parse_oebb_datetime(
  153. departure_data['da'],
  154. departure_data['ti'],
  155. )
  156. if predicted_time:
  157. departures.append(Departure(
  158. line=departure_data['pr'],
  159. towards=html_parser.unescape(departure_data['lastStop']),
  160. predicted_time=predicted_time,
  161. ))
  162. return departures
  163. def draw_departures(departures, indicate_error=False):
  164. oledExp.setCursor(0, 0)
  165. headline = datetime_now_local().strftime("%Y-%m-%d %H:%M:%S")
  166. if indicate_error:
  167. headline += 'E'
  168. oled_write_line(headline)
  169. departures.sort(key=lambda d: d.predicted_time)
  170. for departure_idx, departure in enumerate(departures[:OLED_DISPLAY_HEIGHT - 1]):
  171. oledExp.setCursor(1 + departure_idx, 0)
  172. oled_write_line("%s %s %s" % (
  173. format_timedelta(departure.predicted_timedelta),
  174. departure.line.replace(' ', ''),
  175. oled_encode(departure.towards),
  176. ))
  177. def run(config_path):
  178. if config_path is None:
  179. available_config_paths = [
  180. p for p in DEFAULT_CONFIG_PATHS if os.path.exists(p)
  181. ]
  182. if len(available_config_paths) == 0:
  183. raise Exception('found no config file')
  184. config_path = available_config_paths[0]
  185. print('config path: %s' % config_path)
  186. with open(config_path, 'r') as config_file:
  187. config = yaml.load(config_file.read())
  188. if not 'update_interval_seconds' in config['wiener_linien']:
  189. config['wiener_linien']['update_interval_seconds'] = \
  190. WIENER_LINIEN_DEFAULT_UPDATE_INTERVAL_SECONDS
  191. if not 'oebb' in config:
  192. config['oebb'] = {}
  193. if not 'update_interval_seconds' in config['oebb']:
  194. config['oebb']['update_interval_seconds'] = \
  195. OEBB_DEFAULT_UPDATE_INTERVAL_SECONDS
  196. assert not oledExp.driverInit()
  197. assert not oledExp.setDisplayPower(1)
  198. wiener_linien_departures = []
  199. wiener_linien_last_update_time = None
  200. oebb_departures = []
  201. oebb_last_update_time = None
  202. while True:
  203. if wiener_linien_last_update_time is None \
  204. or time.time() - wiener_linien_last_update_time \
  205. > config['wiener_linien']['update_interval_seconds']:
  206. print('update wiener linien')
  207. wiener_linien_error = False
  208. try:
  209. wiener_linien_departures = request_wiener_linien_departures(
  210. api_key=config['wiener_linien']['api_key'],
  211. rbl=config['wiener_linien']['rbl'],
  212. )
  213. except (urllib2.URLError, socket.timeout) as e:
  214. wiener_linien_departures = []
  215. wiener_linien_error = True
  216. print(e)
  217. wiener_linien_last_update_time = time.time()
  218. if 'eva_ids' in config['oebb'] \
  219. and (oebb_last_update_time is None
  220. or time.time() - oebb_last_update_time
  221. > config['oebb']['update_interval_seconds']):
  222. oebb_departures = []
  223. oebb_error = False
  224. for eva_id in config['oebb']['eva_ids']:
  225. try:
  226. oebb_departures.extend(request_oebb_departures(eva_id))
  227. except (urllib2.URLError, socket.timeout) as e:
  228. oebb_error = True
  229. print(e)
  230. oebb_last_update_time = time.time()
  231. departures = wiener_linien_departures + oebb_departures
  232. if 'offset_seconds' in config:
  233. current_time = datetime_now_local()
  234. departures = filter(
  235. lambda d: (d.predicted_time - current_time).total_seconds()
  236. >= config['offset_seconds'],
  237. departures,
  238. )
  239. draw_departures(
  240. departures=departures,
  241. indicate_error=wiener_linien_error or oebb_error,
  242. )
  243. time.sleep(0.1)
  244. def _init_argparser():
  245. import argparse
  246. argparser = argparse.ArgumentParser()
  247. argparser.add_argument(
  248. '-c', '--config-path',
  249. dest='config_path',
  250. type=str,
  251. default=None,
  252. help='default: %r' % DEFAULT_CONFIG_PATHS,
  253. )
  254. return argparser
  255. def main(argv):
  256. argparser = _init_argparser()
  257. args = argparser.parse_args(argv)
  258. run(**vars(args))
  259. return 0
  260. if __name__ == "__main__":
  261. import sys
  262. sys.exit(main(sys.argv[1:]))