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, '&gt;'
50    if err <= err_min:
51        return err_min, '&lt;'
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