From 499a89109ba1025dec29e717f06c27ada404f7a6 Mon Sep 17 00:00:00 2001 From: Mike Rosseel Date: Sun, 5 Jul 2026 23:29:38 +0200 Subject: [PATCH 1/3] fix(gps): correct UBX NAV-SVINFO field offsets (off by one) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repeated block in UBX-NAV-SVINFO is chn, svid, flags, quality, cno, elev, azim — but the parser started reading at chn, so every field was shifted one byte: satellite IDs were channel numbers, the used flag was svid bit 0, and the C/N0 was the 0-7 quality indicator. Because "cno" was really the quality indicator (>=1 even for idle SBAS/QZSS channels still searching), the sats-seen count was inflated to near the full channel count, and the per-satellite used flags were random — which is why uSat was previously treated as stale and ignored. With the offsets fixed: - nSat only counts satellites with an actual signal (cno > 0) - uSat from SVINFO is valid, so report it as the used count instead of relying solely on NAV-SOL - elevation/azimuth are decoded as signed values per the spec Also update the used count from NAV-PVT numSV, which was parsed but never surfaced — on protVer >= 15 receivers gpsd enables NAV-PVT instead of NAV-SOL, so the used count stayed 0 forever despite a valid fix. And actually set got_sat_update when NAV-SAT arrives so the SVINFO fallback defers to it as intended. Verified against a live MAX-M8 (PROTVER 14) stream: SVINFO uSat now matches NAV-SOL numSV, and IDs/C-N0 match gpsd's own SKY decode. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NrdGJ1s3jmA9qzV8HAsdZs --- python/PiFinder/gps_ubx.py | 11 ++++- python/PiFinder/gps_ubx_parser.py | 13 +++--- python/tests/test_gps_ubx_parser.py | 71 +++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 python/tests/test_gps_ubx_parser.py diff --git a/python/PiFinder/gps_ubx.py b/python/PiFinder/gps_ubx.py index dc56bf07..95301495 100644 --- a/python/PiFinder/gps_ubx.py +++ b/python/PiFinder/gps_ubx.py @@ -32,14 +32,18 @@ async def process_messages( elif msg_class == "NAV-SVINFO" and not got_sat_update: # Fallback satellite info if NAV-SAT not available if "nSat" in msg: - # uSat is also in the message but contains stale info sats_seen = msg["nSat"] + sats_used = msg["uSat"] sats[0] = sats_seen + sats[1] = sats_used gps_queue.put(("satellites", tuple(sats))) - logger.debug("Number of sats (SVINFO) seen: %i", sats_seen) + logger.debug( + "Number of sats (SVINFO) seen: %i, used: %i", sats_seen, sats_used + ) elif msg_class == "NAV-SAT": # Preferred satellite info source - not seen in the current pifinder gps versions + got_sat_update = True sats_seen = msg["nSat"] sats_used = sum( 1 for sat in msg.get("satellites", []) if sat.get("used", False) @@ -95,6 +99,9 @@ async def process_messages( logger.debug(f"TIMEGPS message does not qualify: {msg}") elif msg_class == "NAV-PVT": + if "numSV" in msg: + sats[1] = msg["numSV"] + gps_queue.put(("satellites", tuple(sats))) if all(k in msg for k in ["lat", "lon", "altHAE", "hAcc", "vAcc"]): if not gps_locked and msg["hAcc"] < MAX_GPS_ERROR: gps_locked = True diff --git a/python/PiFinder/gps_ubx_parser.py b/python/PiFinder/gps_ubx_parser.py index f0bb7da4..1f443114 100644 --- a/python/PiFinder/gps_ubx_parser.py +++ b/python/PiFinder/gps_ubx_parser.py @@ -358,12 +358,13 @@ def _parse_nav_svinfo(self, data: bytes) -> dict: logger.warning(f"SVINFO: Message truncated at satellite {i}") break - svid = data[offset] - flags = data[offset + 1] - quality = data[offset + 2] - cno = data[offset + 3] - elev = data[offset + 4] - azim = int.from_bytes(data[offset + 6 : offset + 8], "little") + # Repeated block layout: chn, svid, flags, quality, cno, elev, azim, prRes + svid = data[offset + 1] + flags = data[offset + 2] + quality = data[offset + 3] + cno = data[offset + 4] + elev = int.from_bytes(data[offset + 5 : offset + 6], "little", signed=True) + azim = int.from_bytes(data[offset + 6 : offset + 8], "little", signed=True) is_used = bool(flags & 0x01) if is_used: diff --git a/python/tests/test_gps_ubx_parser.py b/python/tests/test_gps_ubx_parser.py new file mode 100644 index 00000000..c30b5115 --- /dev/null +++ b/python/tests/test_gps_ubx_parser.py @@ -0,0 +1,71 @@ +import struct + +import pytest + +from PiFinder.gps_ubx_parser import UBXParser + + +def make_svinfo_channel(chn, svid, flags, quality, cno, elev, azim, prres=0): + """Build one 12-byte UBX-NAV-SVINFO repeated block.""" + return struct.pack(" 0 but cno == 0 + # and must not inflate the seen count. + payload = make_svinfo_payload( + [ + (0, 14, 0x0D, 4, 26, 30, 90), + (11, 120, 0x10, 1, 0, 0, 0), + (5, 193, 0x10, 1, 0, 0, 0), + ] + ) + result = parser._parse_nav_svinfo(payload) + + assert result["nSat"] == 1 + assert result["uSat"] == 1 + assert result["satellites"][0]["id"] == 14 + + +@pytest.mark.unit +def test_svinfo_too_short(parser): + assert "error" in parser._parse_nav_svinfo(b"\x00" * 4) From e8d57717b8de296d49d747d4c4aaca3004facbff Mon Sep 17 00:00:00 2001 From: Mike Rosseel Date: Sun, 5 Jul 2026 23:33:08 +0200 Subject: [PATCH 2/3] fix(gps): decode NAV-SAT per spec for protVer >= 15 receivers Newer u-blox receivers (later M8 firmware, M9, M10) get NAV-PVT + NAV-SAT from gpsd instead of NAV-SOL + NAV-SVINFO, so this path is what recent PiFinder GPS units actually exercise. - take "used" from the dedicated svUsed flag (bit 3) instead of inferring it from the quality indicator - only count satellites with an actual signal: NAV-SAT lists every known satellite, so nSat was inflated by idle entries - decode elevation/azimuth as signed values Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NrdGJ1s3jmA9qzV8HAsdZs --- python/PiFinder/gps_ubx_parser.py | 38 ++++++++++++++--------------- python/tests/test_gps_ubx_parser.py | 27 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/python/PiFinder/gps_ubx_parser.py b/python/PiFinder/gps_ubx_parser.py index 1f443114..aff539ad 100644 --- a/python/PiFinder/gps_ubx_parser.py +++ b/python/PiFinder/gps_ubx_parser.py @@ -312,28 +312,26 @@ def _parse_nav_sat(self, data: bytes) -> dict: gnssId = data[offset] svId = data[offset + 1] cno = data[offset + 2] - elev = data[offset + 3] - azim = int.from_bytes(data[offset + 4 : offset + 6], "little") - flags = data[ - offset + 8 - ] # Warning this is a 4 byte field of flags, we're only using the first byte - # lowest 3 bits are a quality indicator and according to - # https://portal.u-blox.com/s/question/0D52p000097B0bFCAS/interpretation-of-signal-quality-indicator-in-ubxnavsat - # the 0-7 values from an ordered scale. So taking 3 as the threshold below.q - satellites.append( - { - "id": svId, - "system": gnssId, - "signal": cno, - "elevation": elev, - "azimuth": azim, - "used": (flags & 0x07) > 3, # lowest 3 bits are used for the status - "flags": flags & 0x07, - } - ) + elev = int.from_bytes(data[offset + 3 : offset + 4], "little", signed=True) + azim = int.from_bytes(data[offset + 4 : offset + 6], "little", signed=True) + flags = data[offset + 8] # X4 bitfield, only the first byte is needed here: + # bits 0-2 are the quality indicator, bit 3 is svUsed + # NAV-SAT lists all known satellites; only count those with a signal + if cno > 0: + satellites.append( + { + "id": svId, + "system": gnssId, + "signal": cno, + "elevation": elev, + "azimuth": azim, + "used": bool(flags & 0x08), + "quality": flags & 0x07, + } + ) result = { "class": "NAV-SAT", - "nSat": sum(1 for sat in satellites), + "nSat": len(satellites), "satellites": satellites, } logger.debug(f"NAV-SAT result: {result}") diff --git a/python/tests/test_gps_ubx_parser.py b/python/tests/test_gps_ubx_parser.py index c30b5115..e7dca34b 100644 --- a/python/tests/test_gps_ubx_parser.py +++ b/python/tests/test_gps_ubx_parser.py @@ -69,3 +69,30 @@ def test_svinfo_idle_channels_not_counted_as_seen(parser): @pytest.mark.unit def test_svinfo_too_short(parser): assert "error" in parser._parse_nav_svinfo(b"\x00" * 4) + + +def make_nav_sat_block(gnss_id, sv_id, cno, elev, azim, flags): + return struct.pack(" Date: Mon, 6 Jul 2026 14:28:54 +0200 Subject: [PATCH 3/3] fix(gps): don't count acquisition candidates as satellites seen During cold start the receiver reports an estimated C/N0 for almanac-predicted satellites it is still trying to confirm, so a cno > 0 filter makes the seen count start around 20+ and sink to the real value as candidates fail to confirm. Gate on the quality indicator instead: only code-locked signals (quality >= 4) count as seen, in both NAV-SVINFO and NAV-SAT. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NrdGJ1s3jmA9qzV8HAsdZs --- python/PiFinder/gps_ubx_parser.py | 16 +++++++++++++--- python/tests/test_gps_ubx_parser.py | 8 +++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/python/PiFinder/gps_ubx_parser.py b/python/PiFinder/gps_ubx_parser.py index aff539ad..0c41f41b 100644 --- a/python/PiFinder/gps_ubx_parser.py +++ b/python/PiFinder/gps_ubx_parser.py @@ -14,6 +14,11 @@ logger = logging.getLogger("GPS.parser") +# u-blox quality indicator (qualityInd / flags bits 0-2) value at which the +# signal is code locked; below this the receiver is still searching and any +# reported C/N0 is an unconfirmed acquisition candidate. +QUALITY_CODE_LOCKED = 4 + class UBXClass(IntEnum): NAV = 0x01 @@ -316,8 +321,9 @@ def _parse_nav_sat(self, data: bytes) -> dict: azim = int.from_bytes(data[offset + 4 : offset + 6], "little", signed=True) flags = data[offset + 8] # X4 bitfield, only the first byte is needed here: # bits 0-2 are the quality indicator, bit 3 is svUsed - # NAV-SAT lists all known satellites; only count those with a signal - if cno > 0: + # NAV-SAT lists every known satellite, including acquisition + # candidates with an estimated C/N0; only count tracked signals + if (flags & 0x07) >= QUALITY_CODE_LOCKED: satellites.append( { "id": svId, @@ -368,7 +374,11 @@ def _parse_nav_svinfo(self, data: bytes) -> dict: if is_used: used_sats += 1 - if cno > 0: + # During cold-start acquisition the receiver reports estimated + # C/N0 for candidates it is still searching for; counting those + # makes the seen count start high and sink as they fail to + # confirm. Only quality >= 4 (code locked) is really tracked. + if quality >= QUALITY_CODE_LOCKED: satellites.append( { "id": svid, diff --git a/python/tests/test_gps_ubx_parser.py b/python/tests/test_gps_ubx_parser.py index e7dca34b..c4265d7e 100644 --- a/python/tests/test_gps_ubx_parser.py +++ b/python/tests/test_gps_ubx_parser.py @@ -49,12 +49,13 @@ def test_svinfo_field_alignment(parser): @pytest.mark.unit -def test_svinfo_idle_channels_not_counted_as_seen(parser): - # Searching/idle channels (e.g. SBAS) report quality > 0 but cno == 0 - # and must not inflate the seen count. +def test_svinfo_only_code_locked_counted_as_seen(parser): + # Idle channels (e.g. SBAS) and cold-start acquisition candidates + # (quality < 4 with an estimated cno) must not inflate the seen count. payload = make_svinfo_payload( [ (0, 14, 0x0D, 4, 26, 30, 90), + (7, 25, 0x00, 2, 9, 0, 0), (11, 120, 0x10, 1, 0, 0, 0), (5, 193, 0x10, 1, 0, 0, 0), ] @@ -87,6 +88,7 @@ def test_nav_sat_used_from_svused_bit(parser): [ (0, 17, 27, 45, 180, 0x0C), # quality 4, used (0, 13, 15, -5, 300, 0x04), # quality 4, tracked but not used + (0, 25, 9, 0, 0, 0x02), # acquisition candidate: not seen (6, 3, 0, 0, 0, 0x01), # searching, no signal: not seen ] )