"""
signalsview_frame.py
Signals view frame class for PyGPSClient application.
This handles a frame containing a graph of current signal C/No level,
correction source and other signal-related flags.
Created on 24 Dec 2025
:author: semuadmin (Steve Smith)
:copyright: 2020 semuadmin
:license: BSD 3-Clause
"""
# pylint: disable=no-member, unused-variable, duplicate-code
from tkinter import ALL, NSEW, NW, SE, Frame, N, S, font
from pyubx2 import CORRSOURCE, SIGID, UBXMessage
from pygpsclient.canvas_subclasses import (
TAG_DATA,
TAG_GRID,
TAG_WAIT,
TAG_XLABEL,
TAG_YLABEL,
CanvasGraph,
)
from pygpsclient.globals import (
BGCOL,
FGCOL,
GNSS_LIST,
GRIDMAJCOL,
MAX_SNR,
MAXWAIT,
PNTCOL,
SIGNALSVIEW,
WIDGETU3,
)
from pygpsclient.helpers import col2contrast, fitfont, setubxrate
from pygpsclient.strings import DLGNONAVSIG, DLGWAITNAVSIG
OL_WID = 1
FONTSCALELG = 40
XLBLANGLE = 60
XLBLFMT = "000 WWW_W/W"
# Correction source legend
CSLEG = ", ".join(
f"{key} {val}" for key, val in CORRSOURCE.items() if key != 0
).replace(", 7", ",\n7")
CL = "A" * len(CSLEG.split("\n", 1)[0])
[docs]
def unused_sigs(data: dict) -> int:
"""
Get number of 'unused' sigs in gnss_data.sig_data.
:param dict data: sig_data
:return: number of sigs where cno = 0
:rtype: int
"""
return sum(1 for (_, _, _, cno, _, _, _, _) in data.values() if cno == 0)
[docs]
class SignalsviewFrame(Frame):
"""
Signalsview frame class.
"""
[docs]
def __init__(self, app: Frame, parent: Frame, *args, **kwargs):
"""
Constructor.
:param Frame app: reference to main tkinter application
:param Frame parent: reference to parent frame
:param args: optional args to pass to Frame parent class
:param kwargs: optional kwargs to pass to Frame parent class
"""
self.__app = app # Reference to main application class
self.__master = self.__app.appmaster # Reference to root class (Tk)
super().__init__(parent, *args, **kwargs)
def_w, def_h = WIDGETU3
self.width = kwargs.get("width", def_w)
self.height = kwargs.get("height", def_h)
self._redraw = True
self._pending_confs = {}
self._waits = 0
self._waiting = True
self._body()
self._attach_events()
self.enable_messages(True)
def _body(self):
"""
Set up frame and widgets.
"""
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
self._canvas = CanvasGraph(
self.__app, self, width=self.width, height=self.height, bg=BGCOL
)
self._canvas.grid(column=0, row=0, sticky=NSEW)
def _attach_events(self):
"""
Bind events to frame.
"""
self.bind("<Configure>", self._on_resize)
self._canvas.bind("<Double-Button-1>", self._on_legend)
self._canvas.bind("<Double-Button-2>", self._on_cno0)
self._canvas.bind("<Double-Button-3>", self._on_cno0)
def _on_legend(self, event): # pylint: disable=unused-argument
"""
On double-click - toggle legend on/off.
:param event: event
"""
self.__app.configuration.set(
"legend_b", not self.__app.configuration.get("legend_b")
)
self._redraw = True
def _on_cno0(self, event): # pylint: disable=unused-argument
"""
On double-right-click - include signals where C/No = 0.
:param event: event
"""
self.__app.configuration.set(
"unusedsat_b", not self.__app.configuration.get("unusedsat_b")
)
self._redraw = True
[docs]
def enable_messages(self, status: bool):
"""
Enable/disable UBX NAV-SIG message.
:param bool status: 0 = off, 1 = on
"""
setubxrate(self.__app, "NAV-SIG", status)
for msgid in ("ACK-ACK", "ACK-NAK"):
self._set_pending(msgid, SIGNALSVIEW)
def _set_pending(self, msgid: int, ubxfrm: int):
"""
Set pending confirmation flag for Signalsview frame to
signify that it's waiting for a confirmation message.
:param int msgid: UBX message identity
:param int ubxfrm: integer representing UBX configuration frame
"""
self._pending_confs[msgid] = ubxfrm
[docs]
def update_pending(self, msg: UBXMessage):
"""
Receives polled confirmation message from the ubx_handler and
updates signalsview canvas.
:param UBXMessage msg: UBX config message
"""
pending = self._pending_confs.get(msg.identity, False)
if pending and msg.identity == "ACK-NAK":
self.reset()
w, h = self.width, self.height
self._canvas.create_text(
w / 2,
h / 2,
text=DLGNONAVSIG,
fill=PNTCOL,
anchor=S,
tags=TAG_DATA,
)
self._pending_confs.pop("ACK-NAK")
if self._pending_confs.get("ACK-ACK", False):
self._pending_confs.pop("ACK-ACK")
[docs]
def reset(self):
"""
Reset spectrumview frame.
"""
self.__app.gnss_status.sig_data = []
self._canvas.delete(ALL)
self.update_frame()
[docs]
def init_frame(self):
"""
Initialise graph view
"""
# only redraw the tags that have changed
tags = (TAG_GRID, TAG_XLABEL, TAG_YLABEL, TAG_WAIT) if self._redraw else ()
self._canvas.create_graph(
xdatamax=10,
ydatamax=(MAX_SNR,),
xtickmaj=10,
ytickmaj=int(MAX_SNR / 10),
ylegend=("C/No dBHz",),
ycol=(FGCOL,),
ylabels=True,
xlabelsfrm=XLBLFMT,
xangle=XLBLANGLE,
fontscale=FONTSCALELG,
tags=tags,
)
self._redraw = False
def _draw_legend(self):
"""
Draw GNSS color code and correction source legends
"""
w = self.width / 12 / 2
h = self.height / 18
# gnssid color code legend
lgfont = font.Font(size=int(min(self.width / 2, self.height) / FONTSCALELG))
for i, (_, (gnssName, gnssCol)) in enumerate(GNSS_LIST.items()):
x = (self._canvas.xoffl * 2) + w * i
self._canvas.create_rectangle(
x,
self._canvas.yofft,
x + w - 5,
self._canvas.yofft + h,
outline=GRIDMAJCOL,
fill=gnssCol,
width=OL_WID,
tags=TAG_XLABEL,
)
self._canvas.create_text(
(x + x + w - 5) / 2,
self._canvas.yofft + h / 2,
text=gnssName,
fill=col2contrast(gnssCol),
font=lgfont,
tags=TAG_XLABEL,
)
# correction source legend
xfnt, _, _, _ = fitfont(
CL, self.width / 2 - self._canvas.xoffl, h / 2, maxsiz=12
)
self._canvas.create_text(
self.width / 2,
self._canvas.yofft + 1,
text=f"Correction Source:\n{CSLEG}",
fill=FGCOL,
font=xfnt,
anchor=NW,
tags=TAG_DATA,
)
[docs]
def update_frame(self):
"""
Plot signal signal-to-noise ratio (C/No).
Automatically adjust y axis according to number of satellites in view.
"""
data = self.__app.gnss_status.sig_data
if len(data) == 0:
if self._waits >= MAXWAIT:
self._canvas.create_alert(DLGNONAVSIG, tags=TAG_WAIT)
else:
self._waits += 1
else:
self._waiting = False
self._waits = 0
show_unused = self.__app.configuration.get("unusedsat_b")
siv = len(data)
siv = siv if show_unused else siv - unused_sigs(data)
if siv <= 0:
return
w, h = self.width, self.height
self.init_frame()
offset = self._canvas.xoffl
colwidth = (w - self._canvas.xoffl - self._canvas.xoffr + 1) / siv
xfnt, _, _, _ = fitfont(
XLBLFMT,
colwidth * 1.66,
self._canvas.yoffb,
XLBLANGLE,
)
for val in sorted(data.values()): # sort by ascending gnssid, svid, sigid
gnssId, prn, sigid, cno, corrsource, quality, flags, _ = val
if cno == 0 and not show_unused:
continue
sig = SIGID.get((gnssId, sigid), sigid)
snr_y = int(cno) * (h - self._canvas.yoffb - 1) / MAX_SNR
_, ol_col = GNSS_LIST[gnssId]
prn = f"{int(prn):02}"
self._canvas.create_rectangle(
offset,
h - self._canvas.yoffb - 1,
offset + colwidth - OL_WID,
h - self._canvas.yoffb - snr_y - 1,
outline=GRIDMAJCOL,
fill=ol_col,
width=OL_WID,
tags=TAG_DATA,
)
# xlabel prn - sigid
self._canvas.create_text(
offset + colwidth,
h - self._canvas.yoffb + 3,
text=f"{prn} {sig}",
fill=FGCOL,
font=xfnt,
angle=XLBLANGLE,
anchor=SE,
tags=TAG_DATA,
)
# xcaption corrsource if > 0
if corrsource:
self._canvas.create_text(
offset + colwidth / 2,
h - self._canvas.yoffb - snr_y + 2,
text=corrsource,
fill=col2contrast(ol_col),
font=xfnt,
anchor=N,
tags=TAG_DATA,
)
offset += colwidth
if self.__app.configuration.get("legend_b"):
self._draw_legend()
self.update_idletasks()
def _on_resize(self, event): # pylint: disable=unused-argument
"""
Resize frame
:param event event: resize event
"""
self.width, self.height = self.get_size()
self._redraw = True
self._on_waiting()
def _on_waiting(self):
"""
Display 'waiting for data' alert.
"""
if self._waiting:
txt = DLGNONAVSIG if self._waits >= MAXWAIT else DLGWAITNAVSIG
self._canvas.create_alert(txt, tags=TAG_WAIT)
[docs]
def get_size(self):
"""
Get current canvas size.
:return: window size (width, height)
:rtype: tuple
"""
self.update_idletasks() # Make sure we know about any resizing
return self._canvas.winfo_width(), self._canvas.winfo_height()