#!/usr/local/bin/python3.12
# -*- coding: UTF-8
# This code is generated by scons.  Do not hand-hack it!
"""xgps -- test client for gpsd.

Options can be placed in the XGPSOPTS environment variable.
XGPSOPTS is processed before the CLI options.
"""

# ENVIRONMENT:
#    Options in the XGPSOPTS environment variable will be parsed before
#    the CLI options.  A handy place to put your '-l m -u m '
#
# This file is Copyright 2010 by the GPSD project
# SPDX-License-Identifier: BSD-2-clause
#
# This code runs compatibly under Python 2 and 3.x for x >= 2.
# Preserve this property!
# Codacy D203 and D211 conflict, I choose D203
# Codacy D203 and D211 conflict, I choose D203
from __future__ import absolute_import, print_function, division

import argparse
import cairo
import math
import os
import socket
import sys
import time


# Gtk3 imports.  Gtk3 requires the require_version(), which then causes
# pylint to complain about the subsequent "non-top" imports.
# On gentoo these are from the dev-python/pygobject package.
# "Python bindings for GObject Introspection"
# It looks like PyGTK, but it is not.  PyGTK is unmaintained.

try:
    import gi
    gi.require_version('Gtk', '3.0')

except ImportError as err:
    # ModuleNotFoundError needs Python 3.6
    sys.stderr.write("xgps: ERROR %s.  "
                     "Probably missing package pygobject.\n" % err)
    sys.exit(1)

except ValueError as err:
    # Gtk2 may be installed, has no require_version()
    sys.stderr.write("xgps: ERROR %s\n" % err)
    sys.exit(1)

from gi.repository import Gtk        # pylint: disable=wrong-import-position
from gi.repository import Gdk        # pylint: disable=wrong-import-position
from gi.repository import GdkPixbuf  # pylint: disable=wrong-import-position
from gi.repository import GLib       # pylint: disable=wrong-import-position

# pylint wants local modules last
try:
    import gps
    import gps.clienthelpers
except ImportError as e:
    sys.stderr.write(
        "xgps: can't load Python gps libraries -- check PYTHONPATH.\n")
    sys.stderr.write("%s\n" % e)
    sys.exit(1)


gps_version = '3.25'
if gps.__version__ != gps_version:
    sys.stderr.write("xgps: ERROR: need gps module version %s, got %s\n" %
                     (gps_version, gps.__version__))
    sys.exit(1)


# MAXCHANNELS, from gps.h, currently 120
MAXCHANNELS = 120
# MAXCHANDISP, default max channels to display
MAXCHANDISP = 28

# Each GNSS constellation reuses the same PRNs.  To differentiate they are
# all mushed into the PRN.  Different GPS mush differently.  gpsd should
# have untangled and put in gnssid:svid


def gnssid_str(sat):
    """Convert gnssid:svid to short and long strings."""
    # gnssid:svid appeared in gpsd 3.18
    # allow for old servers
    if 'gnssid' not in sat or 'svid' not in sat:
        return '  '

    if 0 >= sat.svid:
        return ['  ', '']
    if 0 == sat.gnssid:
        return ['GP', 'GPS']
    if 1 == sat.gnssid:
        return ['SB', 'SBAS']
    if 2 == sat.gnssid:
        return ['GA', 'Galileo']
    if 3 == sat.gnssid:
        return ['BD', 'BeiDou']
    if 4 == sat.gnssid:
        return ['IM', 'IMES']
    if 5 == sat.gnssid:
        return ['QZ', 'QZSS']
    if 6 == sat.gnssid:
        return ['GL', 'GLONASS']
    if 7 == sat.gnssid:
        return ['IR', 'IRNSS']

    return '  '


def fit_to_grid(x, y, line_width):
    """Adjust coordinates to produce sharp lines."""
    if 0 != line_width % 1.0:
        # Can't have sharp lines for non-integral line widths.
        return float(x), float(y)  # Be consistent about returning floats
    if 0 == line_width % 2:
        # Round to a pixel corner.
        return round(x), round(y)

    # Round to a pixel center.
    return int(x) + 0.5, int(y) + 0.5


def fit_circle_to_grid(x, y, radius, line_width):
    """Fit cicrle to grid.

Adjust circle coordinates and radius to produce sharp horizontal
and vertical tangents.
"""
    r = radius
    x1, y1 = fit_to_grid(x - r, y - r, line_width)
    x2, y2 = fit_to_grid(x + r, y + r, line_width)
    x, y = (x1 + x2) / 2, (y1 + y2) / 2
    r = (x2 - x1 + y2 - y1) / 4
    return x, y, r


class SkyView(Gtk.DrawingArea):

    """Satellite skyview, encapsulates pygtk's draw-on-expose behavior."""
    # See <http://faq.pygtk.org/index.py?req=show&file=faq18.008.htp>
    HORIZON_PAD = 50    # How much whitespace to leave around horizon
    SAT_RADIUS = 5      # Diameter of satellite circle

    def __init__(self, rotation=None):
        """Initialize class SkyView."""
        Gtk.DrawingArea.__init__(self)
        # GObject.GObject.__init__(self)
        self.set_size_request(400, 400)
        self.cr = None   # New cairo context for each expose event
        self.step_of_grid = 45  # default step of polar grid
        self.connect('size-allocate', self.on_size_allocate)
        self.connect('draw', self.on_draw)
        self.satellites = []
        self.sat_xy = []
        self.center_x = self.center_y = self.radius = None
        self.rotate = rotation
        if self.rotate is None:
            self.rotate = 0
        self.connect('motion_notify_event', self.popup)
        self.popover = None
        self.pop_xy = (None, None)

    def popdown(self):
        """See if need to popdown the sat details."""
        if self.popover:
            self.popover.popdown()
            self.popover = None
            self.pop_xy = (None, None)

    def popup(self, skyview, event):
        """See if need to popup the sat details."""
        for (x, y, sat) in self.sat_xy:
            if ((SkyView.SAT_RADIUS >= abs(x - event.x) and
                 SkyView.SAT_RADIUS >= abs(y - event.y))):
                # got a sat match under the mouse
                # print((x, y))
                if ((self.pop_xy[0] and self.pop_xy[1] and
                     (int(x), int(y)) == self.pop_xy)):
                    # popup already up here, ignore event
                    # print("(%d, %d)" % (x, y))
                    return

                if self.popover:
                    # remove any old, no longer current popup
                    # this never happens?
                    self.popdown()

                # mouse is over a satellite, do popup
                self.pop_xy = (int(x), int(y))
                self.popover = Gtk.Popover()
                if "gnssid" in sat and "svid" in sat:
                    # gnssid:svid in gpsd 3.18 and up
                    constellation = gnssid_str(sat)[1]
                    gnss_str = "%-8s     %4d\n" % (constellation, sat.svid)
                else:
                    gnss_str = ''

                prres_str = ''
                sig_str = ''
                snr_str = ''
                qual_str = ''
                use_str = ''

                for (x1, y1, sat1) in self.sat_xy:
                    # find all instances of this sat

                    if ((sat1["gnssid"] != sat["gnssid"] or
                         sat1["svid"] != sat["svid"])):
                        # not this one
                        continue

                    if "prRes" in sat1:
                        prres = "%4.1f" % sat1.prRes
                    else:
                        prres = "    "
                    if prres_str:
                        prres_str += ", " + prres
                    else:
                        prres_str = prres

                    if "sigid" in sat1:
                        sig = "%4d" % sat1.sigid
                    else:
                        sig = "   0"
                    if sig_str:
                        sig_str += ", " + sig
                    else:
                        sig_str = sig

                    snr = "%4.1f" % sat1.ss
                    if snr_str:
                        snr_str += ", " + snr
                    else:
                        snr_str = snr

                    if "qual" in sat1:
                        qual = "%3d" % sat1.qual
                    else:
                        qual = "   "
                    if qual_str:
                        qual_str += ", " + qual
                    else:
                        qual_str = qual

                    use = "Yes" if sat.used else "No"
                    if use_str:
                        use_str += ", " + use
                    else:
                        use_str = use

                if 'health' not in sat:
                    health = "Unk"
                elif 1 == sat.health:
                    health = "OK"
                elif 2 == sat.health:
                    health = "Bad"
                else:
                    health = "Unk"

                label = Gtk.Label()
                s = ("<span font_desc='monospace 10'>PRN %13d\n"
                     "%s"
                     "Elevation    %4.1f\n"
                     "Azimuth     %5.1f\n"
                     "sigId  %9s\n"
                     "SNR   %11s\n"
                     "prRes %11s\n"
                     "Quality  %7s\n"
                     "Health  %9s\n"
                     "Used  %11s</span>" %
                     (sat.PRN, gnss_str, sat.el, sat.az, sig_str, snr_str,
                      prres_str, qual_str, health, use_str))
                label.set_markup(s)
                rectangle = Gdk.Rectangle()
                rectangle.x = x - 25
                rectangle.y = y - 25
                rectangle.width = 50
                rectangle.height = 50
                self.popover.set_modal(False)
                self.popover.set_relative_to(self)
                self.popover.set_position(Gtk.PositionType.TOP)
                self.popover.set_pointing_to(rectangle)
                self.popover.add(label)
                self.popover.popup()
                self.popover.show_all()
                # remove popup after 15 seconds
                GLib.timeout_add(15000, self.popdown)
                return

        if self.popover:
            # remove any old, no longer current popup
            # this never happens?
            self.popdown()

    def on_size_allocate(self, _unused, allocation):
        """Adjust SkyView on size change."""
        width = allocation.width
        height = allocation.height
        x = width // 2
        y = height // 2
        r = (min(width, height) - SkyView.HORIZON_PAD) // 2
        x, y, r = fit_circle_to_grid(x, y, r, 1)
        self.center_x = x
        self.center_y = y
        self.radius = r

    def set_color(self, r, g, b):
        """Set foreground color for drawing. rgb: 0 to 255."""
        # Gdk.color_parse() deprecated in GDK 3.14
        # gdkcolor = Gdk.color_parse(spec)
        r = r / 255.0
        g = g / 255.0
        b = b / 255.0
        self.cr.set_source_rgb(r, g, b)

    def draw_circle(self, x, y, radius, filled=False):
        """Draw a circle centered on the specified midpoint."""
        lw = self.cr.get_line_width()
        r = int(2 * radius + 0.5) // 2

        x, y, r = fit_circle_to_grid(x, y, radius, lw)

        self.cr.arc(x, y, r, 0, math.pi * 2.0)
        self.cr.close_path()

        if filled:
            self.cr.fill()
        else:
            self.cr.stroke()

    def draw_line(self, x1, y1, x2, y2):
        """Draw a line between specified points."""
        lw = self.cr.get_line_width()
        x1, y1 = fit_to_grid(x1, y1, lw)
        x2, y2 = fit_to_grid(x2, y2, lw)

        self.cr.move_to(x1, y1)
        self.cr.line_to(x2, y2)

        self.cr.stroke()

    def draw_square(self, x, y, radius, filled, flip):
        """Draw a square centered on the specified midpoint."""
        lw = self.cr.get_line_width()
        if 0 == flip:
            # square
            x1, y1 = fit_to_grid(x - radius, y - radius, lw)
            x2, y2 = fit_to_grid(x + radius, y + radius, lw)
            self.cr.rectangle(x1, y1, x2 - x1, y2 - y1)
        else:
            # diamond
            self.cr.move_to(x, y + radius + 1)   # top
            self.cr.line_to(x + radius - 1, y)  # right
            self.cr.line_to(x, y - radius - 1)   # bottom
            self.cr.line_to(x - radius + 1, y)   # left
            self.cr.close_path()

        if filled:
            self.cr.fill()
        else:
            self.cr.stroke()

    def draw_string(self, x, y, text, centered=True):
        """Draw a text on the skyview."""
        self.cr.select_font_face("Sans", cairo.FONT_SLANT_NORMAL,
                                 cairo.FONT_WEIGHT_BOLD)
        self.cr.set_font_size(10)

        if centered:
            extents = self.cr.text_extents(text)
            # width / 2 + x_bearing
            x -= extents[2] / 2 + extents[0]
            # height / 2 + y_bearing
            y -= extents[3] / 2 + extents[1]

        self.cr.move_to(x, y)
        self.cr.show_text(text)
        self.cr.new_path()

    def draw_triangle(self, x, y, radius, filled, flip):
        """Draw a triangle centered on the specified midpoint."""
        lw = self.cr.get_line_width()
        if flip in (0, 1):
            if 0 == flip:
                # down
                ytop = y + radius
                ybot = y - radius
            elif 1 == flip:
                # up
                ytop = y - radius
                ybot = y + radius

            x1, y1 = fit_to_grid(x, ytop, lw)
            x2, y2 = fit_to_grid(x + radius, ybot, lw)
            x3, y3 = fit_to_grid(x - radius, ybot, lw)
        else:
            ytop = y + radius
            ybot = y - radius

            if 2 == flip:
                # right
                x1, y1 = fit_to_grid(x - radius, ytop, lw)
                x2, y2 = fit_to_grid(x - radius, ybot, lw)
                x3, y3 = fit_to_grid(x + radius, y, lw)
            else:
                # left
                x1, y1 = fit_to_grid(x + radius, ytop, lw)
                x2, y2 = fit_to_grid(x + radius, ybot, lw)
                x3, y3 = fit_to_grid(x - radius, y, lw)

        self.cr.move_to(x1, y1)
        self.cr.line_to(x2, y2)
        self.cr.line_to(x3, y3)
        self.cr.close_path()

        if filled:
            self.cr.fill()
        else:
            self.cr.stroke()

    def pol2cart(self, az, el):
        """Polar to Cartesian coordinates within the horizon circle."""
        az = (az - self.rotate) % 360.0
        az *= (math.pi / 180)  # Degrees to radians
        # Exact spherical projection would be like this:
        # el = sin((90.0 - el) * DEG_2_RAD);
        el = ((90.0 - el) / 90.0)
        xout = self.center_x + math.sin(az) * el * self.radius
        yout = self.center_y - math.cos(az) * el * self.radius
        return (xout, yout)

    def on_draw(self, widget, _unused):
        """Draw the skyview."""
        window = widget.get_window()
        region = window.get_clip_region()
        context = window.begin_draw_frame(region)
        self.cr = context.get_cairo_context()

        self.cr.set_line_width(1)

        self.cr.set_source_rgb(0, 0, 0)
        self.cr.paint()

        self.cr.set_source_rgb(1, 1, 1)
        # The zenith marker
        self.draw_circle(self.center_x, self.center_y, 6, filled=False)

        # The horizon circle
        if 45 == self.step_of_grid:
            # The circle corresponding to 45 degrees elevation.
            # There are two ways we could plot this.  Projecting the sphere
            # on the display plane, the circle would have a diameter of
            # sin(45) ~ 0.7.  But the naive linear mapping, just splitting
            # the horizon diameter in half, seems to work better visually.
            self.draw_circle(self.center_x, self.center_y, self.radius / 2,
                             filled=False)
        elif 30 == self.step_of_grid:
            self.draw_circle(self.center_x, self.center_y, self.radius * 2 / 3,
                             filled=False)
            self.draw_circle(self.center_x, self.center_y, self.radius / 3,
                             filled=False)
        self.draw_circle(self.center_x, self.center_y, self.radius,
                         filled=False)

        (x1, y1) = self.pol2cart(0, 0)
        (x2, y2) = self.pol2cart(180, 0)
        self.draw_line(x1, y1, x2, y2)

        (x1, y1) = self.pol2cart(90, 0)
        (x2, y2) = self.pol2cart(270, 0)
        self.draw_line(x1, y1, x2, y2)

        # The compass-point letters
        (x, y) = self.pol2cart(0, -5)
        self.draw_string(x, y, "N")
        (x, y) = self.pol2cart(90, -5)
        self.draw_string(x, y, "E")
        (x, y) = self.pol2cart(180, -5)
        self.draw_string(x, y, "S")
        (x, y) = self.pol2cart(270, -5)
        self.draw_string(x, y, "W")
        # place an invisible space above to allow sats below horizon
        (x, y) = self.pol2cart(0, -10)
        self.draw_string(x, y, "")

        # The satellites
        self.cr.set_line_width(2)
        self.sat_xy = []
        for sat in self.satellites:
            if not 1 <= sat.PRN <= 437:
                # Bad PRN, skip.  NMEA uses up to 437
                continue
            if not 0 <= sat.az <= 359:
                # Bad azimuth, skip.
                continue
            if not -10 <= sat.el <= 90:
                # Bad elevation, skip.  Allow just below horizon
                continue

            # The Navika-100 reports el/az of 0/0 for SBAS satellites,
            # causing them to appear inappropriately at the "north point".
            # Although this value isn't technically illegal (and hence not
            # filtered above), excluding this one specific case has a very
            # low probability of excluding legitimate cases, while avoiding
            # the improper display in this case.
            # Note that this only excludes them from the map, not the list.
            if ((0 == sat.az and
                 0 == sat.el)):
                continue

            (x, y) = self.pol2cart(sat.az, sat.el)
            # colorize by signal to noise ratio
            # RINEX 3 uses 9 steps: 1 to 9.  Corresponding to
            # <12, 12-17, 18-23, 24-29, 30-35, 36-41, 42-47, 48-53, >= 54
            if 12 > sat.ss:
                self.set_color(190, 190, 190)      # gray
            elif 30 > sat.ss:
                self.set_color(255, 0, 0)          # red
            elif 36 > sat.ss:
                # RINEX 3 says 30 is "threshold for good tracking"
                self.set_color(255, 255, 0)        # yellow
            elif 42 > sat.ss:
                self.set_color(0, 205, 0)          # green3
            else:
                self.set_color(0, 255, 180)        # green and some blue

            # shape by constellation
            constellation = gnssid_str(sat)[0]
            if constellation in ('GP', '  '):
                self.draw_circle(x, y, SkyView.SAT_RADIUS, sat.used)
            elif 'SB' == constellation:
                self.draw_square(x, y, SkyView.SAT_RADIUS, sat.used, 0)
            elif 'GA' == constellation:
                self.draw_triangle(x, y, SkyView.SAT_RADIUS, sat.used, 0)
            elif 'BD' == constellation:
                self.draw_triangle(x, y, SkyView.SAT_RADIUS, sat.used, 1)
            elif 'GL' == constellation:
                self.draw_square(x, y, SkyView.SAT_RADIUS, sat.used, 1)
            elif 'QZ' == constellation:
                self.draw_triangle(x, y, SkyView.SAT_RADIUS, sat.used, 2)
            else:
                # IRNSS, IMES, unknown or other
                self.draw_triangle(x, y, SkyView.SAT_RADIUS, sat.used, 3)

            self.sat_xy.append((x, y, sat))
            self.cr.set_source_rgb(1, 1, 1)
            self.draw_string(x + SkyView.SAT_RADIUS,
                             y + (SkyView.SAT_RADIUS * 2), str(sat.PRN),
                             centered=False)

        self.cr = None
        window.end_draw_frame(context)

    def redraw(self, satellites):
        """Redraw the skyview."""
        self.satellites = satellites
        self.queue_draw()


class AISView(object):
    """Encapsulate store and view objects for watching AIS data."""
    AIS_ENTRIES = 10
    DWELLTIME = 360

    def __init__(self, deg_type):
        """Initialize the store and view."""
        self.deg_type = deg_type
        self.name_to_mmsi = {}
        self.named = {}
        self.store = Gtk.ListStore(int, str, str, str, str, str)
        self.widget = Gtk.ScrolledWindow()
        self.widget.set_policy(Gtk.PolicyType.AUTOMATIC,
                               Gtk.PolicyType.AUTOMATIC)
        self.view = Gtk.TreeView(model=self.store)
        self.widget.set_size_request(-1, 300)
        self.widget.add(self.view)

        for (i, label) in enumerate(('#', 'Name:', 'Callsign:',
                                     'Destination:', "Lat/Lon:",
                                     "Information")):
            column = Gtk.TreeViewColumn(label)
            renderer = Gtk.CellRendererText()
            column.pack_start(renderer, expand=True)
            column.add_attribute(renderer, 'text', i)
            self.view.append_column(column)

    def enter(self, ais, name):
        """Add a named object (ship or station) to the store."""
        if ais.mmsi in self.named:
            return False

        ais.entry_time = time.time()
        self.named[ais.mmsi] = ais
        self.name_to_mmsi[name] = ais.mmsi
        # Garbage-collect old entries
        try:
            for i in range(len(self.store)):
                here = self.store.get_iter(i)
                name = self.store.get_value(here, 1)
                mmsi = self.name_to_mmsi[name]
                if ((self.named[mmsi].entry_time <
                     time.time() - AISView.DWELLTIME)):
                    del self.named[mmsi]
                    if name in self.name_to_mmsi:
                        del self.name_to_mmsi[name]
                    self.store.remove(here)
        except (ValueError, KeyError):  # Invalid TreeIters throw these
            pass
        return True

    def latlon(self, lat, lon):
        """Latitude/longitude display in nice format."""
        if 0 > lat:
            latsuff = "S"
        elif 0 < lat:
            latsuff = "N"
        else:
            latsuff = ""
        lat = gps.clienthelpers.deg_to_str(self.deg_type, lat)
        if 0 > lon:
            lonsuff = "W"
        elif 0 < lon:
            lonsuff = "E"
        else:
            lonsuff = ""
        lon = gps.clienthelpers.deg_to_str(self.deg_type, lon)
        return lat + latsuff + "/" + lon + lonsuff

    def update(self, ais):
        """Update the AIS data fields."""
        if ais.type in (1, 2, 3, 18):
            if ais.mmsi in self.named:
                for i in range(len(self.store)):
                    here = self.store.get_iter(i)
                    name = self.store.get_value(here, 1)
                    if name in self.name_to_mmsi:
                        mmsi = self.name_to_mmsi[name]
                        if mmsi == ais.mmsi:
                            latlon = self.latlon(ais.lat, ais.lon)
                            self.store.set_value(here, 4, latlon)
        elif 4 == ais.type:
            if self.enter(ais, ais.mmsi):
                where = self.latlon(ais.lat, ais.lon)
                self.store.prepend(
                    (ais.type, str(ais.mmsi), "(shore)", ais.timestamp, where,
                     ais.epfd_text))
        elif 5 == ais.type:
            if self.enter(ais, ais.shipname):
                self.store.prepend(
                    (ais.type, ais.shipname, ais.callsign, ais.destination,
                     "", str(ais.shiptype)))
        elif 12 == ais.type:
            sender = ais.mmsi
            if sender in self.named:
                sender = self.named[sender].shipname
            recipient = ais.dest_mmsi
            if ((recipient in self.named and
                 hasattr(self.named[recipient], "shipname"))):
                recipient = self.named[recipient].shipname
            self.store.prepend(
                (ais.type, sender, "", recipient, "", ais.text))
        elif 14 == ais.type:
            sender = ais.mmsi
            if sender in self.named:
                sender = self.named[sender].shipname
            self.store.prepend(
                (ais.type, sender, "", "(broadcast)", "", ais.text))
        elif ais.type in (19, 24):
            if self.enter(ais, ais.shipname):
                self.store.prepend(
                    (ais.type, ais.shipname, "(class B)", "", "",
                     ais.shiptype_text))
        elif 21 == ais.type:
            if self.enter(ais, ais.name):
                where = self.latlon(ais.lat, ais.lon)
                self.store.prepend(
                    (ais.type, ais.name, "(%s navaid)" % ais.epfd_text,
                     "", where, ais.aid_type_text))


class IMUView(object):

    """Encapsulate view object for watching ATT/IMU data."""
    COLUMNS = 4
    ROWS = 4
    imufields = (
        # First column
        ("timeTag", "timeTag"),
        ("Acc x", "acc_x"),
        ("Acc y", "acc_y"),
        ("Acc z", "acc_z"),
        # Second column
        ("Gyro t", "gyro_temp"),
        ("Gyro x", "gyro_x"),
        ("Gyro y", "gyro_y"),
        ("Gyro z", "gyro_z"),
        # third column
        ("Heading", "heading"),
        ("Pitch", "pitch"),
        ("Roll", "roll"),
        ("Yaw", "yaw"),
        # fourth column
        ("time", "time"),
        ("Mag x", "mag_x"),
        ("Mag y", "mag_y"),
        ("Mag z", "mag_z"),
    )

    def __init__(self):
        """Initialize class IMUView."""
        self.widget = Gtk.Grid()
        self.imuwidgets = []
        for i in range(len(IMUView.imufields)):
            colbase = (i // IMUView.ROWS) * 2
            # attribute / label
            label = Gtk.Label()
            label.set_markup("<span font_desc='sans 10'> %s:</span>" %
                             IMUView.imufields[i][0])
            # force right alignment
            label.set_halign(Gtk.Align.END)
            self.widget.attach(label, colbase, i % IMUView.ROWS, 1, 1)
            # value
            entry = Gtk.Label()
            entry.set_xalign(1)            # right align data
            # span gets lost later
            entry.set_markup("<span font_desc='monospace 10'> n/a </span>")
            entry.set_width_chars(10)
            self.widget.attach_next_to(entry, label,
                                       Gtk.PositionType.RIGHT, 1, 1)
            self.imuwidgets.append((IMUView.imufields[i][1], entry))

    def update(self, msg):
        """Update the IMU data fields."""
        markup = "<span font_desc='monospace 10'>%s </span>"
        for (attrname, widget) in self.imuwidgets:
            if hasattr(msg, attrname):
                s = str(getattr(msg, attrname))
            elif 'time' == attrname:
                s = "                    n/a "
            else:
                s = " n/a "
            widget.set_markup(markup % s)


class NoiseView(object):

    """Encapsulate view object for watching noise statistics."""
    COLUMNS = 2
    ROWS = 4
    noisefields = (
        # First column
        ("Time", "time"),
        ("Latitude", "lat"),
        ("Longitude", "lon"),
        ("Altitude", "alt"),
        # Second column
        ("RMS", "rms"),
        ("Major", "major"),
        ("Minor", "minor"),
        ("Orient", "orient"),
    )

    def __init__(self):
        """Initialize class NoiseView."""
        self.widget = Gtk.Grid()
        self.noisewidgets = []
        for i in range(len(NoiseView.noisefields)):
            colbase = (i // NoiseView.ROWS) * 2
            label = Gtk.Label()
            label.set_markup("<span font_desc='sans 10'> %s:</span>" %
                             NoiseView.noisefields[i][0])
            # force right alignment
            label.set_halign(Gtk.Align.END)
            self.widget.attach(label, colbase, i % NoiseView.ROWS, 1, 1)
            entry = Gtk.Label()
            # span gets lost later
            entry.set_markup("<span font_desc='monospace 10'> n/a </span>")
            self.widget.attach_next_to(entry, label,
                                       Gtk.PositionType.RIGHT, 1, 1)
            self.noisewidgets.append((NoiseView.noisefields[i][1], entry))

    def update(self, noise):
        """Update the GPGST data fields."""
        markup = "<span font_desc='monospace 10'>%s </span>"
        for (attrname, widget) in self.noisewidgets:
            if hasattr(noise, attrname):
                s = str(getattr(noise, attrname))
            else:
                s = " n/a "
            widget.set_markup(markup % s)


class RTKView(object):

    """Encapsulate view object for watching ATT/RTK data."""
    COLUMNS = 3
    ROWS = 4
    rtkfields = (
        # First column
        ("Heading", "heading"),
        ("Pitch", "pitch"),
        ("Roll", "roll"),
        ("Yaw", "yaw"),
        # second column
        ("Base Course", "baseC"),
        ("Base East", "baseE"),
        ("Base North", "baseN"),
        ("Base up", "baseU"),
        # third column
        ("time", "time"),
        ("Base Status", "baseS"),
        ("Base Length", "baseL"),
        ("", "XX"),
    )

    def __init__(self):
        """Initialize class RTKView."""
        self.widget = Gtk.Grid()
        self.rtkwidgets = []
        for i in range(len(RTKView.rtkfields)):
            colbase = (i // RTKView.ROWS) * 2
            # attribute / label
            label = Gtk.Label()
            label.set_markup("<span font_desc='sans 10'> %s:</span>" %
                             RTKView.rtkfields[i][0])
            # force right alignment
            label.set_halign(Gtk.Align.END)
            self.widget.attach(label, colbase, i % RTKView.ROWS, 1, 1)
            # value
            entry = Gtk.Label()
            entry.set_xalign(1)            # right align data
            # span gets lost later
            entry.set_markup("<span font_desc='monospace 10'> n/a </span>")
            entry.set_width_chars(10)
            self.widget.attach_next_to(entry, label,
                                       Gtk.PositionType.RIGHT, 1, 1)
            self.rtkwidgets.append((RTKView.rtkfields[i][1], entry))

    def update(self, msg):
        """Update the RTK data fields."""
        markup = "<span font_desc='monospace 10'>%s </span>"
        for (attrname, widget) in self.rtkwidgets:
            if hasattr(msg, attrname):
                s = str(getattr(msg, attrname))
            elif 'time' == attrname:
                s = "                    n/a "
            else:
                s = " n/a "
            widget.set_markup(markup % s)


class Base(object):

    """Base class for all the output"""
    ROWS = 9
    gpsfields = (
        # First column
        ("Time", lambda s, r: s.update_time(r)),
        ("Latitude", lambda s, r: s.update_latitude(r)),
        ("Longitude", lambda s, r: s.update_longitude(r)),
        ("Altitude HAE", lambda s, r: s.update_altitude(r, 0)),
        ("Altitude MSL", lambda s, r: s.update_altitude(r, 1)),
        ("Speed", lambda s, r: s.update_speed(r)),
        ("Climb", lambda s, r: s.update_climb(r)),
        ("Track True", lambda s, r: s.update_track(r, 0)),
        ("Track Mag", lambda s, r: s.update_track(r, 1)),
        # Second column
        ("Status", lambda s, r: s.update_status(r, 0)),
        ("For", lambda s, r: s.update_status(r, 1)),
        ("EPX", lambda s, r: s.update_err(r, "epx")),
        ("EPY", lambda s, r: s.update_err(r, "epy")),
        ("EPV", lambda s, r: s.update_err(r, "epv")),
        ("EPS", lambda s, r: s.update_err_speed(r, "eps")),
        ("EPC", lambda s, r: s.update_err_speed(r, "epc")),
        ("EPD", lambda s, r: s.update_err_degrees(r, "epd")),
        ("Mag Dec", lambda s, r: s.update_mag_dec(r)),
        # third column
        ("ECEF X", lambda s, r: s.update_ecef(r, "ecefx")),
        ("ECEF Y", lambda s, r: s.update_ecef(r, "ecefy")),
        ("ECEF Z", lambda s, r: s.update_ecef(r, "ecefz")),
        ("ECEF pAcc", lambda s, r: s.update_ecef(r, "ecefpAcc")),
        ("ECEF VX", lambda s, r: s.update_ecef(r, "ecefvx", "/s")),
        ("ECEF VY", lambda s, r: s.update_ecef(r, "ecefvy", "/s")),
        ("ECEF VZ", lambda s, r: s.update_ecef(r, "ecefvz", "/s")),
        ("ECEF vAcc", lambda s, r: s.update_ecef(r, "ecefvAcc", "/s")),
        ('Grid', lambda s, r: s.update_maidenhead(r)),
        # fourth column
        ("Sats Seen", lambda s, r: s.update_seen(r, 0)),
        ("Sats Used", lambda s, r: s.update_seen(r, 1)),
        ("XDOP", lambda s, r: s.update_dop(r, "xdop")),
        ("YDOP", lambda s, r: s.update_dop(r, "ydop")),
        ("HDOP", lambda s, r: s.update_dop(r, "hdop")),
        ("VDOP", lambda s, r: s.update_dop(r, "vdop")),
        ("PDOP", lambda s, r: s.update_dop(r, "pdop")),
        ("TDOP", lambda s, r: s.update_dop(r, "tdop")),
        ("GDOP", lambda s, r: s.update_dop(r, "gdop")),
    )

    def about(self, _unused):
        """Show about dialog."""
        about = Gtk.AboutDialog()
        about.set_program_name("xgps")
        about.set_version("Versions:\n"
                          "xgps %s\n"
                          "PyGObject Version %d.%d.%d" %
                          (gps_version, gi.version_info[0],
                           gi.version_info[1], gi.version_info[2]))
        about.set_copyright("Copyright 2010 by The GPSD Project")
        about.set_website("https://gpsd.io/")
        about.set_website_label("https://gpsd.io/")
        about.set_license("BSD-2-clause")
        iconpath = gps.__iconpath__ + '/gpsd-logo.png'
        if os.access(iconpath, os.R_OK):
            pixbuf = GdkPixbuf.Pixbuf.new_from_file(iconpath)
            about.set_logo(pixbuf)

        about.run()
        about.destroy()

    def __init__(self, deg_type, rotation=None, title=""):
        """Initialize class Base."""
        self.old_sats = ['n/a', 'n/a']
        self.deg_type = deg_type
        self.rotate = rotation
        self.conversions = gps.clienthelpers.unit_adjustments()
        self.saved_mode = -1
        # Flag to turn on AISView if we see AIS, once
        self.ais_latch = False
        # Flag to turn on ATT if we see ATT, once
        self.att_latch = False
        # Flag to turn on IMU MEAS if we see IMU MEAS, once
        self.imumeas_latch = False
        # Flag to turn on IMU RAW if we see IMU RAW, once
        self.imuraw_latch = False
        # Flag to turn on MoiseView if we see GST, once
        self.noise_latch = False
        # Flag to turn on RTK if we see RTK (base), once
        self.rtk_latch = False
        self.last_transition = 0.0
        self.daemon = None
        self.device = None

        self.window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
        if not self.window.get_display():
            raise Exception("Can't open display")
        if title:
            title = " " + title
        self.window.set_title("xgps" + title)
        self.window.connect("delete-event", self.delete_event)
        self.window.set_resizable(False)

        # do the CSS thing
        style_provider = Gtk.CssProvider()

        css = b"""
frame * {
    background-color: #FFF;
    color: #000;
}
"""
        # font-desc: "Comic Sans 12";

        style_provider.load_from_data(css)

        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(),
            style_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

        vbox = Gtk.VBox(homogeneous=False, spacing=0)
        self.window.add(vbox)

        self.window.connect("destroy", lambda _unused: Gtk.main_quit())

        menubar = Gtk.MenuBar()
        agr = Gtk.AccelGroup()
        self.window.add_accel_group(agr)

        # File
        topmenu = Gtk.MenuItem(label="File")
        menubar.append(topmenu)
        submenu = Gtk.Menu()
        topmenu.set_submenu(submenu)

        menui = Gtk.MenuItem(label="Connect")
        # key, mod = Gtk.accelerator_parse("<Control>Q")
        # menui.add_accelerator("activate", agr, key, mod,
        #                      Gtk.AccelFlags.VISIBLE)
        # menui.connect("activate", Gtk.main_quit)
        submenu.append(menui)

        menui = Gtk.MenuItem(label="Disconnect")
        # key, mod = Gtk.accelerator_parse("<Control>Q")
        # menui.add_accelerator("activate", agr, key, mod,
        #                      Gtk.AccelFlags.VISIBLE)
        # menui.connect("activate", Gtk.main_quit)
        submenu.append(menui)

        menui = Gtk.MenuItem(label="Quit")
        key, mod = Gtk.accelerator_parse("<Control>Q")
        menui.add_accelerator("activate", agr, key, mod,
                              Gtk.AccelFlags.VISIBLE)
        menui.connect("activate", Gtk.main_quit)
        submenu.append(menui)

        # View
        topmenu = Gtk.MenuItem(label="View")
        menubar.append(topmenu)
        submenu = Gtk.Menu()
        topmenu.set_submenu(submenu)

        views = [["AIS Data", False, "<Control>A", "AIS"],
                 ["ATT Data", False, "<Control>T", "ATT"],
                 ["GPS Data", True, "<Control>G", "GPS"],
                 ["IMU MEAS Data", False, "<Control>M", "IMU MEAS"],
                 ["IMU RAW Data", False, "<Control>I", "IMU RAW"],
                 ["Noise Statistics", False, "<Control>N", "Noise"],
                 ["Responses", True, "<Control>R", "Responses"],
                 ["RTK Data", False, "<Control>K", "RTK"],
                 ["Skyview", True, "<Control>S", "Skyview"],
                 ]

        self.menumap = {}

        for name, active, acc, handle in views:
            menui = Gtk.CheckMenuItem(label=name)
            self.menumap[handle] = menui
            menui.set_active(active)
            menui.connect("activate", self.view_toggle, handle)
            if acc:
                key, mod = Gtk.accelerator_parse(acc)
                menui.add_accelerator("activate", agr, key, mod,
                                      Gtk.AccelFlags.VISIBLE)
            submenu.append(menui)

        # Units
        topmenu = Gtk.MenuItem(label="Units")
        menubar.append(topmenu)
        submenu = Gtk.Menu()
        topmenu.set_submenu(submenu)

        units = [["Imperial", True, "i", 'i'],
                 ["Nautical", False, "n", 'n'],
                 ["Metric", False, "m", 'm'],
                 ]

        menui = None
        for name, active, acc, handle in units:
            menui = Gtk.RadioMenuItem(group=menui, label=name)
            menui.set_active(active)
            menui.connect("activate", self.set_units, handle)
            if acc:
                key, mod = Gtk.accelerator_parse(acc)
                menui.add_accelerator("activate", agr, key, mod,
                                      Gtk.AccelFlags.VISIBLE)
            submenu.append(menui)

        submenu.append(Gtk.SeparatorMenuItem())

        units = [["DD.dd", True, "0", gps.clienthelpers.deg_dd],
                 ["DD MM.mm", False, "1", gps.clienthelpers.deg_ddmm],
                 ["DD MM SS.ss", False, "2", gps.clienthelpers.deg_ddmmss],
                 ]

        menui = None
        for name, active, acc, handle in units:
            menui = Gtk.RadioMenuItem(group=menui, label=name)
            menui.set_active(active)
            menui.connect("activate", self.set_deg, handle)
            if acc:
                key, mod = Gtk.accelerator_parse(acc)
                menui.add_accelerator("activate", agr, key, mod,
                                      Gtk.AccelFlags.VISIBLE)
            submenu.append(menui)

        # Step of Grid
        topmenu = Gtk.MenuItem(label="Step of Grid")
        menubar.append(topmenu)
        submenu = Gtk.Menu()
        topmenu.set_submenu(submenu)

        grid = [["30 deg", False, "3", 30],
                ["45 deg", True, "4", 45],
                ["Off", False, "5", 0],
                ]

        menui = None
        for name, active, acc, handle in grid:
            menui = Gtk.RadioMenuItem(group=menui, label=name)
            menui.set_active(active)
            menui.connect("activate", self.set_step_of_grid, handle)
            if acc:
                key, mod = Gtk.accelerator_parse(acc)
                menui.add_accelerator("activate", agr, key, mod,
                                      Gtk.AccelFlags.VISIBLE)
            submenu.append(menui)

        submenu.append(Gtk.SeparatorMenuItem())
        skymr = [["Mag North  Up", True, "6", None],
                 ["Track Up", False, "7", True],
                 ["True North Up", False, "8", 0],
                 ]
        menui = None
        for name, active, acc, handle in skymr:
            menui = Gtk.RadioMenuItem(group=menui, label=name)
            menui.set_active(active)
            menui.connect("activate", self.set_skyview_n, handle)
            if acc:
                key, mod = Gtk.accelerator_parse(acc)
                menui.add_accelerator("activate", agr, key, mod,
                                      Gtk.AccelFlags.VISIBLE)
            submenu.append(menui)

        # Help
        topmenu = Gtk.MenuItem(label="Help")
        menubar.append(topmenu)
        submenu = Gtk.Menu()
        topmenu.set_submenu(submenu)

        menui = Gtk.MenuItem(label="About")
        menui.connect("activate", self.about)
        submenu.append(menui)

        vbox.pack_start(menubar, expand=False, fill=True, padding=0)

        # Gtk.HBox() is deprecated, use Gtk.Box() or Gtk.Grid().
        self.satbox = Gtk.HBox(homogeneous=False, spacing=0)
        vbox.add(self.satbox)

        skyframe = Gtk.Frame(label="Satellite List")
        self.satbox.add(skyframe)

        self.satlist = Gtk.ListStore(str, str, str, str, str, str, str, str,
                                     str, str)
        sat_view = Gtk.TreeView(model=self.satlist)
        sat_view.set_fixed_height_mode(True)  # supposed to speed up TreeView

        satcols = [['', 0],
                   ['svid', 1],
                   ['sig', 1],
                   ['PRN', 1],
                   ['Elev', 1],
                   ['Azim', 1],
                   ['SNR', 1],
                   ['qual', 1],
                   ['prRes', 1],
                   ['Used', 0],
                   ]

        for (i, satcol) in enumerate(satcols):
            renderer = Gtk.CellRendererText(xalign=satcol[1])
            # renderer.props.font = "monospace 11"
            # renderer.set_fixed_height_from_font(1)
            column = Gtk.TreeViewColumn(satcol[0], renderer)
            column.add_attribute(renderer, 'text', i)
            # required for set_fixed_height_mode(True)
            column.set_sizing(Gtk.TreeViewColumnSizing.FIXED)
            sat_view.append_column(column)

        self.row_iters = []
        for i in range(options.maxchandisp):
            self.satlist.append(["", "", "", "", "", "", "", "", "", ""])
            self.row_iters.append(self.satlist.get_iter(i))

        skyframe.add(sat_view)

        # now do the Skyview
        viewframe = Gtk.Frame(label="Skyview")
        self.satbox.add(viewframe)
        self.sky_view = SkyView(self.rotate)
        try:
            # mouseovers fail with remote DISPLAY
            self.sky_view.set_property('events',
                                       Gdk.EventMask.POINTER_MOTION_MASK)
        except NotImplementedError:
            # keep going anyway, w/o popups
            sys.stderr.write("xgps: WARNING: failed to grab mouse events, "
                             "popups disabled\n")

        viewframe.add(self.sky_view)

        # Display area for incoming JSON
        self.rawdisplay = Gtk.Entry()
        self.rawdisplay.set_editable(False)
        vbox.add(self.rawdisplay)

        # Display area for GPS Data
        self.dataframe = Gtk.Frame(label="GPS Data")
        # print("GPS Data css:", self.dataframe.get_css_name())

        datatable = Gtk.Grid()
        self.dataframe.add(datatable)
        gpswidgets = []
        # min col widths
        widths = [0, 25, 0, 20, 0, 23, 0, 0, 0, 8]
        for i in range(len(Base.gpsfields)):
            colbase = (i // Base.ROWS) * 2
            label = Gtk.Label()
            label.set_markup("<span font_desc='sans 10'> %s:</span>" %
                             Base.gpsfields[i][0])
            # force right alignment
            label.set_halign(Gtk.Align.END)
            datatable.attach(label, colbase, i % Base.ROWS, 1, 1)
            entry = Gtk.Label()
            if 0 < widths[colbase + 1]:
                entry.set_width_chars(widths[colbase + 1])
            entry.set_selectable(True)
            # span gets lost later
            entry.set_markup("<span font_desc='monospace 10'> n/a </span>")
            datatable.attach_next_to(entry, label,
                                     Gtk.PositionType.RIGHT, 1, 1)
            gpswidgets.append(entry)
        vbox.add(self.dataframe)

        # Add AIS
        self.ais_box = Gtk.Box(homogeneous=False, spacing=0)
        vbox.add(self.ais_box)
        aisframe = Gtk.Frame(label="AIS Data")
        self.ais_box.add(aisframe)
        self.ais_view = AISView(self.deg_type)
        aisframe.add(self.ais_view.widget)

        # Add ATT
        self.att_box = Gtk.Grid()
        vbox.add(self.att_box)
        att_frame = Gtk.Frame(label="ATT")
        self.att_box.add(att_frame)
        self.att_view = IMUView()
        att_frame.add(self.att_view.widget)

        # Add IMU MEAS and RAW
        self.imumeas_box = Gtk.Grid()
        vbox.add(self.imumeas_box)
        imumeas_frame = Gtk.Frame(label="IMU MEAS")
        self.imumeas_box.add(imumeas_frame)
        self.imumeas_view = IMUView()
        imumeas_frame.add(self.imumeas_view.widget)

        self.imuraw_box = Gtk.Grid()
        vbox.add(self.imuraw_box)
        imuraw_frame = Gtk.Frame(label="IMU RAW")
        self.imuraw_box.add(imuraw_frame)
        self.imuraw_view = IMUView()
        imuraw_frame.add(self.imuraw_view.widget)

        # Add noise box
        # FIXME: GtkBox() is deprecated, use Gtk.Grid()
        self.noisebox = Gtk.Box(homogeneous=False, spacing=0)
        vbox.add(self.noisebox)
        noiseframe = Gtk.Frame(label="Noise Statistics")
        self.noisebox.add(noiseframe)
        self.noise_view = NoiseView()
        noiseframe.add(self.noise_view.widget)

        # Add RTK
        self.rtk_box = Gtk.Grid()
        vbox.add(self.rtk_box)
        rtk_frame = Gtk.Frame(label="RTK")
        self.rtk_box.add(rtk_frame)
        self.rtk_view = RTKView()
        rtk_frame.add(self.rtk_view.widget)

        self.window.show_all()
        # Hide the ATT/IMU windows until first data seen
        self.att_box.hide()
        self.imumeas_box.hide()
        self.imuraw_box.hide()
        self.rtk_box.hide()

        # Hide the Noise Statistics window until user selects it.
        self.noisebox.hide()

        # Hide the AIS window until user selects it.
        self.ais_box.hide()

        self.view_name_to_widget = {
            "AIS": self.ais_box,
            "ATT": self.att_box,
            "GPS": self.dataframe,
            "IMU MEAS": self.imumeas_box,
            "IMU RAW": self.imuraw_box,
            "Noise": self.noisebox,
            "Responses": self.rawdisplay,
            "RTK": self.rtk_box,
            "Skyview": self.satbox,
            }

        # Discard field labels and associate data hooks with their widgets
        Base.gpsfields = [(label_hook_widget[0][1], label_hook_widget[1])
                          for label_hook_widget
                          in zip(Base.gpsfields, gpswidgets)]

    def view_toggle(self, action, name):
        """Toggle widget view"""

        # print("View toggle:", action.get_active(), name)
        if hasattr(self, 'view_name_to_widget'):
            is_vis = self.view_name_to_widget[name].is_visible()
            self.view_name_to_widget[name].set_visible(not is_vis)
        # The effect we're after is to make the top-level window
        # resize itself to fit when we show or hide widgets.
        # This is undocumented magic to do that.
        self.window.resize(1, 1)

    def set_satlist_field(self, row, column, value):
        """Set a specified field in the satellite list."""
        try:
            self.satlist.set_value(self.row_iters[row], column, str(value))
        except IndexError:
            sys.stderr.write("xgps: channel = %d, maxchandisp = %d\n"
                             % (row, options.maxchandisp))

    def delete_event(self, _widget, _event, _data=None):
        """Say goodbye nicely."""
        Gtk.main_quit()
        return False

    # State updates

    def update_time(self, data):
        """Update time."""
        if hasattr(data, "time"):
            # str() just in case we get an old-style float.
            ret = str(data.time)
        else:
            ret = "n/a"

        if hasattr(data, "leapseconds"):
            ret += " (%u)" % data.leapseconds

        return ret

    def update_latitude(self, data):
        """Update latitude."""
        if gps.MODE_2D <= data.mode and hasattr(data, "lat"):
            lat = gps.clienthelpers.deg_to_str(self.deg_type, data.lat)
            if 0 > data.lat:
                ns = 'S'
            else:
                ns = 'N'
            return "%14s %s" % (lat, ns)

        return "n/a"

    def update_longitude(self, data):
        """Update longitude."""
        if gps.MODE_2D <= data.mode and hasattr(data, "lon"):
            lon = gps.clienthelpers.deg_to_str(self.deg_type, data.lon)
            if 0 > data.lon:
                ew = 'W'
            else:
                ew = 'E'
            return "%14s %s" % (lon, ew)

        return "n/a"

    def update_altitude(self, data, item):
        """Update altitude."""
        ret = "n/a"
        if gps.MODE_3D <= data.mode:
            if 0 == item and hasattr(data, "altHAE"):
                ret = ("%10.3f %s" %
                       ((data.altHAE * self.conversions.altfactor),
                        self.conversions.altunits))

            if 1 == item and hasattr(data, "altMSL"):
                ret = ("%10.3f %s" %
                       ((data.altMSL * self.conversions.altfactor),
                        self.conversions.altunits))

        return ret

    def update_speed(self, data):
        """Update speed."""
        if hasattr(data, "speed"):
            return "%9.3f %s" % (
                data.speed * self.conversions.speedfactor,
                self.conversions.speedunits)

        return "n/a"

    def update_climb(self, data):
        """Update climb."""
        if hasattr(data, "climb"):
            return "%9.3f %s" % (
                data.climb * self.conversions.speedfactor,
                self.conversions.speedunits)

        return "n/a"

    def update_track(self, data, item):
        """Update track."""
        if 0 == item and hasattr(data, "track"):
            return "%14s " % (
                gps.clienthelpers.deg_to_str(self.deg_type, data.track))

        if 1 == item and hasattr(data, "magtrack"):
            return "%14s " % (
                gps.clienthelpers.deg_to_str(self.deg_type, data.magtrack))

        return "n/a"

    def update_seen(self, data, item):
        """Update sats seen."""
        # update sats seen/used in the GPS Data window
        if 0 == item and hasattr(data, 'satellites_seen'):
            return getattr(data, 'satellites_seen')

        if 1 == item and hasattr(data, 'satellites_used'):
            return getattr(data, 'satellites_used')

        return self.old_sats[item]

    def update_dop(self, data, doptype):
        """Update a DOP in the GPS Data window."""
        if hasattr(data, doptype):
            return "%5.2f" % getattr(data, doptype)

        return "n/a"

    def update_ecef(self, data, eceftype, speedunit=''):
        """Update a ECEF in the GPS Data window."""
        if hasattr(data, eceftype):
            value = getattr(data, eceftype)
            return ("% 14.3f %s%s" %
                    (value * self.conversions.altfactor,
                     self.conversions.altunits, speedunit))

        return "n/a"

    def update_err(self, data, errtype):
        """Update a error estimate in the GPS Data window."""
        if hasattr(data, errtype):
            return "%8.3f %s" % (
                getattr(data, errtype) * self.conversions.altfactor,
                self.conversions.altunits)

        return "n/a"

    def update_err_speed(self, data, errtype):
        """Update speed error estimate in the GPS Data window."""
        if hasattr(data, errtype):
            return "%8.3f %s" % (
                getattr(data, errtype) * self.conversions.speedfactor,
                self.conversions.speedunits)

        return "n/a"

    def update_err_degrees(self, data, errtype):
        """Update heading error estimate in the GPS Data window."""
        if hasattr(data, errtype):
            return ("%s " %
                    (gps.clienthelpers.deg_to_str(self.deg_type,
                                                  getattr(data, errtype))))

        return "n/a"

    def update_mag_dec(self, data):
        """Update magnetic declination in the GPS Data window."""
        if ((gps.MODE_2D <= data.mode and
             hasattr(data, "lat") and
             hasattr(data, "lon"))):
            off = gps.clienthelpers.mag_var(data.lat, data.lon)
            off2 = gps.clienthelpers.deg_to_str(self.deg_type, off)
            return off2
        return "n/a"

    def update_maidenhead(self, data):
        """Update maidenhead grid square in the GPS Data window."""
        if ((gps.MODE_2D <= data.mode and
             hasattr(data, "lat") and
             hasattr(data, "lon"))):
            return gps.clienthelpers.maidenhead(data.lat, data.lon)
        return "n/a"

    def update_status(self, data, item):
        """Update the status window."""
        if 1 == item:
            return "%d secs" % (time.time() - self.last_transition)

        sub_status = ''
        if hasattr(data, 'status'):
            if gps.STATUS_DGPS == data.status:
                sub_status = " DGPS"
            elif gps.STATUS_RTK_FIX == data.status:
                sub_status = " RTKfix"
            elif gps.STATUS_RTK_FLT == data.status:
                sub_status = " RTKflt"
            elif gps.STATUS_DR == data.status:
                sub_status = " DR"
            elif gps.STATUS_GNSSDR == data.status:
                sub_status = " GNSSDR"
            elif gps.STATUS_TIME == data.status:
                sub_status = " FIXED"
            elif gps.STATUS_SIM == data.status:
                sub_status = " SIM"
            elif gps.STATUS_PPS_FIX == data.status:
                sub_status = " PPS"

        if gps.MODE_2D == data.mode:
            status = "2D%s FIX" % sub_status
        elif gps.MODE_3D == data.mode:
            if hasattr(data, 'status') and gps.STATUS_TIME == data.status:
                status = "FIXED SURVEYED"
            else:
                status = "3D%s FIX" % sub_status
        else:
            status = "NO FIX"

        if data.mode != self.saved_mode:
            self.last_transition = time.time()
            self.saved_mode = data.mode
        return status

    def update_gpsdata(self, tpv):
        """Update the GPS data fields."""
        # the first 28 fields are updated using TPV data
        # the next 9 fields are updated using SKY data

        markup = "<span font_desc='monospace 10'>%s </span>"
        for (hook, widget) in Base.gpsfields[:27]:
            if hook:  # Remove this guard when we have all hooks
                widget.set_markup(markup % hook(self, tpv))
        if self.sky_view:
            if ((self.rotate is None and
                 hasattr(tpv, 'lat') and hasattr(tpv, 'lon'))):
                self.sky_view.rotate = gps.clienthelpers.mag_var(tpv.lat,
                                                                 tpv.lon)
            elif self.rotate is True and 'track' in tpv:
                self.sky_view.rotate = tpv.track

    def update_version(self, ver):
        """Update the Version."""
        if ver.release != gps_version:
            sys.stderr.write("%s: WARNING gpsd version %s different than "
                             "expected %s\n" %
                             (sys.argv[0], ver.release, gps_version))

        if ((ver.proto_major != gps.api_version_major or
             ver.proto_minor != gps.api_version_minor)):
            sys.stderr.write("%s: WARNING API version %s.%s different than "
                             "expected %s.%s\n" %
                             (sys.argv[0], ver.proto_major, ver.proto_minor,
                              gps.api_version_major, gps.api_version_minor))

    def _int_to_str(self, value, min_val, max_val):
        """Rest val in range min to max, or return."""
        if min_val <= value <= max_val:
            return '%3d' % value
        return 'n/a'

    def _tenth_to_str(self, value, min_val, max_val):
        """Test val in range min to max, or return."""
        if min_val <= value <= max_val:
            return '%5.1f' % value
        return 'n/a'

    def update_skyview(self, data):
        """Update the satellite list and skyview."""
        if hasattr(data, 'satellites'):
            data.satellites_seen = 0
            data.satellites_used = 0

            def key_sats(s):
                """sat sort function.  Used, gnssid, svid, sigid."""
                val = 1
                if s.used:
                    val = 0
                val = (val * 100) + s.gnssid
                val = (val * 1000) + s.svid
                val *= 100
                if 'sigid' in s:
                    val += s.sigid
                return val

            satellites = sorted(data.satellites, key=key_sats)

            # print("Sats: ", satellites)
            for (i, satellite) in enumerate(satellites):
                yesno = 'N'
                data.satellites_seen += 1
                if satellite.used:
                    yesno = 'Y'
                    data.satellites_used += 1
                if 'health' not in satellite:
                    yesno = '   ' + yesno
                elif 2 == satellite.health:
                    yesno = '  u' + yesno
                else:
                    yesno = '   ' + yesno

                if i >= options.maxchandisp:
                    # more than can be displaced
                    continue

                self.set_satlist_field(i, 0, gnssid_str(satellite)[0])
                if 'svid' in satellite:
                    # SBAS is in the 100's...
                    self.set_satlist_field(i, 1,
                                           self._int_to_str(satellite.svid,
                                                            1, 199))
                if 'sigid' in satellite:
                    s = int(satellite.sigid)
                else:
                    s = "   "
                self.set_satlist_field(i, 2, s)

                # NMEA uses PRN up to 437
                self.set_satlist_field(i, 3,
                                       self._int_to_str(satellite.PRN, 1, 437))
                # allow satellites 10 degree below horizon
                self.set_satlist_field(i, 4,
                                       self._tenth_to_str(satellite.el,
                                                          -10, 90))
                self.set_satlist_field(i, 5,
                                       self._tenth_to_str(satellite.az,
                                                          0, 359))
                self.set_satlist_field(i, 6,
                                       self._tenth_to_str(satellite.ss,
                                                          0, 100))
                if 'qual' in satellite:
                    self.set_satlist_field(i, 7,
                                           self._int_to_str(satellite.qual,
                                                            1, 9))
                if 'prRes' in satellite:
                    self.set_satlist_field(i, 8,
                                           self._tenth_to_str(satellite.prRes,
                                                              -999, 999))
                self.set_satlist_field(i, 9, yesno)
            self.old_sats = [data.satellites_seen, data.satellites_used]

            # clear rest of the list
            for i in range(data.satellites_seen, options.maxchandisp):
                for j in range(0, 10):
                    self.set_satlist_field(i, j, "")
            # repaint Skyview
            self.sky_view.redraw(satellites)
        # else:   #  a SKY with just DOPs

        markup = "<span font_desc='monospace 10'>%s </span>"
        # the first 27 fields are updated using TPV data
        # the next 9 fields are updated using SKY data
        for (hook, widget) in Base.gpsfields[27:36]:
            if hook:  # Remove this guard when we have all hooks
                widget.set_markup(markup % hook(self, data))

    # Preferences

    def set_skyview_n(self, system, handle):
        """Change the grid orientation."""
        self.rotate = handle
        if handle is not None:
            self.sky_view.rotate = handle

    def set_step_of_grid(self, system, handle):
        """Change the step of grid."""
        # print("set_step_of_grid:", system, handle)
        if hasattr(self, 'skyview') and self.skyview is not None:
            self.sky_view.step_of_grid = handle

    def set_deg(self, _unused, handle):
        """Change the degree format."""
        # print("set_deg:", _unused, handle)
        self.deg_type = handle
        if hasattr(self, 'mv_view') and self.mv_view is not None:
            self.mv_view.deg_type = handle

    def set_units(self, _unused, handle):
        """Change the display units."""
        # print("set_units:", handle)
        self.conversions = gps.clienthelpers.unit_adjustments(handle)

    # I/O monitoring and gtk housekeeping

    def watch(self, daem, dev):
        """Set up monitoring of a daemon instance."""
        self.daemon = daem
        self.device = dev
        GLib.io_add_watch(daemon.sock, GLib.PRIORITY_DEFAULT,
                          GLib.IO_IN, self.handle_response)
        GLib.io_add_watch(daemon.sock, GLib.PRIORITY_DEFAULT,
                          GLib.IO_ERR, self.handle_hangup)
        GLib.io_add_watch(daemon.sock, GLib.PRIORITY_DEFAULT,
                          GLib.IO_HUP, self.handle_hangup)

    def handle_response(self, source, condition):
        """Handle ordinary I/O ready condition from the daemon."""
        if -1 == self.daemon.read():
            self.handle_hangup(source, condition)
        if self.daemon.valid & gps.PACKET_SET:
            if ((self.device and
                 "device" in self.daemon.data and
                 self.device != self.daemon.data["device"])):
                return True
            self.rawdisplay.set_text(self.daemon.response.strip())
            if "AIS" == self.daemon.data["class"]:
                self.ais_view.update(self.daemon.data)
                if not self.ais_latch:
                    self.ais_latch = True
                    self.menumap['AIS'].set_active(True)
                    self.ais_box.show()
            elif "ATT" == self.daemon.data["class"]:
                self.att_view.update(self.daemon.data)
                self.rtk_view.update(self.daemon.data)
                if not self.att_latch:
                    self.att_latch = True
                    self.menumap['ATT'].set_active(True)
                    self.att_box.show()
                if not self.rtk_latch:
                    self.rtk_latch = True
                    self.menumap['RTK'].set_active(True)
                    self.rtk_box.show()
            elif "GST" == self.daemon.data["class"]:
                self.noise_view.update(self.daemon.data)
                if not self.noise_latch:
                    self.noise_latch = True
                    self.menumap['Noise'].set_active(True)
                    self.noisebox.show()
            elif "IMU" == self.daemon.data["class"]:
                if 'UBX-ESF-MEAS' == self.daemon.data["msg"]:
                    self.imumeas_view.update(self.daemon.data)
                    if not self.ais_latch:
                        self.imumeas_latch = True
                        self.menumap['IMU MEAS'].set_active(True)
                        self.imumeas_box.show()
                elif 'UBX-ESF-RAW' == self.daemon.data["msg"]:
                    self.imuraw_view.update(self.daemon.data)
                    if not self.imuraw_latch:
                        self.imuraw_latch = True
                        self.menumap['IMU RAW'].set_active(True)
                        self.imuraw_box.show()
            elif "SKY" == self.daemon.data["class"]:
                self.update_skyview(self.daemon.data)
            elif "TPV" == self.daemon.data["class"]:
                self.update_gpsdata(self.daemon.data)
            elif "VERSION" == self.daemon.data["class"]:
                self.update_version(self.daemon.version)

        return True

    def handle_hangup(self, _source, _condition):
        """Handle hangup condition from the daemon."""
        win = Gtk.MessageDialog(parent=self.window,
                                message_type=Gtk.MessageType.ERROR,
                                destroy_with_parent=True,
                                buttons=Gtk.ButtonsType.CANCEL)
        win.connect("destroy", lambda _unused: Gtk.main_quit())
        win.set_markup("gpsd has stopped sending data.")
        win.run()
        Gtk.main_quit()
        return True

    def main(self):
        """The main routine."""
        Gtk.main()


if __name__ == "__main__":

    usage = '%(prog)s [OPTIONS] [host[:port[:device]]]'
    epilog = ('Options can be placed in the XGPSOPTS environment variable.\n'
              'Default units can be placed in the GPSD_UNITS environment'
              ' variabla.\n\ne'
              'BSD terms apply: see the file COPYING in the distribution root'
              ' for details.')

    # get default units from the environment
    # GPSD_UNITS, LC_MEASUREMENT and LANG
    default_units = gps.clienthelpers.unit_adjustments()

    parser = argparse.ArgumentParser(usage=usage, epilog=epilog)
    parser.add_argument(
        '-?',
        action="help",
        help='show this help message and exit'
    )
    parser.add_argument(
        '-D',
        '--debug',
        dest='debug',
        default=0,
        type=int,
        help='Set level of debug. Must be integer. [Default %(default)s]',
    )
    parser.add_argument(
        '--device',
        dest='device',
        default='',
        help='The device to connect. [Default %(default)s]',
    )
    parser.add_argument(
        '--host',
        dest='host',
        default='localhost',
        help='The host to connect. [Default %(default)s]',
    )
    parser.add_argument(
        '-l',
        '--llfmt',
        choices=['d', 'm', 's'],
        dest='degreefmt',
        default='d',
        help=("Select lat/lon format. d = DD.dddddd, m = DD MM.mmmm'"
              "s = DD MM' SS.sss\" [Default %(default)s]"),
    )
    parser.add_argument(
        '--port',
        dest='port',
        default=gps.GPSD_PORT,
        help='The port to connect. [Default %(default)s]',
    )
    parser.add_argument(
        '-r',
        '--rotate',
        dest='rotate',
        default=0,
        type=float,
        help='Rotation of skyview ("up" direction) in degrees. '
             ' [Default %(default)s]',
    )
    parser.add_argument(
        '-s',
        '--sats',
        dest='maxchandisp',
        default=MAXCHANDISP,
        type=int,
        help=('Set number of sats to display in sat window. '
              '[Default %(default)s]'),
    )
    parser.add_argument(
        '-u, --units',
        choices=['i', 'imperial', 'm', 'metric', 'n', 'nautical'],
        dest='units',
        default=default_units.name,
        help='The unit of speed. [Default %(default)s]',
    )
    parser.add_argument(
        '-V', '--version',
        action='version',
        version="%(prog)s: Version " + gps_version + "\n",
        help='Output version to stderr, then exit',
    )
    parser.add_argument(
        'target',
        nargs='?',
        help='[host[:port[:device]]]',
    )

    if 'XGPSOPTS' in os.environ:
        # grab the XGPSOPTS environment variable for options
        options = os.environ['XGPSOPTS'].split(' ') + sys.argv[1:]
    else:
        options = sys.argv[1:]

    options = parser.parse_args(options)

    options.degreefmt = {
        'd': gps.clienthelpers.deg_dd,
        'm': gps.clienthelpers.deg_ddmm,
        's': gps.clienthelpers.deg_ddmmss}[options.degreefmt]

    # the options host, port, device are set by the defaults
    if options.target:
        # override with target
        arg = options.target.split(':')
        len_arg = len(arg)
        if 1 == len_arg:
            (options.host,) = arg
        elif 2 == len_arg:
            (options.host, options.port) = arg
        elif 3 == len_arg:
            (options.host, options.port, options.device) = arg
        else:
            parser.print_help()
            sys.exit(0)

    if not options.port:
        options.port = gps.GPSD_PORT

    ltarget = [options.host, options.port, options.device]
    target = ':'.join(ltarget)

    if 'DISPLAY' not in os.environ or not os.environ['DISPLAY']:
        sys.stderr.write("xgps: ERROR: DISPLAY not set\n")
        sys.exit(1)

    base = Base(deg_type=options.degreefmt, rotation=options.rotate,
                title=target)
    # If we're debuuging, stop here so we can set breakpoints
    pdb_module = sys.modules.get('pdb')
    if pdb_module:
        pdb_module.set_trace()
    base.set_units(None, options.units)
    try:
        sys.stderr.write("xgps: host %s port %s\n" %
                         (options.host, options.port))
        daemon = gps.gps(host=options.host,
                         port=options.port,
                         mode=(gps.WATCH_ENABLE | gps.WATCH_JSON |
                               gps.WATCH_SCALED),
                         verbose=options.debug)
        base.watch(daemon, options.device)
        base.main()
    except socket.error:
        w = Gtk.MessageDialog(parent=base.window,
                              message_type=Gtk.MessageType.ERROR,
                              destroy_with_parent=True,
                              buttons=Gtk.ButtonsType.CANCEL)
        w.set_markup("gpsd is not running on host %s port %s" %
                     (options.host, options.port))
        w.run()
        w.destroy()
    except KeyboardInterrupt:
        # ^C, die
        pass

# vim: set expandtab shiftwidth=4
