3 xgps -- test client for gpsd
5 usage: xgps [-D level] [-hV?] [-l degmfmt] [-u units] [server[:port[:device]]]
9 This is xgps, a test client for the gpsd daemon.
11 By Eric S. Raymond for the GPSD project, December 2009
14 # This file is Copyright (c) 2010 by the GPSD project
15 # BSD terms apply: see the file COPYING in the distribution root for details.
17 import sys, os, re, math, time, exceptions, getopt, socket
23 import gps, gps.clienthelpers
25 class unit_adjustments:
26 "Encapsulate adjustments for unit systems."
27 def __init__(self, units=None):
28 self.altfactor = gps.METERS_TO_FEET
30 self.speedfactor = gps.MPS_TO_MPH
31 self.speedunits = "mph"
33 units = gps.clienthelpers.gpsd_units()
34 if units in (gps.clienthelpers.unspecified, gps.clienthelpers.imperial, "imperial", "i"):
36 elif units in (gps.clienthelpers.nautical, "nautical", "n"):
37 self.altfactor = gps.METERS_TO_FEET
39 self.speedfactor = gps.MPS_TO_KNOTS
40 self.speedunits = "knots"
41 elif units in (gps.clienthelpers.metric, "metric", "m"):
44 self.speedfactor = gps.MPS_TO_KPH
45 self.speedunits = "kph"
47 raise ValueError # Should never happen
49 class SkyView(gtk.DrawingArea):
50 "Satellite skyview, encapsulates pygtk's draw-on-expose behavior."
51 # See <http://faq.pygtk.org/index.py?req=show&file=faq18.008.htp>
52 HORIZON_PAD = 20 # How much whitespace to leave around horizon
53 SAT_RADIUS = 5 # Diameter of satellite circle
54 GPS_PRNMAX = 32 # above this number are SBAS satellites
56 gtk.DrawingArea.__init__(self)
57 self.set_size_request(400, 400)
58 self.gc = None # initialized in realize-event handler
59 self.width = 0 # updated in size-allocate handler
60 self.height = 0 # updated in size-allocate handler
61 self.connect('size-allocate', self.on_size_allocate)
62 self.connect('expose-event', self.on_expose_event)
63 self.connect('realize', self.on_realize)
64 self.pangolayout = self.create_pango_layout("")
67 def on_realize(self, widget):
68 self.gc = widget.window.new_gc()
69 self.gc.set_line_attributes(1, gtk.gdk.LINE_SOLID,
70 gtk.gdk.CAP_ROUND, gtk.gdk.JOIN_ROUND)
72 def on_size_allocate(self, widget, allocation):
73 self.width = allocation.width
74 self.height = allocation.height
75 self.diameter = min(self.width, self.height) - SkyView.HORIZON_PAD
77 def set_color(self, spec):
78 "Set foreground color for draweing."
79 self.gc.set_rgb_fg_color(gtk.gdk.color_parse(spec))
81 def draw_circle(self, widget, x, y, diam, filled=False):
82 "Draw a circle centered on the specified midpoint."
83 widget.window.draw_arc(self.gc, filled,
84 x - diam / 2, y - diam / 2,
85 diam, diam, 0, 360 * 64)
87 def draw_line(self, widget, x1, y1, x2, y2):
88 "Draw a line between specified points."
89 widget.window.draw_lines(self.gc, [(x1, y1), (x2, y2)])
91 def draw_square(self, widget, x, y, diam, filled=False):
92 "Draw a square centered on the specified midpoint."
93 widget.window.draw_rectangle(self.gc, filled,
94 x - diam / 2, y - diam / 2,
97 def draw_string(self, widget, x, y, letter, centered=True):
98 "Draw a letter on the skyview."
99 self.pangolayout.set_text(letter)
101 (w, h) = self.pangolayout.get_pixel_size()
104 self.window.draw_layout(self.gc, x, y, self.pangolayout)
106 def pol2cart(self, az, el):
107 "Polar to Cartesian coordinates within the horizon circle."
108 az *= (math.pi/180) # Degrees to radians
109 # Exact spherical projection would be like this:
110 # el = sin((90.0 - el) * DEG_2_RAD);
111 el = ((90.0 - el) / 90.0);
112 xout = int((self.width / 2) + math.sin(az) * el * (self.diameter / 2))
113 yout = int((self.height / 2) - math.cos(az) * el * (self.diameter / 2))
116 def on_expose_event(self, widget, event):
117 self.set_color("white")
118 widget.window.draw_rectangle(self.gc, True, 0,0, self.width,self.height)
120 self.set_color("gray")
121 self.draw_circle(widget, self.width / 2, self.height / 2, 6)
122 # The circle corresponding to 45 degrees elevation.
123 # There are two ways we could plot this. Projecting the sphere
124 # on the display plane, the circle would have a diameter of
125 # sin(45) ~ 0.7. But the naive linear mapping, just splitting
126 # the horizon diameter in half, seems to work better visually.
127 self.draw_circle(widget, self.width / 2, self.height / 2,
128 int(self.diameter * 0.5))
129 self.set_color("black")
131 self.draw_circle(widget, self.width / 2, self.height / 2,
133 self.set_color("gray")
134 (x1, y1) = self.pol2cart(0, 0)
135 (x2, y2) = self.pol2cart(180, 0)
136 self.draw_line(widget, x1, y1, x2, y2)
137 (x1, y1) = self.pol2cart(90, 0)
138 (x2, y2) = self.pol2cart(270, 0)
139 self.draw_line(widget, x1, y1, x2, y2)
140 # The compass-point letters
141 self.set_color("black")
142 (x, y) = self.pol2cart(0, 0)
143 self.draw_string(widget, x, y+10, "N")
144 (x, y) = self.pol2cart(90, 0)
145 self.draw_string(widget, x-10, y, "E")
146 (x, y) = self.pol2cart(180, 0)
147 self.draw_string(widget, x, y-10, "S")
148 (x, y) = self.pol2cart(270, 0)
149 self.draw_string(widget, x+10, y, "W")
151 for sat in self.satellites:
152 (x, y) = self.pol2cart(sat.az, sat.el)
154 self.set_color("Black")
156 self.set_color("Red")
158 self.set_color("Yellow");
160 self.set_color("Green3");
162 self.set_color("Green1");
163 if sat.PRN > SkyView.GPS_PRNMAX:
164 self.draw_square(widget,
165 x-SkyView.SAT_RADIUS, y-SkyView.SAT_RADIUS,
166 2 * SkyView.SAT_RADIUS + 1, sat.used);
168 self.draw_circle(widget,
169 x-SkyView.SAT_RADIUS, y-SkyView.SAT_RADIUS,
170 2 * SkyView.SAT_RADIUS + 1, sat.used);
171 self.set_color("Black")
172 self.draw_string(widget, x, y, str(sat.PRN), centered=False)
173 def redraw(self, satellites):
174 "Redraw the skyview."
175 self.satellites = satellites
179 "Encapsulate store and view objects for watching AIS data."
182 def __init__(self, deg_type):
183 "Initialize the store and view."
184 self.deg_type = deg_type
185 self.name_to_mmsi = {}
187 self.store = gtk.ListStore(str,str,str,str,str,str)
188 self.widget = gtk.ScrolledWindow()
189 self.widget.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
190 self.view = gtk.TreeView(model=self.store)
191 self.widget.set_size_request(-1, 300)
192 self.widget.add_with_viewport(self.view)
194 for (i, label) in enumerate(('#', 'Name:','Callsign:','Destination:', "Lat/Lon:", "Information")):
195 column = gtk.TreeViewColumn(label)
196 renderer = gtk.CellRendererText()
197 column.pack_start(renderer)
198 column.add_attribute(renderer, 'text', i)
199 self.view.append_column(column)
201 def enter(self, ais, name):
202 "Add a named object (ship or station) to the store."
203 if ais.mmsi in self.named:
206 ais.entry_time = time.time()
207 self.named[ais.mmsi] = ais
208 self.name_to_mmsi[name] = ais.mmsi
209 # Garbage-collect old entries
211 for i in range(len(self.store)):
212 here = self.store.get_iter(i)
213 name = self.store.get_value(here, 1)
214 mmsi = self.name_to_mmsi[name]
215 if self.named[mmsi].entry_time < time.time() - AISView.DWELLTIME:
217 if name in self.name_to_mmsi:
218 del self.name_to_mmsi[name]
219 self.store.remove(here)
220 except (ValueError, KeyError): # Invalid TreeIters throw these
224 def latlon(self, lat, lon):
225 "Latitude/longitude display in nice format."
233 lat = gps.clienthelpers.deg_to_str(self.deg_type, lat)
241 lon = gps.clienthelpers.deg_to_str(gps.clienthelpers.deg_ddmmss, lon)
242 return lat + latsuff + "/" + lon + lonsuff
244 def update(self, ais):
245 "Update the AIS data fields."
246 if ais.type in (1, 2, 3, 18):
247 if ais.mmsi in self.named:
248 for i in range(len(self.store)):
249 here = self.store.get_iter(i)
250 name = self.store.get_value(here, 1)
251 if name in self.name_to_mmsi:
252 mmsi = self.name_to_mmsi[name]
254 latlon = self.latlon(ais.lat, ais.lon)
255 self.store.set_value(here, 4, latlon)
257 if self.enter(ais, ais.mmsi):
258 where = self.latlon(ais.lat, ais.lon)
260 (ais.type, ais.mmsi, "(shore)", ais.timestamp, where, ais.epfd))
262 if self.enter(ais, ais.shipname):
264 (ais.type, ais.shipname, ais.callsign, ais.destination, "", ais.shiptype))
267 if sender in self.named:
268 sender = self.named[sender].shipname
269 recipient = ais.dest_mmsi
270 if recipient in self.named and hasattr(self.named[recipient], "shipname"):
271 recipient = self.named[recipient].shipname
273 (ais.type, sender, "", recipient, "", ais.text))
276 if sender in self.named:
277 sender = self.named[sender].shipname
279 (ais.type, sender, "", "(broadcast)", "", ais.text))
280 elif ais.type in (19, 24):
281 if self.enter(ais, ais.shipname):
283 (ais.type, ais.shipname, "(class B)", "", "", ais.shiptype))
285 if self.enter(ais, ais.name):
286 where = self.latlon(ais.lat, ais.lon)
288 (ais.type, ais.name, "(%s navaid)" % ais.epfd, "", where, ais.aid_type))
293 ("Time", lambda s, r: s.update_time(r)),
294 ("Latitude", lambda s, r: s.update_latitude(r)),
295 ("Longitude", lambda s, r: s.update_longitude(r)),
296 ("Altitude", lambda s, r: s.update_altitude(r)),
297 ("Speed", lambda s, r: s.update_speed(r)),
298 ("Climb", lambda s, r: s.update_climb(r)),
299 ("Track", lambda s, r: s.update_track(r)),
301 ("Status", lambda s, r: s.update_status(r)),
302 ("EPX", lambda s, r: s.update_err(r, "epx")),
303 ("EPY", lambda s, r: s.update_err(r, "epy")),
304 ("EPV", lambda s, r: s.update_err(r, "epv")),
305 ("EPS", lambda s, r: s.update_err(r, "eps")),
306 ("EPC", lambda s, r: s.update_err(r, "epc")),
307 ("EPD", lambda s, r: s.update_err(r, "epd")),
309 def __init__(self, deg_type):
310 self.deg_type = deg_type
311 self.conversions = unit_adjustments()
313 self.ais_latch = False
315 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
316 self.window.set_title("xgps")
317 self.window.connect("delete_event", self.delete_event)
318 self.window.set_resizable(False)
320 vbox = gtk.VBox(False, 0)
321 self.window.add(vbox)
323 self.window.connect("destroy", lambda w: gtk.main_quit())
325 self.uimanager = gtk.UIManager()
326 self.accelgroup = self.uimanager.get_accel_group()
327 self.window.add_accel_group(self.accelgroup)
328 self.actiongroup = gtk.ActionGroup('xgps')
329 self.actiongroup.add_actions(
330 [('Quit', gtk.STOCK_QUIT, '_Quit', None,
331 'Quit the Program', lambda w: gtk.main_quit()),
332 ('File', None, '_File'),
333 ('View', None, '_View'),
334 ('Units', None, '_Units')])
335 self.actiongroup.add_toggle_actions(
336 [('Skyview', None, '_Skyview', '<Control>s',
337 'Enable Skyview', lambda a: self.view_toggle(a)),
338 ('Responses', None, '_Responses', '<Control>r',
339 'Enable Response Reports', lambda a: self.view_toggle(a)),
340 ('GPS', None, '_GPS Data', '<Control>g',
341 'Enable GPS Data', lambda a: self.view_toggle(a)),
342 ('AIS', None, '_AIS Data', '<Control>a',
343 'Enable AIS Data', lambda a: self.view_toggle(a)),
345 self.actiongroup.add_radio_actions(
346 [('Imperial', None, '_Imperial', '<Control>i',
347 'Imperial units', 0),
348 ('Nautical', None, '_Nautical', '<Control>n',
349 'Nautical units', 1),
350 ('Metric', None, '_Metric', '<Control>m',
352 ], 0, lambda a, v: self.set_units(['i', 'n', 'm'][a.get_current_value()]))
353 self.uimanager.insert_action_group(self.actiongroup, 0)
354 self.uimanager.add_ui_from_string('''
356 <menubar name="MenuBar">
358 <menuitem action="Quit"/>
361 <menuitem action="Skyview"/>
362 <menuitem action="Responses"/>
363 <menuitem action="GPS"/>
364 <menuitem action="AIS"/>
366 <menu action="Units">
367 <menuitem action="Imperial"/>
368 <menuitem action="Nautical"/>
369 <menuitem action="Metric"/>
374 self.uimanager.get_widget('/MenuBar/View/Skyview').set_active(True)
375 self.uimanager.get_widget('/MenuBar/View/Responses').set_active(True)
376 self.uimanager.get_widget('/MenuBar/View/GPS').set_active(True)
377 self.uimanager.get_widget('/MenuBar/View/AIS').set_active(True)
378 menubar = self.uimanager.get_widget('/MenuBar')
379 vbox.pack_start(menubar, False)
381 self.satbox = gtk.HBox(False, 0)
382 vbox.add(self.satbox)
384 skyframe = gtk.Frame(label="Satellite List")
385 self.satbox.add(skyframe)
387 self.satlist = gtk.ListStore(str,str,str,str,str)
388 view = gtk.TreeView(model=self.satlist)
390 for (i, label) in enumerate(('PRN:','Elev:','Azim:','SNR:','Used:')):
391 column = gtk.TreeViewColumn(label)
392 renderer = gtk.CellRendererText()
393 column.pack_start(renderer)
394 column.add_attribute(renderer, 'text', i)
395 view.append_column(column)
398 for i in range(gps.MAXCHANNELS):
399 self.satlist.append(["", "", "", "", ""])
400 self.row_iters.append(self.satlist.get_iter(i))
404 viewframe = gtk.Frame(label="Skyview")
405 self.satbox.add(viewframe)
406 self.skyview = SkyView()
407 viewframe.add(self.skyview)
409 self.rawdisplay = gtk.Entry()
410 self.rawdisplay.set_editable(False)
411 vbox.add(self.rawdisplay)
413 self.dataframe = gtk.Frame(label="GPS data")
414 datatable = gtk.Table(7, 4, False)
415 self.dataframe.add(datatable)
417 for i in range(len(Base.gpsfields)):
418 if i < len(Base.gpsfields) / 2:
422 label = gtk.Label(Base.gpsfields[i][0] + ": ")
423 # Wacky way to force right alignment
424 label.set_alignment(xalign=1, yalign=0.5)
425 datatable.attach(label, colbase, colbase+1, i % 7, i % 7 + 1)
427 datatable.attach(entry, colbase+1, colbase+2, i % 7, i % 7 + 1)
428 gpswidgets.append(entry)
429 vbox.add(self.dataframe)
431 self.aisbox = gtk.HBox(False, 0)
432 vbox.add(self.aisbox)
434 aisframe = gtk.Frame(label="AIS Data")
435 self.aisbox.add(aisframe)
437 self.aisview = AISView(self.deg_type)
438 aisframe.add(self.aisview.widget)
440 self.window.show_all()
441 # Hide the AIS window util user selects it.
442 self.uimanager.get_widget('/MenuBar/View/AIS').set_active(False)
445 self.view_name_to_widget = \
446 {"Skyview": self.satbox,
447 "Responses": self.rawdisplay,
448 "GPS": self.dataframe,
451 # Discard field labels and associate data hooks with their widgets
452 Base.gpsfields = map(lambda ((label, hook), widget): (hook, widget),
453 zip(Base.gpsfields, gpswidgets))
455 def view_toggle(self, action):
456 #print "View toggle:", action.get_active(), action.get_name()
457 if hasattr(self, 'view_name_to_widget'):
458 if action.get_active():
459 self.view_name_to_widget[action.get_name()].show()
461 self.view_name_to_widget[action.get_name()].hide()
462 # The effect we're after is to make the top-level window
463 # resize itself to fit when we show or hide widgets.
464 # This is undocumented magic to do that.
465 self.window.resize(1, 1)
467 def set_satlist_field(self, row, column, value):
468 "Set a specified field in the satellite list."
470 self.satlist.set_value(self.row_iters[row], column, value)
472 sys.stderr.write("xgps: channel = %d, MAXCHANNELS = %d\n" % (row, gps.MAXCHANNELS))
474 def delete_event(self, widget, event, data=None):
480 def update_time(self, data):
481 if hasattr(data, "time"):
482 return gps.isotime(data.time)
486 def update_latitude(self, data):
487 if data.mode >= gps.MODE_2D:
488 lat = gps.clienthelpers.deg_to_str(self.deg_type, abs(data.lat))
493 return "%s %s" % (lat, ns)
497 def update_longitude(self, data):
498 if data.mode >= gps.MODE_2D:
499 lon = gps.clienthelpers.deg_to_str(self.deg_type, abs(data.lon))
504 return "%s %s" % (lon, ew)
508 def update_altitude(self, data):
509 if data.mode >= gps.MODE_3D:
511 data.alt * self.conversions.altfactor,
512 self.conversions.altunits)
516 def update_speed(self, data):
517 if hasattr(data, "speed"):
519 data.speed * self.conversions.speedfactor,
520 self.conversions.speedunits)
524 def update_climb(self, data):
525 if hasattr(data, "climb"):
527 data.climb * self.conversions.speedfactor,
528 self.conversions.speedunits)
532 def update_track(self, data):
533 if hasattr(data, "track"):
534 return gps.clienthelpers.deg_to_str(self.deg_type, abs(data.track))
538 def update_err(self, data, errtype):
539 if hasattr(data, errtype):
541 getattr(data, errtype) * self.conversions.altfactor,
542 self.conversions.altunits)
546 def update_status(self, data):
547 if data.mode == gps.MODE_2D:
549 elif data.mode == gps.MODE_3D:
553 if data.mode != self.saved_mode:
554 self.last_transition = time.time()
555 self.saved_mode = data.mode
556 return status + " (%d secs)" % (time.time() - self.last_transition)
558 def update_gpsdata(self, tpv):
559 "Update the GPS data fields."
560 for (hook, widget) in Base.gpsfields:
561 if hook: # Remove this guard when we have all hooks
562 widget.set_text(hook(self, tpv))
564 def update_skyview(self, data):
565 "Update the satellite list and skyview."
566 satellites = data.satellites
567 for (i, satellite) in enumerate(satellites):
568 self.set_satlist_field(i, 0, satellite.PRN)
569 self.set_satlist_field(i, 1, satellite.el)
570 self.set_satlist_field(i, 2, satellite.az)
571 self.set_satlist_field(i, 3, satellite.ss)
575 self.set_satlist_field(i, 4, yesno)
576 for i in range(len(satellites), gps.MAXCHANNELS):
577 for j in range(0, 5):
578 self.set_satlist_field(i, j, "")
579 self.skyview.redraw(satellites)
583 def set_units(self, system):
584 "Change the display units."
585 self.conversions = unit_adjustments(system)
587 # I/O monitoring and gtk housekeeping
589 def watch(self, daemon, device):
590 "Set up monitoring of a daemon instance."
593 gobject.io_add_watch(daemon.sock, gobject.IO_IN, self.handle_response)
594 gobject.io_add_watch(daemon.sock, gobject.IO_ERR, self.handle_hangup)
595 gobject.io_add_watch(daemon.sock, gobject.IO_HUP, self.handle_hangup)
597 def handle_response(self, source, condition):
598 "Handle ordinary I/O ready condition from the daemon."
599 if self.daemon.poll() == -1:
600 self.handle_hangup(source, condition)
601 if self.daemon.valid & gps.PACKET_SET:
602 if self.device and self.device != self.daemon.data["device"]:
604 self.rawdisplay.set_text(self.daemon.response.strip())
605 if self.daemon.data["class"] == "SKY":
606 self.update_skyview(self.daemon.data)
607 elif self.daemon.data["class"] == "TPV":
608 self.update_gpsdata(self.daemon.data)
609 elif self.daemon.data["class"] == "AIS":
610 self.aisview.update(self.daemon.data)
611 if self.ais_latch == False:
612 self.ais_latch = True
613 self.uimanager.get_widget('/MenuBar/View/AIS').set_active(True)
618 def handle_hangup(self, source, condition):
619 "Handle hangup condition from the daemon."
620 w = gtk.MessageDialog(type=gtk.MESSAGE_ERROR,
621 flags=gtk.DIALOG_DESTROY_WITH_PARENT,
622 buttons=gtk.BUTTONS_CANCEL)
623 w.connect("destroy", lambda w: gtk.main_quit())
624 w.set_markup("gpsd has stopped sending data.")
632 if __name__ == "__main__":
633 (options, arguments) = getopt.getopt(sys.argv[1:], "D:hl:u:V?",
638 for (opt, val) in options:
645 elif opt in ('-?', '-h', '--help'):
649 sys.stderr.write("xgps 1.0\n")
652 degreefmt = {'d':gps.clienthelpers.deg_dd,
653 'm':gps.clienthelpers.deg_ddmm,
654 's':gps.clienthelpers.deg_ddmmss}[degreefmt]
656 (host, port, device) = ("localhost", "2947", None)
658 args = arguments[0].split(":")
666 base = Base(deg_type=degreefmt)
667 base.set_units(unit_system)
669 daemon = gps.gps(host=host,
671 mode=gps.WATCH_ENABLE|gps.WATCH_JSON|gps.WATCH_SCALED,
673 base.watch(daemon, device)
676 w = gtk.MessageDialog(type=gtk.MESSAGE_ERROR,
677 flags=gtk.DIALOG_DESTROY_WITH_PARENT,
678 buttons=gtk.BUTTONS_CANCEL)
679 w.set_markup("gpsd is not running.")