#!/usr/bin/env python # # by # Robin Wittler (speedometer mode) # and # Chen Wei (nautical mode) # # BSD terms apply: see the file COPYING in the distribution root for details. import pygtk pygtk.require('2.0') import gtk import cairo import gobject import gps from math import pi from math import cos from math import sin from math import sqrt from math import radians from socket import error as SocketError class Speedometer(gtk.DrawingArea): def __init__(self, speed_unit=None): gtk.DrawingArea.__init__(self) self.MPH_UNIT_LABEL = 'mph' self.KPH_UNIT_LABEL = 'kmh' self.KNOTS_UNIT_LABEL = 'knots' self.conversions = { self.MPH_UNIT_LABEL: gps.MPS_TO_MPH, self.KPH_UNIT_LABEL: gps.MPS_TO_KPH, self.KNOTS_UNIT_LABEL: gps.MPS_TO_KNOTS } self.speed_unit = speed_unit or self.MPH_UNIT_LABEL if not self.speed_unit in self.conversions: raise TypeError( '%s is not a valid speed unit' %(repr(speed_unit)) ) class LandSpeedometer(Speedometer): def __init__(self, speed_unit=None): Speedometer.__init__(self, speed_unit) self.connect('expose_event', self.expose_event) self.long_ticks = (2, 1, 0, -1, -2, -3, -4, -5, -6, -7, -8) self.short_ticks = (0.1, 0.2, 0.3, 0.4, 0.6, 0.7, 0.8, 0.9) self.long_inset = lambda x: 0.1 * x self.middle_inset = lambda x: self.long_inset(x) / 1.5 self.short_inset = lambda x: self.long_inset(x) / 3 self.res_div = 10.0 self.res_div_mul = 1 self.last_speed = 0 self.nums = { -8: 0, -7: 10, -6: 20, -5: 30, -4: 40, -3: 50, -2: 60, -1: 70, 0: 80, 1: 90, 2: 100 } def expose_event(self, unused, event, empty=None): self.cr = self.window.cairo_create() self.cr.rectangle( event.area.x, event.area.y, event.area.width, event.area.height ) self.cr.clip() x, y = self.get_x_y() width, height = self.window.get_size() radius = self.get_radius(width, height) self.cr.set_line_width(radius / 100) self.draw_arc_and_ticks(width, height, radius, x, y) self.draw_needle(self.last_speed, radius, x, y) self.draw_speed_text(self.last_speed, radius, x, y) def draw_arc_and_ticks(self, width, height, radius, x, y): self.cr.set_source_rgb(1.0, 1.0, 1.0) self.cr.rectangle(0, 0, width, height) self.cr.fill() self.cr.set_source_rgb(0.0, 0.0, 0.0) #draw the speedometer arc self.cr.arc_negative(x, y, radius, radians(60), radians(120)) self.cr.stroke() long_inset = self.long_inset(radius) middle_inset = self.middle_inset(radius) short_inset = self.short_inset(radius) #draw the ticks for i in self.long_ticks: self.cr.move_to( x + (radius - long_inset) * cos(i * pi / 6.0), y + (radius - long_inset) * sin(i * pi / 6.0) ) self.cr.line_to( x + (radius + (self.cr.get_line_width() / 2)) * cos(i * pi / 6.0), y + (radius + (self.cr.get_line_width() / 2)) * sin(i * pi / 6.0) ) self.cr.select_font_face( 'Georgia', cairo.FONT_SLANT_NORMAL, ) self.cr.set_font_size(radius / 10) self.cr.save() _num = str(self.nums.get(i) * self.res_div_mul) ( x_bearing, y_bearing, t_width, t_height, x_advance, y_advance ) = self.cr.text_extents(_num) if i in (-8, -7, -6, -5, -4): self.cr.move_to( (x + (radius - long_inset - (t_width / 2)) * cos(i * pi / 6.0)), (y + (radius - long_inset - (t_height * 2)) * sin(i * pi / 6.0)) ) elif i in (-2, -1, 0, 2, 1): self.cr.move_to( (x + (radius - long_inset - (t_width * 1.5 )) * cos(i * pi / 6.0)), (y + (radius - long_inset - (t_height * 2 )) * sin(i * pi / 6.0)) ) elif i in (-3,): self.cr.move_to( (x - t_width / 2), (y - radius + self.long_inset(radius) * 2 + t_height) ) self.cr.show_text(_num) self.cr.restore() if i != self.long_ticks[0]: self.cr.move_to( x + (radius - middle_inset) * cos((i + 0.5) * pi / 6.0), y + (radius - middle_inset) * sin((i + 0.5) * pi / 6.0) ) self.cr.line_to( x + (radius + (self.cr.get_line_width() / 2)) * cos((i + 0.5) * pi / 6.0), y + (radius + (self.cr.get_line_width() / 2)) * sin((i + 0.5) * pi / 6.0) ) for z in self.short_ticks: if i < 0: self.cr.move_to( x + (radius - short_inset) * cos((i + z) * pi / 6.0), y + (radius - short_inset) * sin((i + z) * pi / 6.0) ) self.cr.line_to( x + (radius + (self.cr.get_line_width() / 2)) * cos((i + z) * pi / 6.0), y + (radius + (self.cr.get_line_width() / 2)) * sin((i + z) * pi / 6.0) ) else: self.cr.move_to( x + (radius - short_inset) * cos((i - z) * pi / 6.0), y + (radius - short_inset) * sin((i - z) * pi / 6.0) ) self.cr.line_to( x + (radius + (self.cr.get_line_width() / 2)) * cos((i - z) * pi / 6.0), y + (radius + (self.cr.get_line_width() / 2)) * sin((i - z) * pi / 6.0) ) self.cr.stroke() def draw_needle(self, speed, radius, x, y): self.cr.save() inset = self.long_inset(radius) speed = speed * self.conversions.get(self.speed_unit) speed = speed / (self.res_div * self.res_div_mul) actual = self.long_ticks[-1] + speed if actual > self.long_ticks[0]: #TODO test this in real conditions! ;) self.res_div_mul += 1 speed = speed / (self.res_div * self.res_div_mul) actual = self.long_ticks[-1] + speed self.cr.move_to(x, y) self.cr.line_to( x + (radius - (2 * inset)) * cos(actual * pi / 6.0), y + (radius - (2 * inset)) * sin(actual * pi / 6.0) ) self.cr.stroke() self.cr.restore() def draw_speed_text(self, speed, radius, x, y): self.cr.save() speed = '%.2f %s' %( speed * self.conversions.get(self.speed_unit), self.speed_unit ) self.cr.select_font_face( 'Georgia', cairo.FONT_SLANT_NORMAL, #cairo.FONT_WEIGHT_BOLD ) self.cr.set_font_size(radius / 10) x_bearing, y_bearing, t_width, t_height = self.cr.text_extents(speed)[:4] self.cr.move_to((x - t_width / 2), (y + radius) - self.long_inset(radius)) self.cr.show_text(speed) self.cr.restore() def get_x_y(self): rect = self.get_allocation() x = (rect.x + rect.width / 2.0) y = (rect.y + rect.height / 2.0) - 20 return x, y def get_radius(self, width, height): return min(width / 2.0, height / 2.0) - 20 class NauticalSpeedometer(Speedometer): HEADING_SAT_GAP = 0.8 GPS_PRNMAX = 32 # Above this number are SBAS satellites SAT_SIZE = 10 # radius of the satellite circle in skyview def __init__(self, speed_unit=None, maxspeed=100): Speedometer.__init__(self, speed_unit) self.connect('expose_event', self.expose_event) self.long_inset = lambda x: 0.05 * x self.mid_inset = lambda x: self.long_inset(x) / 1.5 self.short_inset = lambda x: self.long_inset(x) / 3 self.last_speed = 0 self.satellites = [] self.last_heading = 0 self.maxspeed = int(maxspeed) @staticmethod def polar2xy(radius, angle, polex, poley): '''convert Polar coordinate to Cartesian coordinate system the y axis in pygtk points downward Args: radius: angle: azimuth from from Polar coordinate system, in radian polex and poley are the Cartesian coordinate of the pole return a tuple contains (x, y)''' return (polex + cos(angle) * radius, poley - sin(angle) * radius) def expose_event(self, unused, event, empty=None): self.cr = self.window.cairo_create() self.cr.rectangle( event.area.x, event.area.y, event.area.width, event.area.height ) self.cr.clip() x, y = self.get_x_y() width, height = self.window.get_size() radius = self.get_radius(width, height) self.cr.set_line_width(radius / 100) self.draw_arc_and_ticks(width, height, radius, x, y) self.draw_heading(20, self.last_heading, radius, x, y) for sat in self.satellites: self.draw_sat(sat, radius * NauticalSpeedometer.HEADING_SAT_GAP, x, y) self.draw_speed(radius, x, y) def draw_text(self, x, y, text, fontsize=10): '''draw text at given location Args: x, y is the center of textbox''' txt = str(text) self.cr.new_sub_path() self.cr.set_source_rgba(0, 0, 0) self.cr.select_font_face('Sans', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD) self.cr.set_font_size(fontsize) (x_bearing, y_bearing, t_width, t_height) = self.cr.text_extents(txt)[:4] # set the center of textbox self.cr.move_to(x - t_width / 2, y + t_height / 2) self.cr.show_text(txt) def draw_arc_and_ticks(self, width, height, radius, x, y): '''Draw a serial of circle, with ticks in outmost circle''' self.cr.set_source_rgb(1.0, 1.0, 1.0) self.cr.rectangle(0, 0, width, height) self.cr.fill() self.cr.set_source_rgba(0, 0, 0) # draw the speedmeter arc rspeed = radius + 50 self.cr.arc(x, y, rspeed, 2 * pi / 3, 7 * pi / 3) self.cr.set_source_rgba(0, 0, 0, 1.0) self.cr.stroke() s_long = self.long_inset(rspeed) s_middle = self.mid_inset(radius) s_short = self.short_inset(radius) for i in xrange(11): #draw the large ticks alpha = (8 - i) * pi / 6 self.cr.move_to(*NauticalSpeedometer.polar2xy(rspeed, alpha, x, y)) self.cr.set_line_width(radius / 100) self.cr.line_to(*NauticalSpeedometer.polar2xy(rspeed - s_long, alpha, x, y)) self.cr.stroke() self.cr.set_line_width(radius / 200) xf, yf = NauticalSpeedometer.polar2xy(rspeed + 10, alpha, x, y) stxt = (self.maxspeed / 10) * i self.draw_text(xf, yf, stxt, fontsize=radius / 15) for i in xrange(1, 11): # middle tick alpha = (8 - i) * pi / 6 beta = (17 - 2 * i) * pi / 12 self.cr.move_to(*NauticalSpeedometer.polar2xy(rspeed, beta, x, y)) self.cr.line_to(*NauticalSpeedometer.polar2xy(rspeed - s_middle, beta, x, y)) # short tick for n in xrange(10): gamma = alpha + n * pi / 60 self.cr.move_to(*NauticalSpeedometer.polar2xy(rspeed, gamma, x, y)) self.cr.line_to(*NauticalSpeedometer.polar2xy(rspeed - s_short, gamma, x, y)) #draw the heading arc self.cr.new_sub_path() self.cr.arc(x, y, radius, 0, 2 * pi) self.cr.stroke() self.cr.arc(x, y, radius - 20, 0, 2 * pi) self.cr.set_source_rgba(0, 0, 0, 0.20) self.cr.fill() self.cr.set_source_rgba(0, 0, 0) # heading label 90/180/270 for n in xrange(0, 4): label = str(n * 90) #self.cr.set_source_rgba(0, 1, 0) #radius * (1 + NauticalSpeedometer.HEADING_SAT_GAP), tbox_x, tbox_y = NauticalSpeedometer.polar2xy(radius * 0.88, (1 - n) * pi / 2, x, y) self.draw_text(tbox_x, tbox_y, label, fontsize=radius / 20) # draw the satellite arcs skyradius = radius * NauticalSpeedometer.HEADING_SAT_GAP self.cr.set_line_width(radius / 200) self.cr.set_source_rgba(0, 0, 0) self.cr.arc(x, y, skyradius, 0, 2 * pi) self.cr.set_source_rgba(1, 1, 1) self.cr.fill() self.cr.set_source_rgba(0, 0, 0) self.cr.arc(x, y, skyradius * 2 / 3, 0, 2 * pi) self.cr.arc(x, y, skyradius / 3, 0, 2 * pi) # draw the cross hair self.cr.move_to(x - skyradius, y) self.cr.line_to(x + skyradius, y) self.cr.move_to(x, y - skyradius) self.cr.line_to(x, y + skyradius) self.cr.set_line_width(radius / 200) self.cr.stroke() long_inset = self.long_inset(radius) mid_inset = self.mid_inset(radius) short_inset = self.short_inset(radius) #draw the large ticks for i in xrange(12): agllong = i * pi / 6 self.cr.move_to(*NauticalSpeedometer.polar2xy(radius - long_inset, agllong, x, y)) self.cr.line_to(*NauticalSpeedometer.polar2xy(radius, agllong, x, y)) self.cr.set_line_width(radius / 100) self.cr.stroke() self.cr.set_line_width(radius / 200) # middle tick aglmid = (i + 0.5) * pi / 6 self.cr.move_to(*NauticalSpeedometer.polar2xy(radius - mid_inset, aglmid, x, y)) self.cr.line_to(*NauticalSpeedometer.polar2xy(radius, aglmid, x, y)) # short tick for n in xrange(1, 10): aglshrt = agllong + n * pi / 60 self.cr.move_to(*NauticalSpeedometer.polar2xy(radius - short_inset, aglshrt, x, y)) self.cr.line_to(*NauticalSpeedometer.polar2xy(radius, aglshrt, x, y)) self.cr.stroke() def draw_heading(self, trig_height, heading, radius, x, y): hypo = trig_height * 2 / sqrt(3) h = pi / 2 - radians(heading) # to xyz self.cr.set_line_width(2) self.cr.set_source_rgba(0, 0.3, 0.2, 0.8) # the triangle pointer x0 = x + radius * cos(h) y0 = y - radius * sin(h) x1 = x0 + hypo * cos(7 * pi / 6 + h) y1 = y0 - hypo * sin(7 * pi / 6 + h) x2 = x0 + hypo * cos(5 * pi / 6 + h) y2 = y0 - hypo * sin(5 * pi / 6 + h) self.cr.move_to(x0, y0) self.cr.line_to(x1, y1) self.cr.line_to(x2, y2) self.cr.line_to(x0, y0) self.cr.close_path() self.cr.fill() self.cr.stroke() # heading text (tbox_x, tbox_y) = NauticalSpeedometer.polar2xy(radius * 1.1, h, x, y) self.draw_text(tbox_x, tbox_y, int(heading), fontsize=radius / 15) # the ship shape, based on test and try shiplen = radius * NauticalSpeedometer.HEADING_SAT_GAP / 4 xh, yh = NauticalSpeedometer.polar2xy(shiplen * 2.3, h, x, y) xa, ya = NauticalSpeedometer.polar2xy(shiplen * 2.2, h + pi - 0.3, x, y) xb, yb = NauticalSpeedometer.polar2xy(shiplen * 2.2, h + pi + 0.3, x, y) xc, yc = NauticalSpeedometer.polar2xy(shiplen * 1.4, h - pi / 5, x, y) xd, yd = NauticalSpeedometer.polar2xy(shiplen * 1.4, h + pi / 5, x, y) self.cr.set_source_rgba(0, 0.3, 0.2, 0.5) self.cr.move_to(xa, ya) self.cr.line_to(xb, yb) self.cr.line_to(xc, yc) self.cr.line_to(xh, yh) self.cr.line_to(xd, yd) self.cr.close_path() self.cr.fill() #self.cr.stroke() def set_color(self, spec): '''Set foreground color for drawing.''' gdkcolor = gtk.gdk.color_parse(spec) r = gdkcolor.red / 65535.0 g = gdkcolor.green / 65535.0 b = gdkcolor.blue / 65535.0 self.cr.set_source_rgb(r, g, b) def draw_sat(self, satsoup, radius, x, y): """given a sat's elevation, azimath, SNR, draw it on the skyview Arg: satsoup: a dictionary {'el': xx, 'az': xx, 'ss': xx} """ h = pi / 2 - radians(satsoup['az']) # to xy self.cr.set_line_width(2) self.cr.set_source_rgb(0, 0, 0) x0, y0 = NauticalSpeedometer.polar2xy(radius * (90 - satsoup['el']) / 90, h, x, y) self.cr.new_sub_path() if satsoup['PRN'] > NauticalSpeedometer.GPS_PRNMAX: self.cr.rectangle(x0 - NauticalSpeedometer.SAT_SIZE, y0 - NauticalSpeedometer.SAT_SIZE, NauticalSpeedometer.SAT_SIZE * 2, NauticalSpeedometer.SAT_SIZE * 2) else: self.cr.arc(x0, y0, NauticalSpeedometer.SAT_SIZE, 0, pi * 2.0) if satsoup['ss'] < 10: self.set_color('Gray') elif satsoup['ss'] < 30: self.set_color('Red') elif satsoup['ss'] < 35: self.set_color('Yellow') elif satsoup['ss'] < 40: self.set_color('Green3') else: self.set_color('Green1') if satsoup['used']: self.cr.fill() else: self.cr.stroke() self.draw_text(x0, y0, satsoup['PRN'], fontsize=15) def draw_speed(self, radius, x, y): self.cr.new_sub_path() self.cr.set_line_width(20) self.cr.set_source_rgba(0, 0, 0, 0.5) speed = self.last_speed * self.conversions.get(self.speed_unit) # cariol arc angle start at polar 0, going clockwise alpha = 4 * pi / 3 beta = 2 * pi - alpha theta = 5 * pi * speed / (self.maxspeed * 3) self.cr.arc(x, y, radius + 40, beta, beta + theta) self.cr.stroke() #self.cr.close_path() #self.cr.fill() label = '%.2f %s' % (speed, self.speed_unit) self.draw_text(x, y + radius + 40, label, fontsize=20) def get_x_y(self): rect = self.get_allocation() x = (rect.x + rect.width / 2.0) y = (rect.y + rect.height / 2.0) - 20 return x, y def get_radius(self, width, height): return min(width / 2.0, height / 2.0) - 70 class Main(object): def __init__(self, host='localhost', port='2947', device=None, debug=0, speed_unit=None, maxspeed=0, nautical=False): self.host = host self.port = port self.device = device self.debug = debug self.speed_unit = speed_unit self.maxspeed = maxspeed self.nautical = nautical self.window = gtk.Window(gtk.WINDOW_TOPLEVEL) if not self.window.get_display(): raise Exception("Can't open display") self.window.set_title('xgpsspeed') if self.nautical: self.window.set_size_request(500, 550) self.widget = NauticalSpeedometer(speed_unit=self.speed_unit, maxspeed=self.maxspeed) else: self.widget = LandSpeedometer(speed_unit=self.speed_unit) self.window.connect('delete_event', self.delete_event) self.window.connect('destroy', self.destroy) self.widget.show() vbox = gtk.VBox(False, 0) self.window.add(vbox) self.window.present() self.uimanager = gtk.UIManager() self.accelgroup = self.uimanager.get_accel_group() self.window.add_accel_group(self.accelgroup) self.actiongroup = gtk.ActionGroup('gpsspeed-ng') self.actiongroup.add_actions( [('Quit', gtk.STOCK_QUIT, '_Quit', None, 'Quit the Program', lambda unused: gtk.main_quit()), ('File', None, '_File'), ('Units', None, '_Units')] ) self.actiongroup.add_radio_actions( [('Imperial', None, '_Imperial', 'i', 'Imperial Units', 0), ('Metric', None, '_Metric', 'm', 'Metrical Units', 1), ('Nautical', None, '_Nautical', 'n', 'Nautical Units', 2) ], 0, lambda a, unused: setattr(self.widget, 'speed_unit', ['mph', 'kmh', 'knots'][a.get_current_value()]) ) self.uimanager.insert_action_group(self.actiongroup, 0) self.uimanager.add_ui_from_string(''' ''') self.active_unit_map = { 'mph': '/MenuBar/Units/Imperial', 'kmh': '/MenuBar/Units/Metric', 'knots': '/MenuBar/Units/Nautical' } menubar = self.uimanager.get_widget('/MenuBar') self.uimanager.get_widget( self.active_unit_map.get(self.speed_unit) ).set_active(True) vbox.pack_start(menubar, False, False, 0) vbox.add(self.widget) self.window.show_all() def watch(self, daemon, device): self.daemon = daemon self.device = device gobject.io_add_watch(daemon.sock, gobject.IO_IN, self.handle_response) gobject.io_add_watch(daemon.sock, gobject.IO_ERR, self.handle_hangup) gobject.io_add_watch(daemon.sock, gobject.IO_HUP, self.handle_hangup) return True def handle_response(self, source, condition): if self.daemon.read() == -1: self.handle_hangup(source, condition) if self.daemon.data['class'] == 'TPV': self.update_speed(self.daemon.data) if self.nautical and self.daemon.data['class'] == 'SKY': self.update_skyview(self.daemon.data) return True def handle_hangup(self, dummy, unused): w = gtk.MessageDialog( type=gtk.MESSAGE_ERROR, flags=gtk.DIALOG_DESTROY_WITH_PARENT, buttons=gtk.BUTTONS_OK ) w.connect("destroy", lambda unused: gtk.main_quit()) w.set_title('gpsd error') w.set_markup("gpsd has stopped sending data.") w.run() gtk.main_quit() return True def update_speed(self, data): if hasattr(data, 'speed'): self.widget.last_speed = data.speed self.widget.queue_draw() if self.nautical and hasattr(data, 'track'): self.widget.last_heading = data.track self.widget.queue_draw() # Used for NauticalSpeedometer only def update_skyview(self, data): "Update the satellite list and skyview." if hasattr(data, 'satellites'): self.widget.satellites = data.satellites self.widget.queue_draw() def delete_event(self, widget, event, data=None): #TODO handle all cleanup operations here return False def destroy(self, unused, empty=None): gtk.main_quit() def run(self): try: daemon = gps.gps( host=self.host, port=self.port, mode=gps.WATCH_ENABLE | gps.WATCH_JSON | gps.WATCH_SCALED, verbose=self.debug ) self.watch(daemon, self.device) gtk.main() except SocketError: w = gtk.MessageDialog( type=gtk.MESSAGE_ERROR, flags=gtk.DIALOG_DESTROY_WITH_PARENT, buttons=gtk.BUTTONS_OK ) w.set_title('socket error') w.set_markup( "could not connect to gpsd socket. make sure gpsd is running." ) w.run() w.destroy() except KeyboardInterrupt: self.window.emit('delete_event', gtk.gdk.Event(gtk.gdk.NOTHING)) if __name__ == '__main__': import sys from os.path import basename from optparse import OptionParser prog = basename(sys.argv[0]) usage = ('%s [-V|--version] [-h|--help] [--debug] [--host] ' + '[--port] [--device] [--speedunits {[mph] [kmh] [knots]}] ' + '[host [:port [:device]]]') %(prog) epilog = 'BSD terms apply: see the file COPYING in the distribution root for details.' parser = OptionParser(usage=usage, epilog=epilog) parser.add_option( '--host', dest='host', default='localhost', help='The host to connect. [Default localhost]' ) parser.add_option( '--port', dest='port', default='2947', help='The port to connect. [Default 2947]' ) parser.add_option( '--device', dest='device', default=None, help='The device to connet. [Default None]' ) parser.add_option( '--speedunits', dest='speedunits', default='mph', help='The unit of speed. Possible units are: mph, kmh, knots. [Default mph]' ) parser.add_option( '--maxspeed', dest='maxspeed', default='50', help='max speed of the speedmeter [Default 50]' ) parser.add_option( '--nautical', dest='nautical', default=False, action='store_true', help='Enable nautical-style speed and track display.' ) parser.add_option( '--debug', dest='debug', default=0, action='store', type='int', help='Set level of debug. Must be integer. [Default 0]' ) (options, args) = parser.parse_args() if args: arg = args[0].split(':') len_arg = len(arg) if len_arg == 1: (options.host,) = arg elif len_arg == 2: (options.host, options.port) = arg elif len_arg == 3: (options.host, options.port, options.device) = arg else: parser.print_help() sys.exit(0) Main( host=options.host, port=options.port, device=options.device, speed_unit=options.speedunits, maxspeed=options.maxspeed, nautical=options.nautical, debug=options.debug ).run()