Skip to content

Add high-level configuration for Septentrio receivers#354

Open
jclark wants to merge 528 commits into
septentrio-msgfilefrom
septentrio-config
Open

Add high-level configuration for Septentrio receivers#354
jclark wants to merge 528 commits into
septentrio-msgfilefrom
septentrio-config

Conversation

@jclark

@jclark jclark commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Add Tier 1 high-level (device-independent) configuration for Septentrio receivers: the ConfigProtocol/Configurator implementation in gps/internal/septentrio (the scfg*.go files) and its coarse Septentrio-name to gpsprot.Signal/SignalSet table.

With this, satpulsetool gps and satpulsed auto-configuration can drive a Septentrio receiver through the device-independent interface: probing and --show-receiver/--show-config, GNSS and signal selection, time-pulse setup, minimum elevation, antenna cable delay, OSNMA navigation-message authentication, survey / static / fixed-position modes, NMEA/binary/raw/RTCM output control, and save/reload/reset/factory-reset.

Stacked on #347 (septentrio-msgfile), which supplies the $R reply PacketFormat this configurator relies on. Part of the Septentrio branch stack in PLAN.md; design in plan/septentrio-config.md.

Depends on #353 (allow multiple probe packets): the escape-before-probe sequence needs ConfigProtocol.ProbePackets. Until #353 merges to master and propagates up the stack, that commit's gpscfg, ubx, and unicore changes also appear in this diff.

Fixes #341.

jclark added 30 commits May 22, 2026 09:53
Adapt to the new msgfile.ToRaw(msgs, port, save) signature from the
master merge, which added ubxval/ubxvalport message types carrying
--port and --save.

- Add PropIDPort to the config readback so ReadConfig returns the
  active receiver port; seed the message tab's port dropdown from it.
- Tag the loaded message file's tags with NeedsPort/SaveAware so the
  frontend knows which messages take a port or honor save.
- Add a port dropdown and save checkbox to the send row. They are
  shown only when relevant (a readback port or a port/save-aware tag
  is present) and greyed by the selected message; Send is gated on a
  port when the selected message requires one. The Send button stays
  in place across message selection.
The Wails app built on macOS and Windows but not Linux. Two Linux-specific
issues blocked it:

- Current distros (Ubuntu 24.04+, Debian trixie+, Fedora 40+) ship only the
  WebKitGTK 4.1 / libsoup3 webview, which Wails links via the webkit2_41 build
  tag. The Makefile now passes this tag on Linux, selected by a WEBKIT variable
  (default 4.1; use `make WEBKIT=4.0` on older 4.0-only distros). The logic
  lives in the recipe shell so no GNU make extensions are needed.

- The Debian/Ubuntu npm default install-links=true copies the file: dep
  @satpulse/gps (gps/ts) into node_modules, dragging its .go files along; the
  '@' in the path then breaks the Go build. A desktop/frontend/.npmrc with
  install-links=false restores the symlink behaviour macOS and Windows already
  use, leaving the .go sources behind a link Go ignores.

Also documents the Linux GTK/WebKitGTK prerequisites and per-platform run
commands in the README and CLAUDE.md.
Package the desktop GUI as a .deb for the Debian-based distro and architecture
it is built on (Debian trixie and derivatives). Unlike the main repo Makefile,
which hand-assembles a tree and calls dpkg-deb, this uses the standard
debhelper / dpkg-buildpackage flow, so runtime dependencies -- notably the
WebKitGTK 4.1 stack -- are computed automatically by dpkg-shlibdeps.

The package (satpulse-gui) ships just the binary plus a .desktop launcher and
icon. The version follows the same convention as the main satpulse .deb, and a
friendly GH_RELEASE-named symlink points at the versioned file, as the main
package does. `make deb` runs the script; build/*.deb is gitignored.
Build the desktop .deb in a Docker container for a chosen Debian-based distro
and architecture, so packages can be produced for Debian trixie and derivatives
(and arm64 for Raspberry Pi) from any machine with Docker.

- Dockerfile: parameterized by BASE_IMAGE (target distro) and --platform (arch).
  Go and Node are installed from upstream at a version line (GO_VERSION /
  NODE_VERSION, latest patch resolved from the vendor index); the Wails CLI
  version is read from desktop/go.mod so the CLI and library stay in sync. The
  final stage exports a single versioned .deb via --output type=local.
- docker-build-deb.sh: ./docker-build-deb.sh <amd64|arm64>, extracting to
  out/<arch>/. Documented env overrides: BASE_IMAGE, GO_VERSION, NODE_VERSION,
  WAILS_VERSION, OUT, NO_CACHE, PULL, PROGRESS.
- Split the version/symlink logic out of build-deb.sh: deb-version.sh is the
  single source of the version and GH_RELEASE name; deb-symlink.sh makes the
  friendly symlink separately, so a container build exports just one file.
- Makefile: deb now builds the deb and the symlink; deb-docker-amd64 and
  deb-docker-arm64 drive the container builds.
- .dockerignore keeps the build context small while retaining .git for the
  version derivation.
The build context is a git worktree, so its .git is a pointer file to a
path outside the container. git therefore fails inside the container and
deb-version.sh cannot derive a version, producing an empty version and a
malformed changelog that dpkg-buildpackage rejects.

Compute the version on the host in docker-build-deb.sh and pass it via a
DEB_VERSION build-arg to build-deb.sh, which already accepts an explicit
version argument.
Add desktop/plan/vrs.md (send position as NMEA to a VRS caster, with the single-owner readiness design) and index it in plan/README.md. Expand the spartn issue with the forwarding change and the unified corrections-message table UI.
Scan the corrections consume stream with gpsreg.CreateCorrectionFormats()
(RTCM + SPARTN) instead of a hand-rolled RTCM-only list, so SPARTN packets
pulled from the source are recognised and written to the receiver, which
applies them itself.

Make the corrections message panel protocol-neutral: rename rtcm-panel.tsx/
RtcmPanel to cor-msg-panel.tsx/CorMsgPanel, add an R/S badge column from the
report tag, show the msgID verbatim in Type, and source the description from
rtcmInfo or the new spartn.ts. Station ID/MSM/Splits stay blank for SPARTN.
The vrs merge moved the source, writer, port lock, and packet formats
from Pull.Run into NewPull, and changed Run to take a selected-GGA feed.
Update StartCorrections to the new signatures, passing a nil GGA feed and
a zero send interval so behaviour is unchanged: consume RTCM and SPARTN
with no upstream GGA upload.
The plan described the GGASelector synth sink as GGASentence(gga); the
actual nmeasyn.Sink method is Msg(m nmeamsg.GNSSTalkerIDMsg, phase).
Replace the plan's replicate-the-private-check approach with a shared
stream.GGAPacketPosition(pkt) ([2]float64, bool) helper (a vrs-branch
addition factored from parseSelectedGGA). The desktop GGA monitor calls
it for both the lat/lon display and the readiness gate, so the gate and
the GGASender upload agree by construction instead of via two copies.
Add VRS support to the Corrections tab: synthesize a GGA each epoch from
the receiver's navigation messages, display the live position, and -- for
ntrip sources -- optionally upload it to the caster so a VRS caster can
serve corrections for the receiver's location.

A connection-scoped GGA monitor is the single owner of readiness: it is
the sole reader of the GGASelector feed, judges each packet with
stream.GGAPacketPosition (the same predicate the uploader uses), emits
gps:nmeaPosition for the live Position display, and while an NMEA-send
session is active forwards the feed into Pull.Run, primed with the latest
usable GGA so the upload goes out without delay. It tracks the current
fix: an unusable GGA drops the held position and emits a cleared event,
so the Position display and Connect gate reflect the current fix rather
than the last one. StartCorrections gates atomically on a usable position
and refuses with "waiting for a position fix" instead of stalling.

The frontend adds a read-only Position field, a "Send position as NMEA"
checkbox (ntrip only), and greys Connect until a position is available.
Three issues noticed while testing VRS corrections:

- Slow message types (e.g. SPARTN HPAC, sent every ~30s) were dimmed
  between arrivals because staleness used a fixed 10s threshold. Dim
  adaptively instead: track each row's measured update interval and dim
  at max(10s, 2.5x interval), and never dim a row whose cadence is not
  yet known.

- Age kept climbing while corrections were disconnected. Freeze "now"
  (and stop the 1s tick) when the session is not running, so ages hold
  at their last value.

- The protocol badge was too faint to read. Give RTCM and SPARTN their
  own colour tokens (blue/red, same palette family as the GNSS
  constellation colours) and a coloured Badge tone each.
gpsprot.Signal.String() now returns GNSS-qualified names (e.g. GPSL1)
for most constellations, while the signalsEnabled readback
(SignalSet.GNSSSignalMap) and apply parser (ParseSignalMap) both use
unqualified names. The catalog built from sig.String() no longer matched,
so GPS/GLO showed as disabled on readback (only BeiDou/Galileo, whose
names are left unqualified, matched), and applying signal changes sent
unparseable names that hung the Apply button.

Build the catalog with sig.UnqualifiedName() to match.
handleApply called setApplying(false) only on the normal await path, so a
rejected ApplyConfig binding call left the Apply button greyed out and
stuck on "Applying..." forever. Move the call into try/catch/finally:
finally always clears the applying flag, and the catch surfaces the error
as a toast and failed operation status.
The base ARP belongs to the active correction stream. Drop it on a
'connecting' or 'stopped' corrections event so a stale RTCM ARP does not
linger after switching to a source that sends no 1005/1006 (e.g. SPARTN).
jclark added 29 commits July 10, 2026 23:20
The single-seat design let one workbench window exist at a time: a new seat claim terminated the old window's SSE streams, and the dethroned window died behind a full-screen takeover overlay. Terminating streams was the only way to notify the loser, because the seat value is secret and nothing was broadcastable, and it cascaded complexity: per-client kill machinery in the SSE hub, a 204 stale-seat path with claim-vs-subscribe locking, and an ambiguous terminal EventSource close (stale seat vs stale token) that needed a probe request to disambiguate. It also enforced more than needed: every real interference between windows goes through a mutating POST, while pure observation is broadcast-driven and correct under any number of windows.

Replace it with a writer seat, per plan/wb-writer-seat.md: at most one window holds the write seat and every other window is a live read-only viewer; no stream is ever terminated. A claim now mints the secret seat together with a public grant, broadcast as a sticky writer SSE event, so each window determines for itself whether it is the writer. The newest claim always wins, so a fresh window (or a reload) is always writable with no friction. POSTs split into writer-gated (connect, disconnect, config, corrections; 410 on a stale or missing seat) and reader (signals, decode-packet, geo conversions; token and JSON only), and /sse is seat-free again. The hub seeds a no-holder grant at startup so a tab surviving a tokenless restart primes a grant that cannot match its own and flips read-only rather than rendering writable against the empty seat.

The frontend replaces the takeover overlay with a connection-bar notice, "Read-only: this workbench has been taken over by another window", plus a Use here button that re-claims in place: the streams never closed, so the window keeps its scroll positions and drafts and becomes the writer without a reload. The broadcast grant is the sole authority for the writable flag, so a stale claim response or a stale 410 arriving after a reclaim cannot leave the window rendered wrongly. Mutating controls disable alongside the existing connection-state checks, and the Configuration panel's automatic readback is gated on holding the seat so a viewer never POSTs readbacks in the background. A terminal EventSource close now unambiguously means stale authentication, so the probe and the synthetic terminal-event latch are gone. The desktop transport is untouched and remains always-writer.

The smoke tests drop the context-owned seat entirely: wb_url carries only the token, checks that write claim a seat with the new common.wb_claim and pass it explicitly, the takeover check reads the still-open stream synchronously instead of via a reader thread, and check_wb_csrf targets a reader POST, where the content-type gate is the only cross-site defence.

Part of #357.
The compiled-in default search path was a Unix path list assembled in
one function: ~/.satpulse/gpsmsg followed by /usr/local/share and
/usr/share. It was wrong everywhere but Linux. os.UserHomeDir gives a
dotdir that is not the convention on any platform, the two share paths
are meaningless off Unix, and the whole list was already inconsistent
with the Homebrew tap, which installs the library under its prefix
(/opt/homebrew on Apple silicon, /usr/local on Intel). A test that set
HOME to a temp dir failed on Windows CI, where os.UserHomeDir reads
USERPROFILE, not HOME.

Take the user's own library from os.UserConfigDir (satpulse/gpsmsg
under it), which is right on each platform without a switch:
%AppData% on Windows, ~/Library/Application Support on macOS,
~/.config (honouring XDG_CONFIG_HOME) on Linux. Move the installed
locations into build-tagged msgdirs_*.go files in cmd/satpulsewb,
where the system-dependent knowledge belongs, because the mechanism
differs and not just the string. Linux keeps the absolute FHS paths,
local before packaged. macOS uses the Homebrew prefix, itself
architecture-dependent, so it splits again into
msgdirs_darwin_{arm64,amd64}.go. Windows has no shared data
hierarchy, so the library sits beside the executable. gps/msgfile
keeps only the portable lookup.

defaultDirs is split from msgDirs so the ordering is tested against a
fixed configuration directory, with no environment variable whose
meaning changes per OS; a second subtest asserts systemDirs is
absolute on whatever platform runs it. The man page, NEWS, internals,
and plan are updated to the per-platform defaults.
wb-multi-window.md described the writer seat as a future "part 1" with a read-only checkbox and claim modes, which no longer matches what was built, and carried the full multi-writer analysis as "part 2". Rewrite it as the current design document: the model as implemented (one write seat, newest claim wins, every other window a live read-only viewer, no read-only mode a window can choose) plus the remaining work to make viewer tabs fully live - the config composite-snapshot broadcast, the Messages loaded-file and send-activity broadcast (after wb-msgfile), and the corrections presentation fixes. It replaces wb-writer-seat.md, which gets a note recording that it was implemented in e8341e4 and now stands as the record of the change itself.

The multi-writer analysis moves to wb-multi-window-write.md, framed as a possible follow-on with the case against it stated up front: with one writer there are no concurrent edits or operation collisions, and the message-file version map loses its main driver. The state taxonomy moves with it, since those rules exist to make concurrent writers coherent; the one place the one-writer plan relied on them (the config readback trigger) now states its rule inline.

The satpulseweb.md delivery-stack entry and the archived single-seat plan are updated to match.
The writer seat carried two random values per claim: a "secret" seat presented on writer POSTs and a "public" grant broadcast as the sticky writer event. The secrecy bought nothing. Every window that can claim is equally trusted, so the 410 check is a freshness check, not authentication; a cross-site page cannot read the broadcast anyway (EventSource is CORS-gated, and with a token it cannot open the stream at all); and a direct network client under -L can simply claim the seat itself, as the man page already concedes. The split was inherited from the rejected takeover design, where the grant was invented solely to avoid broadcasting the seat.

Claiming now mints a single per-claim seat value, returns it to the claimant, and broadcasts it as the writer event ({"seat": v} instead of {"grant": v}). The server still seeds the broadcast at startup with a no-holder value that never becomes the current seat, so a tab surviving a tokenless restart still flips read-only. The transport tracks its claimed seat against the latest broadcast exactly as before; only the vocabulary shrinks. The plans and internals.md drop the secret/public framing and describe the 410 as the freshness check it is.
Two overlapping reclaims from the same window (Use here clicked twice before the first claim completes, which the UI allows since the button stays visible while read-only) both mint new seats, but handleSeat writes its response after releasing the seat lock and nothing orders the two responses on the wire. The older response could then overwrite the transport's seat after the newer claim had won, leaving the window read-only and its writer POSTs carrying a superseded value until Use here was clicked again.

Tag each reclaim with a sequence number and apply only the newest one's result, mirroring the server's newest-claim-wins. A dropped older response costs nothing: the newer claim's response or its writer broadcast establishes the correct state.
If two overlapping reclaims also reach the server out of initiation order, the seat the server keeps is the one the client discarded, leaving the window spuriously read-only until the next click. Benign and vanishingly unlikely; record it in the reclaim comment rather than adding machinery.
Handle multiple windows in SatPulse workbench
master brought the writer-seat rework (#367): one browser window holds a
write seat, mutating POSTs carry it as a query parameter, and every other
window is a live read-only viewer.

Adapt the message-file feature to it: the msgfile select/send/cancel
routes register as writer endpoints (catalog stays a token-only GET); the
transport sends them through writerPost so they carry the seat; the
Messages panel takes a readOnly flag that greys the file picker, tag rows,
and Send while leaving the catalog refetch live; and the wb-listen smoke
check claims a seat before selecting a file.
Support message files in Satpulse workbench
After Apply completed, the action returned to its disabled no-changes state without any positive feedback, making a successful request look stuck. Show applying and applied status in the existing pending-changes position, retaining the success message until the next edit, discard, or disconnect.
A rejected Ntrip client password returns HTTP 401, but pull treated every handshake failure as transient and kept reconnecting indefinitely.

Classify HTTP/1.0 and HTTP/1.1 401 responses as fatal, report the pull as failed, and shut down its internal pipeline after the first attempt.
Below protocol version 50 the Configurator discovers the active
receiver port by polling the legacy UBX-CFG-PRT message (ubxcfg.go
pollPrt), because MON-COMMS does not yet report the current port there.
The simulator NAKed every ackable CFG message it did not handle, so any
config run against the shipped F9P personality (protVer 27.50) died on
the CFG-PRT poll -- satpulsed exited with "NACK for request CFG-PRT" and
satpulsewb's config read/apply failed the same way. This blocked the
config-path smoke tests against the F9P personality.

Handle CFG-PRT as a poll. On the val-based path the Configurator only
reads it (proto and baud changes go through CFG-VALSET), so the
simulator answers the poll and does not model a set. The response is
synthesized from the same database the val messages see -- the legacy
protocol view of one RAM: PortID from the polled port, baud from its
BAUDRATE key (UART only), and the in/out protocol masks from its
INPROT/OUTPROT boolean keys -- in the spirit of monComms. A CFG poll is
acknowledged, and the Configurator's correlator awaits both the response
and the ACK, so the poll emits the CFG-PRT response followed by an
ACK-ACK. A no-payload poll returns the port the poll arrived on (the
simulated port); a 1-byte poll for that same port behaves identically; a
1-byte poll for any other port is NAKed, since the simulator models only
its own port.

Tests cover the response synthesis against the database directly, the
poll and NAK behaviour over the pipe, and an integration test that
drives the real Configurator through gpscfg.Configure against the
checked-in F9P personality, proving port discovery now completes.
ReadConfig's signals poll sends one CFG-VALGET holding two group
wildcards: the SIGNAL group and the group of
CFG-GPS_L5_HEALTH_OVERRIDE (ubxcfg.go valGetSignals). The F9P database
has 19 keys in the first group and none in the second, and the
simulator's expandKeys treated a group wildcard with no inventory
matches as an unknown key, NAKing the whole message -- so a signals
read against the F9P personality failed with "NACK for request
CFG-VALGET", blocking the workbench config smoke test. A recorded real
ZED-F9P received the byte-identical poll and ACKed it, returning the
19 SIGNAL items and nothing for the other group
(gps/testdata/config/u-blox/ZED-F9P/gpshwtest001/019.jsonl lines
10-16); the interface description's NAK rule is for unknown keys, and
a wildcard is a request for all items in the group, however many there
are.

Drop the empty-expansion NAK from expandKeys: an item-part-0xffff
wildcard for a group with no keys now expands to zero items and the
message is ACKed. A complete unknown key still NAKs, which keeps the
Configurator's missing-key re-poll path exercised. VALDEL shares
expandKeys and gets the same behaviour, consistent with its own rules:
the wildcard wording is identical and deleting items that are not
stored is already valid. VALSET is unaffected (it never expands
wildcards; a wildcard item key fails the inventory check and NAKs as
before). Pagination and the all-groups wildcard are unchanged: an
empty expansion just contributes no entries to the paged list.

The Configurator integration test now also reads the enabled signals
against the F9P personality, driving the real valGetSignals poll end
to end.
…cenarios

The phase-9 section was a design sketch: the load-bearing packet-provider dimension was named but unspecified, while run.py owned the replay lifecycle inline. Expand it into an implementation-ready spec: a provider seam (provider_api, the analogue of program_api, with replay and ubxsim implementations and create/start/finish/close hooks mirroring the run_scenario lifecycle), the attribute split (PACKET_LOG/FACTOR become replay-provider attributes; ubxsim takes PERSONALITY and an optional SIM_REPLAY nav bank), the capability and platform rules, fixture and timing constraints (the checked-in F9P personality, an existing NAV-SAT-bearing bank, one epoch per second, serial speed pinned to the personality's default baud), and the two concrete scenarios: config/startup for the daemon's startup config phase and config/wb-apply for the workbench's interactive path. The Playwright half is explicitly deferred with phase 8.

Part of the satpulsewb work (#357).
The message-output model in gpsprot.ConfigOptions assumes a receiver's
native PVT/satellite messages are a separate protocol from NMEA. The
Quectel LG290P breaks that assumption: its native messages are NMEA
sentences (PQTMPVT and friends, and the GSV satellite sentence) sharing
the one NMEA output protocol, so disabling NMEA silences them too and
NMEAMsg/SatsMsg can name the same physical sentence.

This plan defines a two-level message-output model - control output at
the per-message NMEA level or the semantic PVT/Sats level, but not both
- the ConfigSupportNativeNMEA descriptor flag that marks receivers
where mixing is an error, and the consequences for the Quectel backend,
satpulsetool, the workbench UI, and gpshwtest.
Replay can drive only the monitor path: gpscfg skips probing on a read-only port and a pty replay's probe goes unanswered, so probe identification, ReadConfig, ApplyConfig, and the daemon's startup config phase had no black-box coverage. Drive them with the u-blox receiver simulator (satpulsetool ubxsim, #362), which answers probes and config over its own pty.

What plays the receiver becomes a second scenario dimension, orthogonal to the program under test: a packet-provider seam (provider_api.py, the provider counterpart to program_api), implemented by provider_replay.py -- transport selection from the scenario capabilities, the single pack --realtime replay, the one-replay-per-lifetime invariant and wait-replay backstop, and the PACKET_LOG/FACTOR attributes, all moved out of run.py -- and provider_ubxsim.py, which spawns satpulsetool ubxsim behind a --link symlink in the run dir, points SATPULSE_TEST_SERIAL at it, and SIGTERMs the simulator only after the program has shut down so an early kill cannot inject read errors into the program's shutdown. The ubxsim provider rejects CAPTURE_WRITES/SELF_SHUTDOWN/DISCONNECTABLE (the simulator answers the writes; device loss stays with replay) and reports the scenario unsupported where the simulator does not build (Linux/macOS only). run_scenario now routes create/prepare failures through the shared cleanup, so a half-started source is released rather than orphaned and the run dir is kept like any other failure.

Two scenarios land in a new config family, both against the checked-in F9P personality with an existing NAV-SAT-bearing bank: config/startup runs satpulsed's startup config phase (active detection carries the personality's MON-VER identity, and NAV-SAT is off in the personality defaults, so live satellite data proves the startup VALSET landed on the receiver), and config/wb-apply drives the workbench's interactive path (claim the seat, connect, identify the receiver, read the config, an apply that round-trips the antenna cable delay and enables the satellites messages, a re-read showing the change, and live satellite data). Requires the simulator's CFG-PRT and empty-group-wildcard fixes (ecbe785, 6cade4f).

Part of the satpulsewb work (#357); extends the smoke-test suite (#303).
The workbench already posts a whole ConfigTarget as JSON to
/api/config/apply, but the Opts half of the wire format is raw integer
flag values whose bit assignments the webui mirrors by hand in
msg-flags.ts, so any renumbering in configtarget.go silently corrupts
webui requests. Separately, satpulsetool gps does not expose all of
configtarget.go (SetStatic, the Other message flags, unaligned time
pulses, survey without SurveyAgain), leaving several model semantics
inexpressible from the command line.

plan/config-target-json.md plans the fix in two parts: name-based JSON
marshaling for the ConfigOptions flag and enum types (names derived
from the model constants, modeled on PropIDs), with the webui flipped
to send names in the same change; then a hidden --target-json argument
that accepts a whole ConfigTarget as JSON, making every model semantic
expressible without per-flag CLI work. (#372)
The six-way comparison of the ConfigProtocol implementations surfaced
semantics that configtarget.go implements but SEMANTICS.md never
stated, so backends diverged in exactly those places. Document them:

- Wire-format completeness: a named family denotes its required
  companions (GLONASS MSM implies 1230), and undenoted types in the
  group are turned off; the Other elements (NMEAMsgOther, RTCMMsgOther)
  preserve the messages beyond the model vocabulary.
- Rate: enabled NMEA, RTCM MSM, PVT, and satellite output is delivered
  at 1 Hz, independent of the receiver's positioning rate.
- Survey re-application does not restart a survey; only SurveyAgain
  does.
- A save and a reset requested together are ordered: the save
  completes before the reset takes effect.
- SetStatic: ensure static mode without touching an existing fixed
  position.

Also state the general policy that was previously only implied: satpulse
realizes best-effort and reports truthfully, while warning/error policy
belongs to the caller. Best effort must be well-defined (nearest value,
or a stated preference over known constraints); where realization would
need an arbitrary unstated choice, or the receiver refuses for
unreported reasons, the request fails rather than guesses. For options,
which have no property to report through, the ConfigSupport flags are
the reporting mechanism: an undeclared option quietly achieves nothing,
an unimplementable option grows a new flag, and flag truthfulness is
load-bearing in both directions. The u-blox RTCM-without-capability
error is reclassified as legacy that new backends must not reproduce.
Smoke-test the config path against the u-blox simulator
The config scenarios pointed SIM_REPLAY at daemon-sats-pos-38400.jsonl, a
capture of a daemon session, rather than sim.jsonl, the ZED-F9P recording
made for the receiver simulator (#362). That capture is a poor nav bank: the
daemon under capture turned NMEA off after the first epoch and never enabled
RTCM, so the bank holds a single GGA/RMC/GLL/VTG and no RTCM at all, and its
~31 epochs barely outlast the checks. A scenario that enables those messages
would see nothing come back, with no way to tell a broken config path from a
bank that has nothing to gate.

Point both scenarios at sim.jsonl, which carries 300 epochs of the full
message mix (NMEA including ZDA, RTCM 1005/1077/1087/1097/1127/1230, and the
UBX NAV set) and so can gate whatever a config scenario enables.
The simulator emitted a banked packet whenever its MSGOUT key was set,
ignoring the port's OUTPROT keys, which it read only to synthesize the
CFG-PRT response. But clearing OUTPROT is exactly how the Configurator
turns a protocol off: given no NMEA message flags it clears
CFG-UART1OUTPROT-NMEA and leaves the per-message rates alone. The
simulator therefore kept sending NMEA after being told to stop, so the
daemon's startup config (which sets NMEAMsgNone) appeared not to land, and
NMEA GSV kept arriving as satellite data -- which a config scenario cannot
distinguish from the NAV-SAT its VALSET was meant to enable.

Carry each banked packet's protocol on the Pkt and gate emission on the
port's OUTPROT key for that protocol as well as on the message's MSGOUT
key. The config engine stays ungated, so a client that disables UBX output
can still configure the port back.
satpulseweb.md pointed the ubxsim scenarios at a daemon capture
(daemon-sats-pos-38400.jsonl) as the nav bank, which is where the
scenarios got it from: that log holds only the messages the capturing
daemon had enabled, so it has no RTCM and almost no NMEA. It also stated
the message-appearance premise as UBX-off-by-default without noting that
NMEA defaults on, which is what made a decoded satellites kind (NMEA GSV
decodes to a SatellitesMsg, as NAV-SAT does) prove nothing.

Name sim.jsonl as the bank and say why a daemon capture is not a
substitute, record the NMEA exception and how an assertion must work
around it, and update ublox-sim.md, which still described the bank as
gated by MSGOUT alone.
A save and a reset requested together were already stated to be
ordered: the save completes before the reset takes effect. State the
other half of that contract: a failed save suppresses the reset, so a
reset can never discard running changes that the save it was paired
with failed to persist.
func (c *Configurator) generateModeReqs() {
m, ok := c.target.Props.GetMode()
if !ok {
return

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opts.SetStatic is silently ignored

The backend acts on positioning mode only through the Mode property: generateModeReqs (scfgset.go:171) returns immediately when Props.GetMode() is unset, and nothing in the package reads Opts.SetStatic. But satpulsed's standard timing path sets exactly SetStatic=true with no Mode property (time/app/daemon/gps.go:167-170), and the CLI --static flag does the same. So under the default daemon configuration the receiver silently stays in Rover mode - no mode command, no error - which also suppresses RTCM base output (it needs a static mode). All the other backends honor SetStatic by forcing Mode.Static (ubx ubxcfgtmode.go:81, unc cfgprops.go:157-162, allystar, casic, quectel), and configSupport here still declares ConfigSupportSurvey, so callers cannot detect the gap. The command needed already exists in this file: the explicit Mode case emits setPVTMode, Static, , auto (scfgset.go:182-183).

Suggested fix: in generateModeReqs, when GetMode is unset and Opts.SetStatic is true, emit the static request instead of returning. Emitting setPVTMode, Static with RefPos omitted preserves an existing fixed position (matching the SetStatic contract "without changing existing fixed position" and this backend's own setPVTMode, Rover omitted-argument pattern); on a default receiver RefPos is auto. Add a Configurator test with a SetStatic-only target.

}
switch m.PosType {
case gpsprot.PosTypeNone:
c.addReq("setPVTMode, Static, , auto", np.parsePVTMode)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setPVTMode Static auto is re-issued unconditionally and can restart a running survey

generateModeReqs emits the mode command purely from the target: a Static/PosTypeNone Mode always re-issues setPVTMode, Static, , auto, without comparing against the queried current state (np.pvtMode is fetched via getPVTMode but used only for readback) and without reading Opts.Survey.Flags, so SurveyAgain is not honored. The reference guide's PVT Mode-field Bit6 text ties the position-determination phase to command entry, so a re-issue plausibly restarts the autonomous position determination. All four other backends guard this ("leave an existing survey untouched unless SurveyAgain": ubx ubxcfgtmode.go:94-98, unc cfgprops.go:210-212, casic, allystar). Today only a re-applied Mode-static target without SurveyAgain reaches the gap (--survey always sets SurveyAgain), but once SetStatic is wired up (see the separate comment) the daemon path lands here on every configuration pass, so the guard becomes load-bearing.

Suggested fix: in the PosTypeNone arm, skip the set when np.pvtMode already shows Static with staticRef "auto" and SurveyAgain is clear; apply the same guard to the SetStatic fallback. Leave the fixed-position branches unconditional (rewriting a slot does not disturb a survey). Optionally verify the re-issue behavior on hardware and record it in the HW notes; absent that verification the guard should go in.

if c.target.Opts.NMEAMsg.IsSet() {
f := c.target.Opts.NMEAMsg.Get()
names := nmeaSentences(f)
if len(names) > 0 {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NMEAMsgOther-only request leaves previously-enabled standard sentences on

In generateOutputReqs (scfgmsg.go:259-270) setNMEAOutput is sent only when a standard sentence is requested. With NMEAMsgOther alone, only setDataInOut +NMEA is emitted and the Stream1 sentence list is left as-found, so any of the seven standard sentences already enabled stay enabled and now actively emit. That violates the wire-format turn-off contract (SEMANTICS.md:59,75 - PVT is the only incremental exception), which the other implementations honor in the Other-only case (ubx writes rate 0 to all seven standard sentences). It is also internally inconsistent: the RMC|Other path here already drops unrequested standard sentences via outputList. The existing Other-only test (scfgmsg_test.go:159) uses a non-standard as-found sentence (GGAaux1) so never exercises the divergence. The RTCM Other-only path (scfgmsg.go:288-293) has the same len(list) > 0 shape and should get the same treatment.

Suggested fix: send setNMEAOutput whenever any NMEA output is requested, merging Other-preserved sentences via outputList and using listOrNone for an empty list; keep the NMEAMsgNone path unchanged (mask off only). Update the Other-only test and add a case with a standard sentence as-found, expecting it dropped.

c.np.convertToProps(props)
if c.port != "" {
props.SetPort(c.port)
if strings.HasPrefix(c.port, "USB") {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No baud-rate readback for COM ports

ConfigProps() sets a baud rate only for USB connections (hardcoded 0, scfg.go:106-119); there is no getCOMSettings poll anywhere in the package, so --show-port on a serial connection reports the port name but never its current baud. Both established backends report the active UART baud (ubx ubxcfgold.go:131, unc cfgprops.go:919-920). The recorded justification for dropping speed support (scfgprot.go comment, commit 640d769) covers only the speed set - the unverifiable live baud switch - not a read-only readback: getCOMSettings (gcs) is a documented pure inquiry returning a "COMSettings, COMx, baudNNNN, ..." state line. Minor, since the characterized unit is USB-connected.

Suggested fix: on COM ports, poll getCOMSettings, <port> in the query phase and parse the baud field into the readback, keeping the USB SetBaudRate(0) branch; mark the request optional so a refusal on unexercised COM hardware degrades to today's behavior. If the ruling is that even a read-only COM query must not ship untested, then instead extend the scfgprot.go comment to state that baud readback on COM is also deliberately omitted, so the recorded rationale matches the code.

if f&gpsprot.RTCMMsgOther != 0 {
list = outputList(np.rtcmOutput, list, rtcmMessageClass)
}
c.addReq("setRTCMv3Output, "+c.port+", "+strings.Join(list, "+"), np.parseRTCMv3Output)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor gap in output rates: RTCM message intervals are never pinned.

SBF and NMEA output are enabled with an explicit sec1 interval in setSBFOutput/setNMEAOutput, so they are guaranteed 1 Hz regardless of receiver state. setRTCMv3Output has no interval argument, though - the per-message intervals are a separate global setting (setRTCMv3Interval), which the backend never sends. The reference guide gives the range as 0.1...1.0...600.0 s (default 1 s), and the intervals are part of the saveable configuration and shared across all connections, so a receiver with a persisted non-default interval would emit the enabled RTCM messages at that rate and configuration would not correct it.

Much milder than a fix-coupled rate (the interval is absolute time, and the default is what we want), but for the same guarantee as SBF/NMEA the backend could pin the intervals of the messages it enables to 1 s alongside setRTCMv3Output.


Update: no longer minor. SEMANTICS.md (master 04aa648) now states the rate contract explicitly: RTCM MSM messages are delivered at 1 Hz when enabled, independent of receiver state. An unpinned per-message interval means a persisted non-default value breaks that guarantee, so pinning the enabled messages' intervals alongside setRTCMv3Output is required to realize the stated semantics, not a nice-to-have.

if req.onReply != nil {
req.onReply(r)
}
case ReplyBlock:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A multi-block lstInternalFile reply interleaved across a timeout retry can complete the request with truncated content

Intermediate blocks don't extend the 1.5 s deadline; SetDeadlinePassed clears req.blocks and resends, and the original attempt's tail blocks (the last carrying the prompt) then satisfy the retried request - parseIdent fails on truncated XML and identity is silently absent, while the retry's full reply is dropped. Reply.BlockNum is parsed (scfgproc.go:77) but never checked; a continuity check would catch the stale continuation. (This inverts the tempting cleanup of deleting the currently-unread BlockNum/BlockTotal fields.) Low likelihood, bounded impact. (scfg.go:221)

"supportedGNSS": ["GPS", "GAL", "BDS", "GLO", "QZSS", "NAVIC", "SBAS"],
"vendor": "Septentrio"
},
"supports": ["fixedPos", "port", "raw", "rtcmBaseID", "rtcmMSM4", "rtcmMSM7", "rtcmQZSS", "signal", "speed"]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The committed baseline contradicts this PR's own declared support flags

gpshwtest/baselines/mosaic-G5-P3-1.1.0.json:30 lists speed in supports and omits survey/surveyMsg, but configSupport in scfgprot.go (post-640d7690) clears Speed and keeps Survey/SurveyMsg - the baseline predates the speed ruling. Since baselines are hardware-recorded, the fix is to re-record on the mosaic-G5 against the head build before merge; otherwise the first characterization run reports a three-flag diff.

Comment thread gpshwtest/HW/mosaic-g5.md

## Findings

- Identity (`--show-receiver` hardware "mosaic-G5 P3", firmware "1.1.0") is fetched from the Identification internal file (`lstInternalFile, Identification`), the only ASCII carrier of the firmware version: a read-only lst command, so identity never changes the receiver configuration. A ReceiverSetup SBF block is used instead whenever one arrives. (The receiver's one-shot block fetch, `exeSBFOnce`, delivers to its own connection only when the block is enabled on a stream bound to that connection - verified repeatedly, including with a raw capture - so it is not used.)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documented ReceiverSetup identity fallback does not exist in code

The HW note at gpshwtest/HW/mosaic-g5.md:11 ("A ReceiverSetup SBF block is used instead whenever one arrives") and plan/septentrio-config.md (~122-125, ~389-390) state it as current behavior, but ConfigProtocol.NativeMsg discards every non-*Reply message and ReceiverInfo reads only the Identification-file tokens; scfgvals.go:384-387 even records the Identification file as the identity source. Doc-side fix: drop or reword the fallback claims (the plan's own "dead weight" discussion at ~522-525 already points this way).

{gpsprot.NMEAMsgGLL, "GLL"},
}

var nmeaSentenceClass = []string{"RMC", "GGA", "GSA", "GSV", "ZDA", "VTG", "GLL"}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rawClass, satsClass, and nmeaSentenceClass hand-duplicate lists exactly derivable from rawBlocks/satsBlocks/nmeaFlagSentences, and drift has a real consequence

keep() (scfgmsg.go:164) only removes blocks found in cl.class, so a block added to rawBlocks but not rawClass silently survives a disable. Same pattern one file over: gnssTimeScale (scfgset.go:22) hand-maintains the inverse of timeScaleGNSS. The package already derives septSignalFromName from septSignalNames at init; the same derivation applies. (scfgmsg.go:208)

// a message request has no property through which the absence
// could show (SEMANTICS.md). An empty selection still disables
// the port protocol mask.
c.append(&sReq{state: sStateFailed, cmd: "setRTCMv3Output",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RTCM-not-supported error reproduces legacy behavior that new backends must not copy

generateOutputReqs (scfgmsg.go) appends a pre-failed request with "RTCM message output not supported by this receiver" when the receiver lacks the RTCMv3 base capability, and its comment cites the old SEMANTICS.md paragraph ("a message request has no property through which the absence could show") as justification. That paragraph has been replaced (master 04aa648): the ConfigSupport flags are now the reporting mechanism for options - a request that includes an undeclared option is not an error, the backend does what it can, and the caller applies its own policy from the flags. The u-blox error this mirrors is explicitly reclassified as legacy that new backends must not reproduce.

Suggested fix: drop the pre-failed request. The absent rtcmMSM4/rtcmMSM7 flags already carry the information (this backend clears them correctly when the capability is missing); keep only the existing else-branch behavior of disabling the RTCMv3 port protocol mask, so an RTCM request on a non-base receiver quietly achieves nothing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant