Add high-level configuration for Septentrio receivers#354
Conversation
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).
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 |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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") { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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"] |
There was a problem hiding this comment.
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.
|
|
||
| ## 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.) |
There was a problem hiding this comment.
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"} |
There was a problem hiding this comment.
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", |
There was a problem hiding this comment.
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.
Add Tier 1 high-level (device-independent) configuration for Septentrio receivers: the
ConfigProtocol/Configuratorimplementation ingps/internal/septentrio(thescfg*.gofiles) and its coarse Septentrio-name togpsprot.Signal/SignalSettable.With this,
satpulsetool gpsandsatpulsedauto-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
$RreplyPacketFormatthis configurator relies on. Part of the Septentrio branch stack inPLAN.md; design inplan/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.