1#!/usr/bin/env python
2# encoding: utf-8
3
4"""webgps.py
5
6This is a Python port of webgps.c
7from http://www.wireless.org.au/~jhecker/gpsd/
8by Beat Bolli <me+gps@drbeat.li>
9
10It creates a skyview of the currently visible GPS satellites and their tracks
11over a time period.
12
13Usage:
14    ./webgps.py [duration]
15
16    duration may be
17    - a number of seconds
18    - a number followed by a time unit ('s' for secinds, 'm' for minutes,
19      'h' for hours or 'd' for days, e.g. '4h' for a duration of four hours)
20    - the letter 'c' for continuous operation
21
22If duration is missing, the current skyview is generated and webgps.py exits
23immediately. This is the same as giving a duration of 0.
24
25If a duration is given, webgps.py runs for this duration and generates the
26tracks of the GPS satellites in view. If the duration is the letter 'c',
27the script never exits and continuously updates the skyview.
28
29webgps.py generates two files: a HTML5 file that can be browsed, and a
30JavaScript file that contains the drawing commands for the skyview. The HTML5
31file auto-refreshes every five minutes. The generated file names are
32"gpsd-<duration>.html" and "gpsd-<duration>.js".
33
34If webgps.py is interrupted with Ctrl-C before the duration is over, it saves
35the current tracks into the file "tracks.p". This is a Python "pickle" file.
36If this file is present on start of webgps.py, it is loaded. This allows to
37restart webgps.py without losing accumulated satellite tracks.
38"""
39
40# This file is Copyright (c) 2010-2018 by the GPSD project
41# SPDX-License-Identifier: BSD-2-clause
42
43from __future__ import absolute_import, print_function, division
44
45import math
46import os
47import pickle
48import sys
49import time
50
51from gps import *
52
53gps_version = '3.20'
54if gps.__version__ != gps_version:
55    sys.stderr.write("webgps.py: ERROR: need gps module version %s, got %s\n" %
56                     (gps_version, gps.__version__))
57    sys.exit(1)
58
59
60TRACKMAX = 1024
61STALECOUNT = 10
62
63DIAMETER = 200
64
65
66def polartocart(el, az):
67    radius = DIAMETER * (1 - el / 90.0)   # * math.cos(Deg2Rad(float(el)))
68    theta = Deg2Rad(float(az - 90))
69    return (
70        # Changed this back to normal orientation - fw
71        int(radius * math.cos(theta) + 0.5),
72        int(radius * math.sin(theta) + 0.5)
73    )
74
75
76class Track:
77    '''Store the track of one satellite.'''
78
79    def __init__(self, prn):
80        self.prn = prn
81        self.stale = 0
82        self.posn = []          # list of (x, y) tuples
83
84    def add(self, x, y):
85        pos = (x, y)
86        self.stale = STALECOUNT
87        if not self.posn or self.posn[-1] != pos:
88            self.posn.append(pos)
89            if len(self.posn) > TRACKMAX:
90                self.posn = self.posn[-TRACKMAX:]
91            return 1
92        return 0
93
94    def track(self):
95        '''Return the track as canvas drawing operations.'''
96        return('M(%d,%d); ' % self.posn[0] + ''.join(['L(%d,%d); ' %
97               p for p in self.posn[1:]]))
98
99
100class SatTracks(gps):
101    '''gpsd client writing HTML5 and <canvas> output.'''
102
103    def __init__(self):
104        super(SatTracks, self).__init__()
105        self.sattrack = {}      # maps PRNs to Tracks
106        self.state = None
107        self.statetimer = time.time()
108        self.needsupdate = 0
109
110    def html(self, fh, jsfile):
111        fh.write("""<!DOCTYPE html>
112
113<html>
114<head>
115\t<meta http-equiv=Refresh content=300>
116\t<meta charset='utf-8'>
117\t<title>GPSD Satellite Positions and Readings</title>
118\t<style type='text/css'>
119\t\t.num td { text-align: right; }
120\t\tth { text-align: left; }
121\t</style>
122\t<script src='%s'></script>
123</head>
124<body>
125\t<table border=1>
126\t\t<tr>
127\t\t\t<td>
128\t\t\t\t<table border=0 class=num>
129\t\t\t\t\t<tr><th>PRN:</th><th>Elev:</th><th>Azim:</th><th>SNR:</th>
130<th>Used:</th></tr>
131""" % jsfile)
132
133        sats = self.satellites[:]
134        sats.sort(key=lambda x: x.PRN)
135        for s in sats:
136            fh.write("\t\t\t\t\t<tr><td>%d</td><td>%d</td><td>%d</td>"
137                     "<td>%d</td><td>%s</td></tr>\n" %
138                     (s.PRN, s.elevation, s.azimuth, s.ss,
139                      s.used and 'Y' or 'N'))
140
141        fh.write("\t\t\t\t</table>\n\t\t\t\t<table border=0>\n")
142
143        def row(l, v):
144            fh.write("\t\t\t\t\t<tr><th>%s:</th><td>%s</td></tr>\n" % (l, v))
145
146        def deg_to_str(a, hemi):
147            return '%.6f %c' % (abs(a), hemi[a < 0])
148
149        row('Time', self.utc or 'N/A')
150
151        if self.fix.mode >= MODE_2D:
152            row('Latitude', deg_to_str(self.fix.latitude, 'SN'))
153            row('Longitude', deg_to_str(self.fix.longitude, 'WE'))
154            row('Altitude', self.fix.mode == MODE_3D and "%f m" %
155                self.fix.altitude or 'N/A')
156            row('Speed', isfinite(self.fix.speed) and "%f m/s" %
157                self.fix.speed or 'N/A')
158            row('Course', isfinite(self.fix.track) and "%f°" %
159                self.fix.track or 'N/A')
160        else:
161            row('Latitude', 'N/A')
162            row('Longitude', 'N/A')
163            row('Altitude', 'N/A')
164            row('Speed', 'N/A')
165            row('Course', 'N/A')
166
167        row('EPX', isfinite(self.fix.epx) and "%f m" % self.fix.epx or 'N/A')
168        row('EPY', isfinite(self.fix.epy) and "%f m" % self.fix.epy or 'N/A')
169        row('EPV', isfinite(self.fix.epv) and "%f m" % self.fix.epv or 'N/A')
170        row('Climb', self.fix.mode == MODE_3D and isfinite(self.fix.climb) and
171            "%f m/s" % self.fix.climb or 'N/A')
172
173        state = "INIT"
174        if not (self.valid & ONLINE_SET):
175            newstate = 0
176            state = "OFFLINE"
177        else:
178            newstate = self.fix.mode
179            if newstate == MODE_2D:
180                state = "2D FIX"
181            elif newstate == MODE_3D:
182                state = "3D FIX"
183            else:
184                state = "NO FIX"
185        if newstate != self.state:
186            self.statetimer = time.time()
187            self.state = newstate
188        row('State', "%s (%d secs)" % (state, time.time() - self.statetimer))
189
190        fh.write("""\t\t\t\t</table>
191\t\t\t</td>
192\t\t\t<td>
193\t\t\t\t<canvas id=satview width=425 height=425>
194\t\t\t\t\t<p>Your browser needs HTML5 &lt;canvas> support to display
195 the satellite view correctly.</p>
196\t\t\t\t</canvas>
197\t\t\t\t<script>draw_satview();</script>
198\t\t\t</td>
199\t\t</tr>
200\t</table>
201</body>
202</html>
203""")
204
205    def js(self, fh):
206        fh.write("""// draw the satellite view
207
208function draw_satview() {
209    var c = document.getElementById('satview');
210    if (!c.getContext) return;
211    var ctx = c.getContext('2d');
212    if (!ctx) return;
213
214    var circle = Math.PI * 2,
215        M = function (x, y) { ctx.moveTo(x, y); },
216        L = function (x, y) { ctx.lineTo(x, y); };
217
218    ctx.save();
219    ctx.clearRect(0, 0, c.width, c.height);
220    ctx.translate(210, 210);
221
222    // grid and labels
223    ctx.strokeStyle = 'black';
224    ctx.beginPath();
225    ctx.arc(0, 0, 200, 0, circle, 0);
226    ctx.stroke();
227
228    ctx.beginPath();
229    ctx.strokeText('N', -4, -202);
230    ctx.strokeText('W', -210, 4);
231    ctx.strokeText('E', 202, 4);
232    ctx.strokeText('S', -4, 210);
233
234    ctx.strokeStyle = 'grey';
235    ctx.beginPath();
236    ctx.arc(0, 0, 100, 0, circle, 0);
237    M(2, 0);
238    ctx.arc(0, 0,   2, 0, circle, 0);
239    ctx.stroke();
240
241    ctx.strokeStyle = 'lightgrey';
242    ctx.save();
243    ctx.beginPath();
244    M(0, -200); L(0, 200); ctx.rotate(circle / 8);
245    M(0, -200); L(0, 200); ctx.rotate(circle / 8);
246    M(0, -200); L(0, 200); ctx.rotate(circle / 8);
247    M(0, -200); L(0, 200);
248    ctx.stroke();
249    ctx.restore();
250
251    // tracks
252    ctx.lineWidth = 0.6;
253    ctx.strokeStyle = 'red';
254""")
255
256        # Draw the tracks
257        for t in self.sattrack.values():
258            if t.posn:
259                fh.write("    ctx.globalAlpha = %s; ctx.beginPath(); "
260                         "%sctx.stroke();\n" %
261                         (t.stale == 0 and '0.66' or '1', t.track()))
262
263        fh.write("""
264    // satellites
265    ctx.lineWidth = 1;
266    ctx.strokeStyle = 'black';
267""")
268
269        # Draw the satellites
270        for s in self.satellites:
271            el, az = s.elevation, s.azimuth
272            if el == 0 and az == 0:
273                continue  # Skip satellites with unknown position
274            x, y = polartocart(el, az)
275            fill = not s.used and 'lightgrey' or \
276                s.ss < 30 and 'red' or \
277                s.ss < 35 and 'yellow' or \
278                s.ss < 40 and 'green' or 'lime'
279
280            # Center PRNs in the marker
281            offset = s.PRN < 10 and 3 or s.PRN >= 100 and -3 or 0
282
283            fh.write("    ctx.beginPath(); ctx.fillStyle = '%s'; " % fill)
284            if s.PRN > 32:      # Draw a square for SBAS satellites
285                fh.write("ctx.rect(%d, %d, 16, 16); " % (x - 8, y - 8))
286            else:
287                fh.write("ctx.arc(%d, %d, 8, 0, circle, 0); " % (x, y))
288            fh.write("ctx.fill(); ctx.stroke(); "
289                     "ctx.strokeText('%s', %d, %d);\n" %
290                     (s.PRN, x - 6 + offset, y + 4))
291
292        fh.write("""
293    ctx.restore();
294}
295""")
296
297    def make_stale(self):
298        for t in self.sattrack.values():
299            if t.stale:
300                t.stale -= 1
301
302    def delete_stale(self):
303        stales = []
304        for prn in self.sattrack.keys():
305            if self.sattrack[prn].stale == 0:
306                stales.append(prn)
307                self.needsupdate = 1
308        for prn in stales:
309            del self.sattrack[prn]
310
311    def insert_sat(self, prn, x, y):
312        try:
313            t = self.sattrack[prn]
314        except KeyError:
315            self.sattrack[prn] = t = Track(prn)
316        if t.add(x, y):
317            self.needsupdate = 1
318
319    def update_tracks(self):
320        self.make_stale()
321        for s in self.satellites:
322            x, y = polartocart(s.elevation, s.azimuth)
323            self.insert_sat(s.PRN, x, y)
324        self.delete_stale()
325
326    def generate_html(self, htmlfile, jsfile):
327        fh = open(htmlfile, 'w')
328        self.html(fh, jsfile)
329        fh.close()
330
331    def generate_js(self, jsfile):
332        fh = open(jsfile, 'w')
333        self.js(fh)
334        fh.close()
335
336    def run(self, suffix, period):
337        jsfile = 'gpsd' + suffix + '.js'
338        htmlfile = 'gpsd' + suffix + '.html'
339        if period is not None:
340            end = time.time() + period
341        self.needsupdate = 1
342        self.stream(WATCH_ENABLE | WATCH_NEWSTYLE)
343        for report in self:
344            if report['class'] not in ('TPV', 'SKY'):
345                continue
346            self.update_tracks()
347            if self.needsupdate:
348                self.generate_js(jsfile)
349                self.needsupdate = 0
350            self.generate_html(htmlfile, jsfile)
351            if period is not None and (
352                period <= 0 and self.fix.mode >= MODE_2D or
353                period > 0 and time.time() > end
354            ):
355                break
356
357
358def main():
359    argv = sys.argv[1:]
360
361    factors = {
362        's': 1, 'm': 60, 'h': 60 * 60, 'd': 24 * 60 * 60
363    }
364    arg = argv and argv[0] or '0'
365    if arg[-1:] in factors.keys():
366        period = int(arg[:-1]) * factors[arg[-1]]
367    elif arg == 'c':
368        period = None
369    else:
370        period = int(arg)
371    prefix = '-' + arg
372
373    sat = SatTracks()
374
375    # restore the tracks
376    pfile = 'tracks.p'
377    if os.path.isfile(pfile):
378        p = open(pfile, 'rb')
379        try:
380            sat.sattrack = pickle.load(p)
381        except ValueError:
382            print("Ignoring incompatible tracks file.", file=sys.stderr)
383        p.close()
384
385    try:
386        sat.run(prefix, period)
387    except KeyboardInterrupt:
388        # save the tracks
389        p = open(pfile, 'wb')
390        # No protocol is backward-compatible from Python 3 to Python 2,
391        # so we just use the default and punt at load time if needed.
392        pickle.dump(sat.sattrack, p)
393        p.close()
394
395
396if __name__ == '__main__':
397    main()
398