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 <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