#!/usr/bin/env python # -*- coding: utf-8 -*- """ skyview2svg -- Create an SVG image of GPS satellites sky view. Read from file or stdin the JSON data produced by gpsd, example usage: gpspipe -w | skyview2svg > skyview.svg For GPSD JSON format see: http://www.catb.org/gpsd/gpsd_json.html """ # This code runs compatibly under Python 2 and 3.x for x >= 2. # Preserve this property! # # This file is Copyright (c) 2010-2018 by the GPSD project # SPDX-License-Identifier: BSD-2-clause from __future__ import absolute_import, print_function, division import datetime import json import math import sys __author__ = "Niccolo Rigacci" __copyright__ = "Copyright 2018 Niccolo Rigacci " __license__ = "BSD-2-clause" __email__ = "niccolo@rigacci.org" __version__ = "3.19-dev" # ------------------------------------------------------------------------ # ------------------------------------------------------------------------ def polar2cart(azimuth, elevation, r_max): """Convert polar coordinates in cartesian ones.""" radius = r_max * (1 - elevation / 90.0) theta = math.radians(float(azimuth - 90)) return ( int(radius * math.cos(theta) + 0.5), int(radius * math.sin(theta) + 0.5) ) # ------------------------------------------------------------------------ # ------------------------------------------------------------------------ def cutoff_err(err, err_min, err_max): """Cut-off Estimated Error between min and max.""" if err is None or err >= err_max: return err_max, '>' if err <= err_min: return err_min, '<' else: return err, '' # ------------------------------------------------------------------------ # Read JSON data from file or stdin, search a {'class': 'SKY'} line. # ------------------------------------------------------------------------ EXIT_CODE = 0 SKY = None TPV = None try: if len(sys.argv) > 1: with open(sys.argv[1]) as f: while True: SENTENCE = json.loads(f.readline()) if 'class' in SENTENCE and SENTENCE['class'] == 'SKY': SKY = SENTENCE if 'class' in SENTENCE and SENTENCE['class'] == 'TPV': TPV = SENTENCE if SKY is not None and TPV is not None: break else: while True: SENTENCE = json.loads(sys.stdin.readline()) if 'class' in SENTENCE and SENTENCE['class'] == 'SKY': SKY = SENTENCE if 'class' in SENTENCE and SENTENCE['class'] == 'TPV': TPV = SENTENCE if SKY is not None and TPV is not None: sys.stdin.close() break except (IOError, ValueError): # Assume empty data and write msg to stderr. EXIT_CODE = 100 sys.stderr.write("Error reading JSON data from file or stdin." " Creating an empty or partial skyview image.\n") if SKY is None: SKY = {} if TPV is None: TPV = {} # ------------------------------------------------------------------------ # Colors for the SVG styles. # ------------------------------------------------------------------------ # Background and label colors. BACKGROUND_COLOR = '#323232' LBL_FONT_COLOR = 'white' FONT_FAMILY = 'Verdana,Arial,Helvetica,sans-serif' # Compass dial. COMPASS_STROKE_COLOR = '#9d9d9d' DIAL_POINT_COLOR = COMPASS_STROKE_COLOR # Satellites constellation. SAT_USED_FILL_COLOR = '#00ff00' SAT_UNUSED_FILL_COLOR = '#d0d0d0' SAT_USED_STROKE_COLOR = '#0b400b' SAT_UNUSED_STROKE_COLOR = '#101010' SAT_USED_TEXT_COLOR = '#000000' SAT_UNUSED_TEXT_COLOR = '#000000' # Sat signal/noise ratio box and bars. BARS_AREA_FILL_COLOR = '#646464' BARS_AREA_STROKE_COLOR = COMPASS_STROKE_COLOR BAR_USED_FILL_COLOR = '#00ff00' BAR_UNUSED_FILL_COLOR = '#ffffff' BAR_USED_STROKE_COLOR = '#324832' BAR_UNUSED_STROKE_COLOR = BACKGROUND_COLOR # ------------------------------------------------------------------------ # Size and position of elements. # ------------------------------------------------------------------------ IMG_WIDTH = 528 IMG_HEIGHT = 800 STROKE_WIDTH = int(IMG_WIDTH * 0.007) # Scale graph bars to accomodate at least MIN_SAT values. MIN_SAT = 12 NUM_SAT = MIN_SAT # Auto-scale: reasonable values for Signal/Noise Ratio and Error. SNR_MAX = 30.0 # Do not autoscale below this value. # Auto-scale horizontal and vertical error, in meters. ERR_MIN = 5.0 ERR_MAX = 75.0 # Create an empty list, if satellites list is missing. if 'satellites' not in SKY.keys(): SKY['satellites'] = [] if len(SKY['satellites']) < MIN_SAT: NUM_SAT = MIN_SAT else: NUM_SAT = len(SKY['satellites']) # Make a sortable array and autoscale SNR. SATELLITES = {} for sat in SKY['satellites']: SATELLITES[sat['PRN']] = sat if float(sat['ss']) > SNR_MAX: SNR_MAX = float(sat['ss']) # Compass dial and satellites placeholders. CIRCLE_X = int(IMG_WIDTH * 0.50) CIRCLE_Y = int(IMG_WIDTH * 0.49) CIRCLE_R = int(IMG_HEIGHT * 0.22) SAT_WIDTH = int(CIRCLE_R * 0.24) SAT_HEIGHT = int(CIRCLE_R * 0.14) # GPS position. POS_LBL_X = int(IMG_WIDTH * 0.50) POS_LBL_Y = int(IMG_HEIGHT * 0.62) # Sat signal/noise ratio box and bars. BARS_BOX_WIDTH = int(IMG_WIDTH * 0.82) BARS_BOX_HEIGHT = int(IMG_HEIGHT * 0.14) BARS_BOX_X = int((IMG_WIDTH - BARS_BOX_WIDTH) * 0.5) BARS_BOX_Y = int(IMG_HEIGHT * 0.78) BAR_HEIGHT_MAX = int(BARS_BOX_HEIGHT * 0.72) BAR_SPACE = int((BARS_BOX_WIDTH - STROKE_WIDTH) / NUM_SAT) BAR_WIDTH = int(BAR_SPACE * 0.70) BAR_RADIUS = int(BAR_WIDTH * 0.20) # Error box and bars. ERR_BOX_X = int(IMG_WIDTH * 0.65) ERR_BOX_Y = int(IMG_HEIGHT * 0.94) ERR_BOX_WIDTH = int((BARS_BOX_X + BARS_BOX_WIDTH) - ERR_BOX_X) ERR_BOX_HEIGHT = BAR_SPACE * 2 ERR_BAR_HEIGHT_MAX = int(ERR_BOX_WIDTH - STROKE_WIDTH*2) # Timestamp TIMESTAMP_X = int(IMG_WIDTH * 0.50) TIMESTAMP_Y = int(IMG_HEIGHT * 0.98) # Text labels. LBL_FONT_SIZE = int(IMG_WIDTH * 0.036) LBL_COMPASS_POINTS_SIZE = int(CIRCLE_R * 0.12) LBL_SAT_SIZE = int(SAT_HEIGHT * 0.75) LBL_SAT_BAR_SIZE = int(BAR_WIDTH * 0.90) # Get timestamp from GPS or system. if 'time' in SKY: UTC = datetime.datetime.strptime(SKY['time'], '%Y-%m-%dT%H:%M:%S.%fZ') elif 'time' in TPV: UTC = datetime.datetime.strptime(TPV['time'], '%Y-%m-%dT%H:%M:%S.%fZ') else: UTC = datetime.datetime.utcnow() TIME_STR = UTC.strftime('%Y-%m-%d %H:%M:%S UTC') # ------------------------------------------------------------------------ # Output the SGV image. # ------------------------------------------------------------------------ print(''' ''' % (IMG_WIDTH, IMG_HEIGHT)) # NOTICE: librsvg v.2.40 has a bug with "chain" multiple class selectors: # it does not handle a selector like text.label.title and a # tag class="label title". print('') # Background and title. print('' % (BACKGROUND_COLOR,)) print('' 'Sky View of GPS Satellites' % (int(IMG_WIDTH * 0.5), int(LBL_FONT_SIZE * 1.5))) # Sky circle with cardinal points. print('' % (CIRCLE_X, CIRCLE_Y, CIRCLE_R)) print('' % (CIRCLE_X, CIRCLE_Y, int(CIRCLE_R / 2))) print('' % (CIRCLE_X, CIRCLE_Y - CIRCLE_R, CIRCLE_X, CIRCLE_Y + CIRCLE_R)) print('' % (CIRCLE_X - CIRCLE_R, CIRCLE_Y, CIRCLE_X + CIRCLE_R, CIRCLE_Y)) print('%s' % (CIRCLE_X, CIRCLE_Y - CIRCLE_R - LBL_COMPASS_POINTS_SIZE, 'N')) print('%s' % (CIRCLE_X, CIRCLE_Y + CIRCLE_R + LBL_COMPASS_POINTS_SIZE, 'S')) print('%s' % (CIRCLE_X - CIRCLE_R - LBL_COMPASS_POINTS_SIZE, CIRCLE_Y + int(LBL_COMPASS_POINTS_SIZE*0.4), 'W')) print('%s' % (CIRCLE_X + CIRCLE_R + LBL_COMPASS_POINTS_SIZE, CIRCLE_Y + int(LBL_COMPASS_POINTS_SIZE*0.4), 'E')) # Lat/lon. POS_LAT = "%.5f" % (float(TPV['lat']),) if 'lat' in TPV else 'Unknown' POS_LON = "%.5f" % (float(TPV['lon']),) if 'lon' in TPV else 'Unknown' print('Lat/Lon: %s %s' % (POS_LBL_X, POS_LBL_Y, POS_LAT, POS_LON)) # Satellites signal/noise ratio box. print('' % (BARS_BOX_X, BARS_BOX_Y - BARS_BOX_HEIGHT, BAR_RADIUS, BAR_RADIUS, BARS_BOX_WIDTH, BARS_BOX_HEIGHT)) SS_LBL_X = int(BARS_BOX_X + STROKE_WIDTH * 1.5) SS_LBL_Y = int(BARS_BOX_Y - BARS_BOX_HEIGHT + LBL_FONT_SIZE + STROKE_WIDTH * 1.5) print('' 'Satellites Signal/Noise Ratio' % (SS_LBL_X, SS_LBL_Y)) # Box for horizontal and vertical estimated error. if 'epx' in TPV and 'epy' in TPV: EPX = float(TPV['epx']) EPY = float(TPV['epy']) EPH = math.sqrt(EPX**2 + EPY**2) elif 'eph' in TPV: EPH = float(TPV['eph']) else: EPH = ERR_MAX EPV = float(TPV['epv']) if 'epv' in TPV else ERR_MAX ERR_H, SIGN_H = cutoff_err(EPH, ERR_MIN, ERR_MAX) ERR_V, SIGN_V = cutoff_err(EPV, ERR_MIN, ERR_MAX) ERR_LBL_X = int(ERR_BOX_X - STROKE_WIDTH * 2.0) ERR_LBL_Y_OFFSET = STROKE_WIDTH + BAR_WIDTH * 0.6 print('' % (ERR_BOX_X, ERR_BOX_Y - ERR_BOX_HEIGHT, BAR_RADIUS, BAR_RADIUS, ERR_BOX_WIDTH, ERR_BOX_HEIGHT)) # Horizontal error. POS_X = ERR_BOX_X + STROKE_WIDTH POS_Y = ERR_BOX_Y - ERR_BOX_HEIGHT + int((BAR_SPACE - BAR_WIDTH) * 0.5) ERR_H_BAR_HEIGHT = int(ERR_H / ERR_MAX * ERR_BAR_HEIGHT_MAX) print('' 'Horizontal error %s%.1f m' % (ERR_LBL_X, ERR_LBL_Y_OFFSET + POS_Y, SIGN_H, ERR_H)) print('' % (POS_X, POS_Y, BAR_RADIUS, BAR_RADIUS, ERR_H_BAR_HEIGHT, BAR_WIDTH)) # Vertical error. POS_Y = POS_Y + BAR_SPACE ERR_V_BAR_HEIGHT = int(ERR_V / ERR_MAX * ERR_BAR_HEIGHT_MAX) print('' 'Vertical error %s%.1f m' % (ERR_LBL_X, ERR_LBL_Y_OFFSET + POS_Y, SIGN_V, ERR_V)) print('' % (POS_X, POS_Y, BAR_RADIUS, BAR_RADIUS, ERR_V_BAR_HEIGHT, BAR_WIDTH)) # Satellites and Signal/Noise bars. i = 0 for prn in sorted(SATELLITES): sat = SATELLITES[prn] BAR_HEIGHT = int(BAR_HEIGHT_MAX * (float(sat['ss']) / SNR_MAX)) (sat_x, sat_y) = polar2cart(float(sat['az']), float(sat['el']), CIRCLE_R) sat_x = int(CIRCLE_X + sat_x) sat_y = int(CIRCLE_Y + sat_y) rect_radius = int(SAT_HEIGHT * 0.25) sat_rect_x = int(sat_x - (SAT_WIDTH) / 2) sat_rect_y = int(sat_y - (SAT_HEIGHT) / 2) sat_class = 'used' if sat['used'] else 'unused' print('' % (sat_class, sat_rect_x, sat_rect_y, SAT_WIDTH, SAT_HEIGHT, rect_radius, rect_radius)) print('%s' % (sat_class, sat_x, sat_y + int(LBL_SAT_SIZE*0.4), sat['PRN'])) pos_x = (int(BARS_BOX_X + (STROKE_WIDTH * 0.5) + (BAR_SPACE - BAR_WIDTH) * 0.5 + BAR_SPACE * i)) pos_y = int(BARS_BOX_Y - BAR_HEIGHT - (STROKE_WIDTH * 1.5)) print('' % (sat_class, pos_x, pos_y, BAR_RADIUS, BAR_RADIUS, BAR_WIDTH, BAR_HEIGHT)) x = int(pos_x + BAR_WIDTH * 0.5) y = int(BARS_BOX_Y + (STROKE_WIDTH * 1.5)) print('%s' % (x, y, x, y, sat['PRN'])) i = i + 1 print('%s' % (TIMESTAMP_X, TIMESTAMP_Y, TIME_STR)) print('') sys.exit(EXIT_CODE)