1#!/usr/bin/env python 2# -*- coding: utf-8 -*- 3""" 4skyview2svg -- Create an SVG image of GPS satellites sky view. 5 6Read from file or stdin the JSON data produced by gpsd, 7example usage: 8 9 gpspipe -w | skyview2svg > skyview.svg 10 11For GPSD JSON format see: https://gpsd.gitlab.io/gpsd/gpsd_json.html 12""" 13# This code runs compatibly under Python 2 and 3.x for x >= 2. 14# Preserve this property! 15# 16# This file is Copyright (c) 2010-2018 by the GPSD project 17# SPDX-License-Identifier: BSD-2-clause 18from __future__ import absolute_import, print_function, division 19 20import datetime 21import json 22import math 23import sys 24 25__author__ = "Niccolo Rigacci" 26__copyright__ = "Copyright 2018 Niccolo Rigacci <niccolo@rigacci.org>" 27__license__ = "BSD-2-clause" 28__email__ = "niccolo@rigacci.org" 29__version__ = "3.20" 30 31 32# ------------------------------------------------------------------------ 33# ------------------------------------------------------------------------ 34def polar2cart(azimuth, elevation, r_max): 35 """Convert polar coordinates in cartesian ones.""" 36 radius = r_max * (1 - elevation / 90.0) 37 theta = math.radians(float(azimuth - 90)) 38 return ( 39 int(radius * math.cos(theta) + 0.5), 40 int(radius * math.sin(theta) + 0.5) 41 ) 42 43 44# ------------------------------------------------------------------------ 45# ------------------------------------------------------------------------ 46def cutoff_err(err, err_min, err_max): 47 """Cut-off Estimated Error between min and max.""" 48 if err is None or err >= err_max: 49 return err_max, '>' 50 if err <= err_min: 51 return err_min, '<' 52 else: 53 return err, '' 54 55 56# ------------------------------------------------------------------------ 57# Read JSON data from file or stdin, search a {'class': 'SKY'} line. 58# ------------------------------------------------------------------------ 59EXIT_CODE = 0 60SKY = None 61TPV = None 62try: 63 if len(sys.argv) > 1: 64 with open(sys.argv[1]) as f: 65 while True: 66 SENTENCE = json.loads(f.readline()) 67 if 'class' in SENTENCE and SENTENCE['class'] == 'SKY': 68 SKY = SENTENCE 69 if 'class' in SENTENCE and SENTENCE['class'] == 'TPV': 70 TPV = SENTENCE 71 if SKY is not None and TPV is not None: 72 break 73 else: 74 while True: 75 SENTENCE = json.loads(sys.stdin.readline()) 76 if 'class' in SENTENCE and SENTENCE['class'] == 'SKY': 77 SKY = SENTENCE 78 if 'class' in SENTENCE and SENTENCE['class'] == 'TPV': 79 TPV = SENTENCE 80 if SKY is not None and TPV is not None: 81 sys.stdin.close() 82 break 83except (IOError, ValueError): 84 # Assume empty data and write msg to stderr. 85 EXIT_CODE = 100 86 sys.stderr.write("Error reading JSON data from file or stdin." 87 " Creating an empty or partial skyview image.\n") 88 89if SKY is None: 90 SKY = {} 91if TPV is None: 92 TPV = {} 93 94# ------------------------------------------------------------------------ 95# Colors for the SVG styles. 96# ------------------------------------------------------------------------ 97# Background and label colors. 98BACKGROUND_COLOR = '#323232' 99LBL_FONT_COLOR = 'white' 100FONT_FAMILY = 'Verdana,Arial,Helvetica,sans-serif' 101# Compass dial. 102COMPASS_STROKE_COLOR = '#9d9d9d' 103DIAL_POINT_COLOR = COMPASS_STROKE_COLOR 104# Satellites constellation. 105SAT_USED_FILL_COLOR = '#00ff00' 106SAT_UNUSED_FILL_COLOR = '#d0d0d0' 107SAT_USED_STROKE_COLOR = '#0b400b' 108SAT_UNUSED_STROKE_COLOR = '#101010' 109SAT_USED_TEXT_COLOR = '#000000' 110SAT_UNUSED_TEXT_COLOR = '#000000' 111# Sat signal/noise ratio box and bars. 112BARS_AREA_FILL_COLOR = '#646464' 113BARS_AREA_STROKE_COLOR = COMPASS_STROKE_COLOR 114BAR_USED_FILL_COLOR = '#00ff00' 115BAR_UNUSED_FILL_COLOR = '#ffffff' 116BAR_USED_STROKE_COLOR = '#324832' 117BAR_UNUSED_STROKE_COLOR = BACKGROUND_COLOR 118 119# ------------------------------------------------------------------------ 120# Size and position of elements. 121# ------------------------------------------------------------------------ 122IMG_WIDTH = 528 123IMG_HEIGHT = 800 124STROKE_WIDTH = int(IMG_WIDTH * 0.007) 125 126# Scale graph bars to accomodate at least MIN_SAT values. 127MIN_SAT = 12 128NUM_SAT = MIN_SAT 129 130# Auto-scale: reasonable values for Signal/Noise Ratio and Error. 131SNR_MAX = 30.0 # Do not autoscale below this value. 132# Auto-scale horizontal and vertical error, in meters. 133ERR_MIN = 5.0 134ERR_MAX = 75.0 135 136# Create an empty list, if satellites list is missing. 137if 'satellites' not in SKY.keys(): 138 SKY['satellites'] = [] 139 140if len(SKY['satellites']) < MIN_SAT: 141 NUM_SAT = MIN_SAT 142else: 143 NUM_SAT = len(SKY['satellites']) 144 145# Make a sortable array and autoscale SNR. 146SATELLITES = {} 147for sat in SKY['satellites']: 148 SATELLITES[sat['PRN']] = sat 149 if float(sat['ss']) > SNR_MAX: 150 SNR_MAX = float(sat['ss']) 151 152# Compass dial and satellites placeholders. 153CIRCLE_X = int(IMG_WIDTH * 0.50) 154CIRCLE_Y = int(IMG_WIDTH * 0.49) 155CIRCLE_R = int(IMG_HEIGHT * 0.22) 156SAT_WIDTH = int(CIRCLE_R * 0.24) 157SAT_HEIGHT = int(CIRCLE_R * 0.14) 158 159# GPS position. 160POS_LBL_X = int(IMG_WIDTH * 0.50) 161POS_LBL_Y = int(IMG_HEIGHT * 0.62) 162 163# Sat signal/noise ratio box and bars. 164BARS_BOX_WIDTH = int(IMG_WIDTH * 0.82) 165BARS_BOX_HEIGHT = int(IMG_HEIGHT * 0.14) 166BARS_BOX_X = int((IMG_WIDTH - BARS_BOX_WIDTH) * 0.5) 167BARS_BOX_Y = int(IMG_HEIGHT * 0.78) 168BAR_HEIGHT_MAX = int(BARS_BOX_HEIGHT * 0.72) 169BAR_SPACE = int((BARS_BOX_WIDTH - STROKE_WIDTH) / NUM_SAT) 170BAR_WIDTH = int(BAR_SPACE * 0.70) 171BAR_RADIUS = int(BAR_WIDTH * 0.20) 172 173# Error box and bars. 174ERR_BOX_X = int(IMG_WIDTH * 0.65) 175ERR_BOX_Y = int(IMG_HEIGHT * 0.94) 176ERR_BOX_WIDTH = int((BARS_BOX_X + BARS_BOX_WIDTH) - ERR_BOX_X) 177ERR_BOX_HEIGHT = BAR_SPACE * 2 178ERR_BAR_HEIGHT_MAX = int(ERR_BOX_WIDTH - STROKE_WIDTH*2) 179 180# Timestamp 181TIMESTAMP_X = int(IMG_WIDTH * 0.50) 182TIMESTAMP_Y = int(IMG_HEIGHT * 0.98) 183 184# Text labels. 185LBL_FONT_SIZE = int(IMG_WIDTH * 0.036) 186LBL_COMPASS_POINTS_SIZE = int(CIRCLE_R * 0.12) 187LBL_SAT_SIZE = int(SAT_HEIGHT * 0.75) 188LBL_SAT_BAR_SIZE = int(BAR_WIDTH * 0.90) 189 190# Get timestamp from GPS or system. 191if 'time' in SKY: 192 UTC = datetime.datetime.strptime(SKY['time'], '%Y-%m-%dT%H:%M:%S.%fZ') 193elif 'time' in TPV: 194 UTC = datetime.datetime.strptime(TPV['time'], '%Y-%m-%dT%H:%M:%S.%fZ') 195else: 196 UTC = datetime.datetime.utcnow() 197TIME_STR = UTC.strftime('%Y-%m-%d %H:%M:%S UTC') 198 199# ------------------------------------------------------------------------ 200# Output the SGV image. 201# ------------------------------------------------------------------------ 202print('''<?xml version="1.0" encoding="UTF-8" ?> 203<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" 204 "https://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> 205<svg 206 xmlns="https://www.w3.org/2000/svg" 207 width="%d" 208 height="%d">''' % (IMG_WIDTH, IMG_HEIGHT)) 209 210# NOTICE: librsvg v.2.40 has a bug with "chain" multiple class selectors: 211# it does not handle a selector like text.label.title and a 212# tag class="label title". 213print('<style type="text/css">') 214# Labels. 215print(' text ' 216 '{ font-family: Verdana,Arial,Helvetica,sans-serif; font-weight: bold;}') 217print(' text.label { fill: %s; font-size: %dpx; }' % 218 (LBL_FONT_COLOR, LBL_FONT_SIZE)) 219print(' text.label-title { font-size: %dpx; text-anchor: middle; }' % 220 (int(LBL_FONT_SIZE * 1.4),)) 221print(' text.label-prn { font-size: %dpx; text-anchor: end; }' % 222 (LBL_SAT_BAR_SIZE,)) 223print(' text.label-center { text-anchor: middle; }') 224print(' text.label-snr { text-anchor: start; }') 225print(' text.label-err { text-anchor: end; }') 226# Compass dial. 227print(' circle.compass ' 228 '{ stroke: %s; stroke-width: %d; fill-opacity: 0; }' % 229 (COMPASS_STROKE_COLOR, STROKE_WIDTH,)) 230print(' line.compass { stroke: %s; stroke-width: %d; }' % 231 (COMPASS_STROKE_COLOR, STROKE_WIDTH)) 232print(' text.compass ' 233 '{ fill: %s; font-size: %dpx; text-anchor: middle; }' % 234 (DIAL_POINT_COLOR, LBL_COMPASS_POINTS_SIZE)) 235# Satellites constellation. 236print(' rect.sats { stroke-width: %d; fill-opacity: 1.0; }' % 237 (STROKE_WIDTH,)) 238print(' rect.sats-used { stroke: %s; fill: %s; }' % 239 (SAT_USED_STROKE_COLOR, SAT_USED_FILL_COLOR)) 240print(' rect.sats-unused { stroke: %s; fill: %s; }' % 241 (SAT_UNUSED_STROKE_COLOR, SAT_UNUSED_FILL_COLOR)) 242print(' text.sats { font-size: %dpx; text-anchor: middle; }' % 243 (LBL_SAT_SIZE,)) 244print(' text.sats-used { fill: %s; }' % (SAT_USED_TEXT_COLOR,)) 245print(' text.sats-unused { fill: %s; }' % (SAT_UNUSED_TEXT_COLOR,)) 246# Box containing bars graph. 247print(' rect.box { fill: %s; stroke: %s; stroke-width: %d; }' % 248 (BARS_AREA_FILL_COLOR, BARS_AREA_STROKE_COLOR, STROKE_WIDTH)) 249# Graph bars. 250print(' rect.bars { stroke-width: %d; opacity: 1.0; }' % 251 (STROKE_WIDTH,)) 252print(' rect.bars-used { stroke: %s; fill: %s; }' % 253 (BAR_USED_STROKE_COLOR, BAR_USED_FILL_COLOR)) 254print(' rect.bars-unused { stroke: %s; fill: %s; }' % 255 (BAR_UNUSED_STROKE_COLOR, BAR_UNUSED_FILL_COLOR)) 256print('</style>') 257# Background and title. 258print('<rect width="100%%" height="100%%" fill="%s" />' % 259 (BACKGROUND_COLOR,)) 260print('<text class="label label-title" x="%d" y="%d">' 261 'Sky View of GPS Satellites</text>' % 262 (int(IMG_WIDTH * 0.5), int(LBL_FONT_SIZE * 1.5))) 263# Sky circle with cardinal points. 264print('<circle class="compass" cx="%d" cy="%d" r="%d" />' % 265 (CIRCLE_X, CIRCLE_Y, CIRCLE_R)) 266print('<circle class="compass" cx="%d" cy="%d" r="%d" />' % 267 (CIRCLE_X, CIRCLE_Y, int(CIRCLE_R / 2))) 268print('<line class="compass" x1="%d" y1="%d" x2="%d" y2="%d" />' % 269 (CIRCLE_X, CIRCLE_Y - CIRCLE_R, CIRCLE_X, CIRCLE_Y + CIRCLE_R)) 270print('<line class="compass" x1="%d" y1="%d" x2="%d" y2="%d" />' % 271 (CIRCLE_X - CIRCLE_R, CIRCLE_Y, CIRCLE_X + CIRCLE_R, CIRCLE_Y)) 272print('<text x="%d" y="%d" class="compass">%s</text>' % 273 (CIRCLE_X, CIRCLE_Y - CIRCLE_R - LBL_COMPASS_POINTS_SIZE, 'N')) 274print('<text x="%d" y="%d" class="compass">%s</text>' % 275 (CIRCLE_X, CIRCLE_Y + CIRCLE_R + LBL_COMPASS_POINTS_SIZE, 'S')) 276print('<text x="%d" y="%d" class="compass">%s</text>' % 277 (CIRCLE_X - CIRCLE_R - LBL_COMPASS_POINTS_SIZE, 278 CIRCLE_Y + int(LBL_COMPASS_POINTS_SIZE*0.4), 'W')) 279print('<text x="%d" y="%d" class="compass">%s</text>' % 280 (CIRCLE_X + CIRCLE_R + LBL_COMPASS_POINTS_SIZE, 281 CIRCLE_Y + int(LBL_COMPASS_POINTS_SIZE*0.4), 'E')) 282# Lat/lon. 283POS_LAT = "%.5f" % (float(TPV['lat']),) if 'lat' in TPV else 'Unknown' 284POS_LON = "%.5f" % (float(TPV['lon']),) if 'lon' in TPV else 'Unknown' 285print('<text class="label label-center" x="%d" y="%d">Lat/Lon: %s %s</text>' % 286 (POS_LBL_X, POS_LBL_Y, POS_LAT, POS_LON)) 287# Satellites signal/noise ratio box. 288print('<rect class="box" x="%d" y="%d" rx="%d" ry="%d" ' 289 'width="%d" height="%d" />' % 290 (BARS_BOX_X, BARS_BOX_Y - BARS_BOX_HEIGHT, BAR_RADIUS, 291 BAR_RADIUS, BARS_BOX_WIDTH, BARS_BOX_HEIGHT)) 292SS_LBL_X = int(BARS_BOX_X + STROKE_WIDTH * 1.5) 293SS_LBL_Y = int(BARS_BOX_Y - BARS_BOX_HEIGHT + LBL_FONT_SIZE + 294 STROKE_WIDTH * 1.5) 295print('<text class="label label-snr" x="%d" y="%d">' 296 'Satellites Signal/Noise Ratio</text>' % (SS_LBL_X, SS_LBL_Y)) 297# Box for horizontal and vertical estimated error. 298if 'epx' in TPV and 'epy' in TPV: 299 EPX = float(TPV['epx']) 300 EPY = float(TPV['epy']) 301 EPH = math.sqrt(EPX**2 + EPY**2) 302elif 'eph' in TPV: 303 EPH = float(TPV['eph']) 304else: 305 EPH = ERR_MAX 306EPV = float(TPV['epv']) if 'epv' in TPV else ERR_MAX 307ERR_H, SIGN_H = cutoff_err(EPH, ERR_MIN, ERR_MAX) 308ERR_V, SIGN_V = cutoff_err(EPV, ERR_MIN, ERR_MAX) 309ERR_LBL_X = int(ERR_BOX_X - STROKE_WIDTH * 2.0) 310ERR_LBL_Y_OFFSET = STROKE_WIDTH + BAR_WIDTH * 0.6 311print('<rect class="box" x="%d" y="%d" rx="%d" ry="%d" ' 312 'width="%d" height="%d" />' % 313 (ERR_BOX_X, ERR_BOX_Y - ERR_BOX_HEIGHT, BAR_RADIUS, 314 BAR_RADIUS, ERR_BOX_WIDTH, ERR_BOX_HEIGHT)) 315# Horizontal error. 316POS_X = ERR_BOX_X + STROKE_WIDTH 317POS_Y = ERR_BOX_Y - ERR_BOX_HEIGHT + int((BAR_SPACE - BAR_WIDTH) * 0.5) 318ERR_H_BAR_HEIGHT = int(ERR_H / ERR_MAX * ERR_BAR_HEIGHT_MAX) 319print('<text class="label label-err" x="%d" y="%d">' 320 'Horizontal error %s%.1f m</text>' % 321 (ERR_LBL_X, ERR_LBL_Y_OFFSET + POS_Y, SIGN_H, ERR_H)) 322print('<rect class="bars bars-used" x="%d" y="%d" rx="%d" ' 323 ' ry="%d" width="%d" height="%d" />' % 324 (POS_X, POS_Y, BAR_RADIUS, BAR_RADIUS, ERR_H_BAR_HEIGHT, BAR_WIDTH)) 325# Vertical error. 326POS_Y = POS_Y + BAR_SPACE 327ERR_V_BAR_HEIGHT = int(ERR_V / ERR_MAX * ERR_BAR_HEIGHT_MAX) 328print('<text class="label label-err" x="%d" y="%d">' 329 'Vertical error %s%.1f m</text>' % 330 (ERR_LBL_X, ERR_LBL_Y_OFFSET + POS_Y, SIGN_V, ERR_V)) 331print('<rect class="bars bars-used" x="%d" y="%d" rx="%d" ' 332 ' ry="%d" width="%d" height="%d" />' % 333 (POS_X, POS_Y, BAR_RADIUS, BAR_RADIUS, ERR_V_BAR_HEIGHT, BAR_WIDTH)) 334# Satellites and Signal/Noise bars. 335i = 0 336for prn in sorted(SATELLITES): 337 sat = SATELLITES[prn] 338 BAR_HEIGHT = int(BAR_HEIGHT_MAX * (float(sat['ss']) / SNR_MAX)) 339 (sat_x, sat_y) = polar2cart(float(sat['az']), float(sat['el']), CIRCLE_R) 340 sat_x = int(CIRCLE_X + sat_x) 341 sat_y = int(CIRCLE_Y + sat_y) 342 rect_radius = int(SAT_HEIGHT * 0.25) 343 sat_rect_x = int(sat_x - (SAT_WIDTH) / 2) 344 sat_rect_y = int(sat_y - (SAT_HEIGHT) / 2) 345 sat_class = 'used' if sat['used'] else 'unused' 346 print('<rect class="sats sats-%s" x="%d" y="%d" width="%d" ' 347 ' height="%d" rx="%d" ry="%d" />' % 348 (sat_class, sat_rect_x, sat_rect_y, SAT_WIDTH, SAT_HEIGHT, 349 rect_radius, rect_radius)) 350 print('<text class="sats %s" x="%d" y="%d">%s</text>' % 351 (sat_class, sat_x, sat_y + int(LBL_SAT_SIZE*0.4), sat['PRN'])) 352 pos_x = (int(BARS_BOX_X + (STROKE_WIDTH * 0.5) + 353 (BAR_SPACE - BAR_WIDTH) * 0.5 + BAR_SPACE * i)) 354 pos_y = int(BARS_BOX_Y - BAR_HEIGHT - (STROKE_WIDTH * 1.5)) 355 print('<rect class="bars bars-%s" x="%d" y="%d" rx="%d" ry="%d" ' 356 'width="%d" height="%d" />' % 357 (sat_class, pos_x, pos_y, BAR_RADIUS, BAR_RADIUS, 358 BAR_WIDTH, BAR_HEIGHT)) 359 x = int(pos_x + BAR_WIDTH * 0.5) 360 y = int(BARS_BOX_Y + (STROKE_WIDTH * 1.5)) 361 print('<text class="label label-prn" x="%d" y="%d" ' 362 'transform="rotate(270, %d, %d)">%s</text>' % 363 (x, y, x, y, sat['PRN'])) 364 i = i + 1 365print('<text class="label label-center" x="%d" y="%d">%s</text>' % 366 (TIMESTAMP_X, TIMESTAMP_Y, TIME_STR)) 367print('</svg>') 368 369sys.exit(EXIT_CODE) 370