feat(peripherals): add TelePost LP-100A wattmeter support (serial/ser2net) - #5320
feat(peripherals): add TelePost LP-100A wattmeter support (serial/ser2net)#5320NF0T wants to merge 13 commits into
Conversation
…ersdr#5315) Phase 1 of aethersdr#5315: the pure, hardware-free half of LP-100A support. No transport, no applet, no wiring yet -- LpMeterConnection and LpMeterApplet follow separately. Field order comes from TelePost's manual (pp. 20-21). Field WIDTHS and the record framing come from a live capture off real hardware, because the manual's printed example is wrong in one field: its Z is 4 characters where the wire sends 5, zero-padded. That one character is the whole of the 41-vs-42 discrepancy, and two plausible explanations fit it equally well until measured, so it was measured -- 954 records across idle and three transmit cycles, every one 42 characters with constant field widths. Three things the capture settled that no document did: - Phase is an unsigned MAGNITUDE. The sign of the reactance is never transmitted; the manual has the operator recover it by QSY-ing 100 kHz and watching the slope, and LP-Plot automates that only "since it can control your transmitter's frequency". So no signed reactance and no R+jX form. - dBm is signed and not zero-padded (-2.3 at idle), the one field that could widen on another unit. Validation is therefore by field shape, not length. - A record is not a coherent snapshot. In Peak Hold the meter holds power and dBm ~1.7 s past key-up while Z/phase/SWR revert immediately, so 54 of 894 records reported real power alongside SWR 1.00. reflectedWattsFromSwr() is documented as unsafe without a caller-side coherence guard for that reason. PollGate gates on FOREIGN records only. Gating on "no record in the last N ms" does not work: our own replies reset the same timestamp, so N would set the solo poll rate as well as the suppression threshold -- measured against the reference station's 100 ms foreign cadence, N=130 ms caps solo polling at 7.7 Hz while N=100 ms polls over the other client 48.5% of the time. RangeTracker keeps the reference Node-RED flow's expand-now/contract-slowly asymmetry and fixes three defects in it: the hold is wall-clock rather than counted in records (its own comment concedes the count assumes a 100 ms poll rate), contraction requires a stable candidate rather than sampling one record, and there is no power-present gate that would stall the timer between SSB syllables. There is no checksum on this protocol, so looksLikeRecord() is the entire integrity mechanism and deliberately checks far more than the reference flow's length test -- a flipped digit keeps the length. Tests are pure, link only Qt6::Core, and need no hardware: they decode the manual's own example verbatim, decode captured records byte-for-byte, cross-check |Z| and phase against the meter's own SWR, and pin each of the above as a named regression. Refs aethersdr#5315 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hersdr#5315) Phase 2 of aethersdr#5315. Adds LpMeterConnection alongside AcomConnection/ SpeConnection/VkampConnection: one QIODevice, either a QSerialPort or a QTcpSocket, one parser for both because the records are identical either way. Still no applet and no wiring. Three deliberate divergences from those peers, each measured rather than inherited: DTR/RTS are left alone. AcomConnection forces both low to stay clear of its amplifier's power-button logic; the reference station's working path runs through ser2net with `local -rtscts` and CLOCAL, i.e. modem control lines ignored entirely, so leaving them untouched is the proven configuration. The FTDI adapter named in the reference Node-RED flow is in its DISABLED serial node, so "proven on FTDI" was never proven at all. The poll loop is gated, not free-running. Other clients commonly share the port and a second connection receives their replies, so PollGate rides along when someone else is polling and polls when the wire is quiet. A dead-data watchdog reports rather than disconnects. VkampConnection's equivalent calls socket abort(), which fires disconnected(), which hides its applet tile -- so a wedged-but-reachable meter removes the very surface that would explain it. That is not hypothetical here: it is exactly what happened on the bench, TCP up and ser2net serving its banner while the LP-100A answered nothing until power-cycled. dataFlowingChanged(false) reports the state and keeps the link. Also adds Reading::coherent to the protocol layer. A record is not a snapshot: at key-up the meter holds power and dBm ~1.7 s while Z, phase and SWR have already reverted, so 54 of 894 captured records carry real power beside SWR 1.00, and anything combining fields across that boundary is wrong. It is detected from physics -- |Z| and phase must reproduce the SWR the meter itself reports -- rather than from the peak-hold mode field, because the mechanism is "power and impedance have different time constants" and Avg mode's own averaging hold has the same shape. Replayed against the full capture the detector flags exactly the 54 records an independent analysis found, without being told about the SWR 1.00 signature it keys nothing on. Refs aethersdr#5315 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three came out of running LpMeterConnection against the real hardware; none was reachable from the unit tests, and the first two produced a startup that read like a fault while behaving correctly. PollGate now claims at most ONE reply per poll rather than treating every record inside the reply window as ours. When our cadence and a foreign client's are both ~100 ms and drift into phase, their records land inside our window too, get misread as ours, and the gate flaps between riding along and polling until the phases separate again. Observed live: three transitions in the first second of a connect. The suppression threshold is 2x the observed foreign cadence, not 1.3x. The multiplier has to cover the TAIL of the jitter, not its mean: 1.3x of a measured 100.5 ms mean is 131 ms, and the same sample already contained 121 ms gaps, so ordinary jitter crossed it. Costs one extra cadence -- 200 ms -- before a departed foreign client is noticed. Gate-state logging reports only a settled state that differs from the last one logged. Even with both fixes the gate flips occasionally in steady state, because over TCP we time record ARRIVAL rather than the foreign client's true cadence: segment batching delivers two records together then nothing for twice the interval, which no fixed multiple of the mean can absorb. One extra poll every few seconds is harmless; logging it as a state change is not. Verified against the meter over ser2net: one settled log line, 80 readings in 8 s at the meter's own 10 Hz, correct callsign/range/mode, and the gauge ceiling contracting 1500 W -> 25 W after the meter held Low for the full hold, with zero polls of our own sent while the Node-RED flow was polling. Refs aethersdr#5315 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 3 of aethersdr#5315. Two gauges, not the amplifier family's three: the LP-100A reports forward power and SWR and does not report reflected power. Reflected could be derived from the pair, but the fields are not always mutually coherent, so nothing here cross-derives -- every number shown is one the meter actually sent. Two display rules come from the protocol rather than from taste, and both are stated in the class comment because both look like omissions: Impedance is |Z| and |phase|, never signed reactance and never an R+jX form. The sign is not on the wire; the manual has the operator recover it by QSY-ing and watching the reactance slope. Rendering a sign would be inventing data. dBm is shown as the meter reports it, negatives included. The reference Node-RED flow converts to dBW and clamps at 0, discarding everything below 1 W -- the QRP end where a dB readout earns its keep. Three states, not two: LIVE, SHARED (another program is polling the same port and we are reading its replies, which sets the update rate), and NO DATA -- link up, meter not answering. That last is deliberately distinct from OFFLINE because the meter can wedge with the serial link perfectly healthy, and an operator looking at a frozen gauge deserves to know which it is. The status pill's tooltip carries the poll mode, the observed foreign cadence, the active range and its full scale, in the same "why is it doing that" role AcomApplet's diagnostic tooltip plays. Power-range full scale is an operator setting reached from the tile's own context menu, following CrossNeedleMeterApplet: the meter reports which range is active but never how many watts it covers, and connection settings belong in the Peripherals tab while display preferences belong to the applet. Styling is ThemeManager templates with {{color.*}} tokens only -- zero hex literals, so the colour ratchet counts nothing new. Note this deliberately does NOT reuse the family's makeValueLabel(), which carries a literal. Labels refresh on a 10 Hz dirty-flag timer rather than per record: the meter runs at 10 Hz and repainting per record re-announces accessible names at full rate. Same throttle as AcomApplet, same reason. Refs aethersdr#5315 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sdr#5315) Phases 4 and 5 of aethersdr#5315, together because the wiring is not exercisable without the settings row that reveals it. Registration follows ACOM/SPE/VKAMP exactly: markHardwareConditional("LP100"), absent from kDefaultOrder, Metering category alongside PWR and MTR. The wiring block keeps the family's ordering rule -- all signal wiring first, the auto-connect trigger last -- because connectSerial() can call onTransportUp() synchronously and anything wired after the trigger misses the first connected(). On aethersdr#4944, which asked where an undiscoverable peripheral gets configured and which this issue reopened: this gates on the CONNECTION, like its three siblings, rather than on stored configuration as I had proposed. The objection to gate-on-connect is that a configured but unreachable device shows no tile at all -- but that does not bite here, because LpMeterConnection deliberately does not drop the link when the meter stops answering. A wedged meter keeps its tile and shows NO DATA inside it, which is the case that motivated the proposal. Only a genuinely absent transport hides the tile. So this adds no fifth data point to an open divergence, and the convention question can be settled on its own merits. The Peripherals row is row 8, structurally identical to the ACOM row, with three differences: 115200 8N1 rather than 9600, a default port of 2000 (ser2net's own common default) rather than 7000, and a status label that distinguishes "Connected" from "Connected -- meter not answering". Its styling is ThemeManager tokens rather than the neighbouring rows' hex literals, so the colour ratchet counts nothing new. Per-range full scale is NOT in this row: it is a display preference, so it belongs to the applet's context menu, and the wiring persists it under the same Lp100a device key. Verified in the running app against the meter over ser2net: the applet registers and stays hidden until the connection succeeds, then renders LIVE / NETWORK / NF0T with the power gauge auto-scaled to the Low range's 25 W, SWR on its fixed 1-3 axis, and dBm -2.3 / Z / phase / range / mode all matching the wire. With the Node-RED flow polling, the pill correctly reads SHARED. Refs aethersdr#5315 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) Phase 7 of aethersdr#5315, plus two hardware confirmations that retire the last "unconfirmed" hedges in the code. Cycling 'A' and 'F' through full rotations on real hardware settles both enum tables: alarm walks 0,1,2,3,4,5 and peak-hold walks 0,1,2. The manual lists five and two. The reference Node-RED flow's fuller tables were right, which also answers the question I could not answer from the flow alone -- whether its author had observed those values or added the cases defensively. The meter was restored to its original state. The design note records the protocol as measured, and is explicit about which of the manual's claims did not survive contact with the wire: its example record's Z field is 4 characters where the hardware sends 5, and that single character is the whole of the 41-vs-42 length discrepancy. It also documents the two facts that will read as implementation gaps to the next person -- phase carries no sign on the wire at all, and a record is not a coherent snapshot -- at the point where someone would go to "fix" them. Section 9 audits every borrowed behaviour with an adopt / improve / reject verdict and a reason, including the rejections and including one against our own AcomConnection: its kAutoRangeConsecutiveFrames = 2 was chosen against an 8-bit checksum, and this protocol has none, so the same constant is defensible here only because looksLikeRecord() carries the weight instead. That record is what stops a later contributor restoring the originals. Section 8 keeps the open questions open rather than rounding them off. Chief among them: whether field 0 is forward or net power is NOT settled -- at SWR 1.68 the two differ by less than the coax-loss uncertainty -- so the applet labels it "PWR" as the meter does and nothing derives from it in a way the answer would change. Also adds the peripheral(lp100a) touchpoint tag (manifest regenerated) and the THIRD_PARTY_LICENSES provenance record. No CHANGELOG entry (aethersdr#4707). Refs aethersdr#5315 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ethersdr#5315) The colour ratchet counts setStyleSheet CALL SITES as well as colours, and the LP-100A row copied the neighbouring rows' four direct setStyleSheet(kLabelStyle/kEditStyle/kBtnStyle) calls -- so it failed the gate at +4 despite introducing no new colour at all. Routing them through ThemeManager::applyStyleSheet clears it and is the better behaviour anyway: those widgets now re-resolve on a theme change, which a direct setStyleSheet does not. Comment added at the top of the row so the next row's author copies this rather than the direct calls above it. Caught by CI, not locally, because I ran `audit_colours.py --src src` -- which reports colour counts, and showed zero new -- rather than the `--compare-src <base>/src --strict` form the gate actually runs. The call-site metric only appears in the comparison. Now verified with the CI invocation: unique_colours +0, total_references +0, setstylesheet +0. Refs aethersdr#5315 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks for this — the LP-100A work is nicely scoped, and the design note calling out the manual-vs-wire discrepancies is exactly the kind of thing that saves the next person a day. One CI job is red, and it's a small, mechanical fix. What failedStatic checks → Hardcoded-colour ratchet (the other three jobs — I reproduced it locally against your head Good news first: you added zero new colours. Unique colours and total colour references are both +0 — you reused the existing The four sitesAll in the new Peripherals row 8 in
They're a faithful copy of the ACOM row above (7399/7426/7444/7508), so this is the ratchet catching replicated legacy style rather than anything new you invented — the gate is a delta against your base, so pre-existing sites are grandfathered but new ones aren't, regardless of how they got there. How to fix itRoute them through You're already doing this correctly a few lines up with
i.e. define local token-based To check before pushing: References: No Copilot or reviewer comments on the PR yet, so nothing else to fold in. Nothing about the protocol, connection, or applet logic is implicated — this is purely the styling call shape in the settings row. 🤖 aethersdr-agent · cost: $4.7809 · model: claude-opus-5 |
…them (aethersdr#5315) The previous commit routed this row's label/edit/button styles through ThemeManager::applyStyleSheet() to clear the hardcoded-colour ratchet, which it did -- but it passed the file-level kLabelStyle/kEditStyle/ kBtnStyle constants through unchanged, and those are literal hex. applyStyleSheet() resolves {{color.*}} tokens and re-resolves the stored template on a theme change; a template containing no tokens resolves to itself, so the re-resolve is a no-op. The commit message and the code comment both claimed these widgets now follow the theme. They did not. The gate was satisfied and nothing else was. Define token-based kLpLabelStyle/kLpEditStyle/kLpBtnStyle instead, per docs/theming/canonical-tokens.md: #c8d8e8 -> text.primary, #1a2a3a -> background.1, #304050 -> background.2. One mapping needed a decision rather than a lookup. kBtnStyle fills with #1a2a3a and hovers to #203040, and the canonical table folds BOTH into background.1 -- so a literal translation would make the hover identical to the base fill and silently delete it. Lifted the hover to background.2, matching Theme.h:376 and MainWindow_Menus.cpp:1438, which are the in-tree precedent for a background.1-filled button. Ratchet re-run with CI's own invocation against the true merge base (upstream/main b32d035): unique_colours +0, total_references +0, setstylesheet +0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — the diagnosis is right, and the distinction you drew is the one that matters: the metric counts Two notes, one where you caught something I'd already half-fixed badly, and one where I don't think the mapping table agrees with you. The fix I'd already pushed was insufficient, and your comment is what made that visible
That clears the gate, because Your "define local token-based constants" is the substantive fix, and One mapping I read differently —
|
| Token | Canonical | Collapses |
|---|---|---|
color.text.primary |
#e6f0fa |
#c8d8e8 (369 — most-referenced colour in codebase), … |
color.text.secondary |
#8ea8c0 |
#8aa8c0 (132), #8090a0 (57), … |
text.secondary is a visibly dimmer tier. Taking it would have dimmed this row's device label relative to every other Peripherals row's, which is a real visual regression rather than a neutral choice. I've used text.primary for both — which also keeps the row self-consistent, since the kComboStyle immediately above it already resolves #c8d8e8 that way.
Worth flagging because tools/audit_colours.py's own suggestion column prints #c8d8e8 → color.text.secondary, which is where I assume the suggestion came from. If so that's a discrepancy between the tool's hint and the canonical doc, on the single most-referenced colour in the tree — probably worth its own issue, since it will mislead every future migration of that colour and not just this one. Happy to file it.
One mapping that needed a decision rather than a lookup
kBtnStyle fills with #1a2a3a and hovers to #203040. The table (:47) collapses both into color.background.1 — so a literal translation makes the hover identical to the base fill and silently deletes the affordance. The gate would have passed and the button would have stopped visibly reacting to the mouse.
Lifted the hover one tier to {{color.background.2}}, which is what Theme.h:376 and MainWindow_Menus.cpp:1438 already do for a background.1-filled button. Noted in a comment so the next person doesn't "correct" it back.
Verification
Ratchet re-run with CI's own invocation. One trap worth recording: git merge-base HEAD origin/main gave the wrong base here, because origin is my fork and its main was stale — it reported +6 colours in SettingsBrowserDialog.cpp, a file this branch never touches. Against the real base (upstream/main, b32d035, the same one you used):
=== ratchet (vs PR base) ===
OK unique_colours 613 (base 613, +0)
OK total_references 2724 (base 2724, +0)
OK setstylesheet 1090 (base 1090, +0)
All four jobs were already green on 12f7a785; 65d4974 is a no-op for the gate and changes only which colours actually get resolved.
73,
Ryan NF0T
👨🏼💻 Co-authored by Claude Opus 5
There was a problem hiding this comment.
Issue fit
Yes, with one gap. #5315's v1 scope — pure unit-tested LpMeterProtocol + LpMeterConnection under src/core/, a dedicated LpMeterApplet, serial and raw-TCP transports, a Peripherals row, peripheral(lp100a) in the touchpoint manifest, read-only P only — is all present and each piece maps to a hunk. The two decisions the issue asked to settle before coding are both settled the way the issue proposed (markHardwareConditional("LP100") + absent from kDefaultOrder, ceilings in the applet's own context menu), and the issue's three explicit open questions are each resolved against hardware and the resolution recorded: the 41-vs-42 discrepancy is the Z field, not Power (§8); alarm 5 and peak-hold 2 are confirmed real by cycling A/F; listen-before-talk is implemented as PollGate. The gap is that the design note's own §9 audit lists AcomConnection's "5 s reconnect — Adopt" and the code carries the machinery but never enables it (blocker 1).
Scope
| File / group | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/core/LpMeterProtocol.{h,cpp} |
Pure protocol: parse, validate, PollGate, RangeTracker | Yes | In scope |
src/core/LpMeterConnection.{h,cpp} |
Serial/TCP transport, poll loop, watchdogs | Yes | In scope |
src/gui/LpMeterApplet.{h,cpp} |
The applet | Yes | In scope |
src/gui/AppletPanel.{h,cpp} |
LP100 registration, Metering category, hardware-conditional |
Yes | In scope |
src/gui/MainWindow{.h,.cpp,_Wiring.cpp} |
Member connection + signal wiring + auto-connect | Yes | In scope |
src/gui/RadioSetupDialog.{h,cpp} |
Peripherals row 8 | Yes | In scope |
tests/lp100a_protocol_test.cpp, tests/tests.cmake, CMakeLists.txt |
New test target + sources | Yes | In scope |
docs/architecture/lp-100a-wattmeter-design.md, README.md, THIRD_PARTY_LICENSES |
Design note, index entry, provenance | Yes | In scope |
docs/architecture/aetherd-touchpoint-tags.json |
The LpMeterConnection.h entry plus ~20 unrelated — → — rewrites |
Partly | Regenerator churn — see nits |
docs/architecture/aetherd-touchpoints.md |
LP entries plus 6 unrelated headers (CtcssTones.h, SystemInfo.h, SystemInfoCollector.h, ThreadCpuRing.h, ConnectionSharingPolicy.h, RadioCapabilities.h) and drifted includer counts |
Partly | Same — manifest regeneration, not smuggled behaviour |
No new flat AppSettings keys (everything goes through PeripheralSettings::setDevice*, matching ACOM/SPE/VKAMP). No CHANGELOG.md entry — correct. No behaviour change to any existing UI element, so nothing here reads as a smuggled preference: every changed surface is new. No - lines removing a guard anywhere in the functional diff.
Blockers
1. setAutoReconnect() is never called, so the whole reconnect path is dead — and a dropped ser2net link takes the applet tile with it. (inline on LpMeterConnection.h:74)
m_autoReconnect defaults to false and grep -rn "setAutoReconnect" src/ across the head checkout returns no m_lpMeterConn call site. The startup propagation block at MainWindow_Wiring.cpp:6041-6049 sets it on m_tgxlConn/m_pgxlConn/m_antennaGenius/m_acomConn/m_speConn/m_vkampConn and omits the new connection; RadioSetupDialog.cpp:7946-7962's live toggle omits it too. All three armReconnect() call sites are guarded on m_autoReconnect, so kReconnectMs, m_reconnectTimer and its lambda are unreachable.
Failure scenario (reasoned from code, not observed running): ser2net restarts or the network blips. QTcpSocket emits disconnected → onTransportDown() → emit disconnected() → the wiring lambda at MainWindow_Wiring.cpp:6530 calls setLpMeterVisible(false). No reconnect is armed, so the tile is gone until the operator reopens Radio Setup → Peripherals and clicks Connect. That is materially worse than the ACOM/SPE peers this note says it follows, and it undercuts §10's argument that this integration doesn't worsen the #4944 divergence — a configured and previously reachable device does end up with no tile here. Fix: add m_lpMeterConn.setAutoReconnect(ar); to the MainWindow_Wiring.cpp:6041 block and to the RadioSetupDialog.cpp:7946 toggle.
Nits (non-blocking)
ResponseParser::feed()can grow without bound once a marker has arrived (inline onLpMeterProtocol.cpp:199). ThekRecordLength * 4cap only runs on thefirstMarker < 0path. Oncem_bufstarts with;and neither a second marker nor a valid fixed-length body ever appears, thebreakleaves everything buffered and every subsequentfeed()appends. Needs a stuck stream on a connection that already delivered one;, so it is not a likely path — but it is the one case the existing cap was written to prevent and it sits just outside it.reflectedWattsFromSwr()has no production caller (inline onLpMeterProtocol.h:206). §7 explains why the applet shows no reflected figure, which is right — but that leaves a public function exercised only by two test rows, and its comment points readers at "the per-field liveness tracking inLpMeterConnection", which does not exist: the coherence gating lives inLpMeterApplet::applyDimming()viaReading::coherent. Either drop the function until v2 needs it, or fix the pointer.- An operator lowering a ceiling after auto-expansion silently no-ops (inline on
LpMeterConnection.cpp:144).RangeTracker::setCeilings()'s up-only guard is exactly right for a re-read of the same config, but the same path serves a deliberate edit from the context menu. Someone who sees the tooltip's "widened automatically" and sets High to 700 W to match their meter gets no change and no feedback until reconnect. - Ceilings persist through
setDeviceInt, so a fractional full scale truncates.QInputDialog::getDoubleis called with 0 decimals, so this is unreachable today — worth astatic_cast<int>comment or an int-typed setting. m_gateSettleTimeris restarted on every 100 ms tick once it has fired (pollTick's|| !m_gateSettleTimer.isActive()), so it wakes roughly every 1.6 s forever and returns immediately. Harmless, just perpetual.- The touchpoint manifest churn above is almost certainly a regeneration side effect rather than hand edits, but it makes the diff harder to read and
AppSettings.h102→101 /ThemeManager.h139→142 will conflict with any other PR that regenerates. A sentence in the PR body saying the manifest was regenerated would cover it.
What I tried to break
- CodeGuard's five CG-PATH-001 hits: refuted, dropped.
MainWindow.cpp:8806/8918/9037-9039are the FFTW-wisdom progress dialog and the macOS bundle-relaunchQDir::cdUp()walk. This PR touches 3 lines in that file (git diff --stat), none near them. Pre-existing, not this diff's. M_PIon MSVC (my usual suspect for a Windows-only break on new DSP code):LpMeterProtocol.cpp:275and the test both use it with only<cmath>and no_USE_MATH_DEFINES. Five othersrc/core/*.cppdo the same andcheck-windowsis green on12f7a78, so the include chain supplies it. Not a finding.- Attacked the tests for self-confirmation. They largely survive:
kManualExampleis quoted verbatim from the manufacturer's PDF andkCapturedIdle/kCapturedTxare literal captured bytes, not encoder output, so they can fail. The"42 chars but misplaced separators"row would pass against a naivelength == 42check being broken — it is a real mutation guard, not a tautology. The PollGate solo row (polls == 100) fails if own replies are ever allowed to move the suppression timestamp, which is the exact defect §6 describes. The43-char wider dBmrow genuinely pins the "shape not length" rule. - Walked the disconnect/teardown reentrancy.
disconnect()setsm_connected = falsebeforeteardownDevice(), soQTcpSocket::abort()'s synchronousdisconnected()re-entersonTransportDown()withwasConnected == falseand no duplicatedisconnected()is emitted;m_deliberateDisconnectsuppresses the reconnect arm in the re-entrant frame. Mode-switch (network→serial while connected) produces a disconnect/connect pair rather than a wedge.m_serialPort's signals are connected exactly once behindif (!m_serialPort), so repeatedconnectSerial()does not multiply them. No defect found here. - Checked the added code is actually reachable.
lp100a_protocol_testis registered intests/tests.cmake:1982-1988; both new.cppfiles are inCORE_SOURCES/GUI_SOURCES;LpMeterAppletis constructed and registered atAppletPanel.cpp:830;setLpMeterVisibleexists and is called. Nothing dead exceptreflectedWattsFromSwrand the reconnect path above. - Could not check: anything requiring a build, a GUI, or a real LP-100A. Every finding above is reasoned from the head checkout at
/tmp/aetherclaude/pr-5320, not reproduced at runtime. In particular the §3 field-width claims, the §6 measured constants (15 ms reply latency, 79 ms minimum foreign gap, 60 records in 6 s from a silent second connection) and the §7 ~1.7 s key-up hold are all single-station measurements I have no way to falsify — they are stated as measured, and the code degrades safely if a different unit disagrees, which is the right shape for an unverifiable claim. - CI is green on all four checks for
12f7a78(build,check-windows,check-macos,Static checks) — noting for the record thatctestinci.ymlis-R-filtered, so green does not meanlp100a_protocol_testran.
Recommendation
Request changes — for blocker 1 only, which is a two-line fix. Everything else here is unusually strong work: the protocol layer is pure and tested against literal captured bytes, the design note distinguishes measured facts from unverified ones and names what the manufacturer's manual gets wrong, and the §9 provenance audit records what was rejected from the reference sources rather than just what was borrowed. Wire setAutoReconnect into the two existing propagation sites and this is ready for a maintainer to take on its merits; the nits can ride in the same push or a follow-up.
Thanks for filing the issue first and settling both open questions before writing code — it made this reviewable.
🤖 aethersdr-agent · cost: $8.5621 · model: claude-opus-5
| void connectNetwork(const QString& host, quint16 port); | ||
| void disconnect(); | ||
|
|
||
| void setAutoReconnect(bool on) { m_autoReconnect = on; } |
There was a problem hiding this comment.
Blocker — this setter has no caller, so the reconnect machinery below it is dead.
grep -rn "setAutoReconnect" src/ on the head checkout shows the startup propagation block at MainWindow_Wiring.cpp:6041-6049 setting it on m_tgxlConn/m_pgxlConn/m_antennaGenius/m_acomConn/m_speConn/m_vkampConn, and the live Peripherals toggle at RadioSetupDialog.cpp:7946-7962 setting it on five of those — neither mentions m_lpMeterConn. m_autoReconnect therefore stays false for the lifetime of the app, and all three armReconnect() call sites are guarded on it, so kReconnectMs, m_reconnectTimer and its timeout lambda are unreachable.
The consequence isn't just dead code. When the ser2net proxy restarts or the network blips, onTransportDown() emits disconnected(), and the wiring lambda at MainWindow_Wiring.cpp:6530 calls setLpMeterVisible(false) — so the tile disappears and nothing ever brings it back until the operator reopens Radio Setup and clicks Connect. That's the exact "configured but unreachable device shows no tile" state §10 of the design note argues this integration avoids.
Fix is to add m_lpMeterConn.setAutoReconnect(ar); alongside its peers in the MainWindow_Wiring.cpp:6041 block, and to the RadioSetupDialog.cpp:7946 toggle so the setting takes effect without a restart.
(Reasoned from the checkout — I can't build or run this.)
There was a problem hiding this comment.
Confirmed and fixed in `6c5deb5c`. I verified it independently rather than taking the trace: `m_autoReconnect{false}` at `LpMeterConnection.h:142`, all three `armReconnect()` sites guarded on it, no `m_lpMeterConn` call site anywhere in `src/`, and the `disconnected` lambda calls `setLpMeterVisible(false)`. The path was unreachable. Added to both sites you named.
One correction to the severity. PeripheralSettings::autoReconnect() defaults to False (PeripheralSettings.h:35), so for a default-configured operator ACOM/SPE/VKAMP do not reconnect either and the LP-100A behaved exactly like its peers. The defect bit the operator who had explicitly turned the setting on — for whom every other peripheral honoured it and this one silently did not. Still worth blocking on, but it is a setting quietly excluding one device rather than a blip permanently killing the tile for everyone.
You were right to link it to §10. My #4944 argument covered the meter-wedges-while-the-link-is-healthy case, which the transport decision does handle; it did not cover a transport drop, which does hide the tile. With the wiring in place, parity with the peers is restored and the argument holds again.
While in that toggle: m_vkamp is missing from it too, and that is pre-existing from #4919 — VkampConnection has the setter and the startup block does call it, so toggling the checkbox mid-session reaches every peripheral except that one. Left alone per scope discipline and noted in a comment; filing separately.
| // No marker at all yet. Keep only enough to recognise one arriving | ||
| // split across feeds; without this an endless non-record stream would | ||
| // grow the buffer without bound. | ||
| if (m_buf.size() > kRecordLength * 4) { |
There was a problem hiding this comment.
This bound only applies while no marker has been seen. Once m_buf starts with ;, the loop below can break on the incomplete path and every subsequent feed() appends without any cap: no second marker ever arrives, and looksLikeRecord(m_buf.mid(1, kRecordLength)) keeps failing, so nothing is consumed.
It needs a link that delivers one ; and then an unbounded stream containing no further marker — unlikely, but it's precisely the scenario this cap was added to prevent, just one branch over. A cheap fix is to apply the same clamp at the break, e.g. drop everything before the second marker (or truncate to the last kRecordLength bytes) once the buffer exceeds kRecordLength * 4 with no valid decode.
There was a problem hiding this comment.
Confirmed and fixed in `6c5deb5c`. Your reading of the control flow is exact — once m_buf starts with ;, indexOf always succeeds, so the firstMarker < 0 cap can never run again and the break leaves everything buffered.
A body past kRecordLength * 4 now drops its marker and resyncs at the next one, clearing if there is none. Same bound as the existing cap, for the same reason.
Two regression rows added: feed one ; then 12.8 kB of never-valid non-marker bytes and assert the buffer stays bounded, then feed a good record and assert it still decodes — a cap that resynced but left the parser wedged would be a worse bug than the one being fixed.
| // inputs are currently live; see the per-field liveness tracking in | ||
| // LpMeterConnection. Provided as a pure function so the coherence decision | ||
| // stays with the caller that has the timing information. | ||
| double reflectedWattsFromSwr(double forwardW, double swr); |
There was a problem hiding this comment.
Nit: this has no production caller — grep -rn reflectedWattsFromSwr src/ tests/ finds only the definition, the declaration, and two test rows. §7 of the design note explains correctly why the applet shows no reflected figure, so the absence is deliberate; what's left is a public function whose only exercise is a test that pins nothing shipping.
The comment above it also points readers at "the per-field liveness tracking in LpMeterConnection", which isn't there — I grepped, and the coherence gating actually lives in LpMeterApplet::applyDimming() (zLive = live && m_reading.coherent). Either drop the function until v2 needs it, or retarget that reference to the applet.
There was a problem hiding this comment.
Confirmed — the pointer is wrong, and worse than stale. "Per-field liveness tracking in LpMeterConnection" was a design from the plan that never got built; Reading::coherent, computed in decodeReading() and consumed by LpMeterApplet::applyDimming(), replaced it. The comment survived the design change. Fixed in `6c5deb5c`.
On dropping the function: keeping it, and now saying why in the comment. It sits alongside kAlarmCommand / kModeCommand / kPeakAvgCommand, which are also deliberately unused in v1 so the control PR adds no protocol surface — §4 states that intent. It reads as dead code precisely because nothing said so; that is the defect, and it is a comment defect.
| // RangeTracker applies its own up-only guard here, so a re-read or an | ||
| // edited ceiling cannot shrink one that observed power already expanded | ||
| // within this session. | ||
| m_range.setCeilings(ceilings); |
There was a problem hiding this comment.
Nit: RangeTracker::setCeilings()'s up-only guard is right for the case its comment describes (a re-read of the same config must not shrink a ceiling that observed power expanded), but this same path also serves a deliberate operator edit from the applet's context menu.
Sequence: a reading exceeds the configured 1500 W High ceiling twice, so m_ceilingW becomes e.g. 1600 and the tooltip says "widened automatically". The operator reads that, right-clicks, and sets High to 700 W to match their actual meter. setCeilings() sees m_autoExpanded && configured < m_ceilingW, keeps 1600, and emits nothing — no change, no feedback, until reconnect.
A flag distinguishing "re-read" from "explicit edit" (the latter always wins and clears m_autoExpanded) would keep the guard where it earns its keep without silently ignoring the operator.
There was a problem hiding this comment.
Confirmed, and I rate this higher than a nit — fixed in `6c5deb5c`.
Verified both callers: MainWindow_Wiring.cpp:6579 is the stored-config load, :6561 is the context-menu edit, and they share one entry point. The guard is right for the first and wrong for the second, and the failure is worse than "no change" — because the ceiling does not move, setRangeCeilings never emits gaugeCeilingChanged, so the operator gets no feedback either. The value does persist, so it appears to work after a restart, which makes it harder to report than a plain refusal.
RangeTracker::setCeilings() now takes a CeilingSource with no default, so each call site states intent. OperatorEdit always wins: it is a person telling us what their meter is set to, which is the one thing this protocol never puts on the wire. Honouring a mistaken edit is self-correcting — power above the new ceiling re-expands within kCeilingExpandRecords records.
Three test rows pin it in both directions (edit lowers and clears the flag; a re-read still cannot lower; power above a lowered ceiling re-expands) so the two paths cannot be merged back into one. Documented in §5 of the design note.
|
Cisco CodeGuard — static analysis of this PR (5 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $9.0008 · model: claude-opus-5 |
…ng (aethersdr#5315) The touchpoint tags file was committed with 29 escaped \u2014 and 12 literal em-dashes -- it has been written by more than one tool over time. My edit normalised the whole file to literal, which rewrote 27 entries this branch has nothing to do with and would conflict with any other PR touching them. Rebuilt as the base file plus the one core/LpMeterConnection.h entry. The diff is now 6 added lines and nothing else. Worth separating from the aetherd-touchpoints.md churn in the same commit range, which looks identical in a diff but is NOT the same thing. Regenerating the manifest on a pristine upstream/main worktree with none of this branch's code present reproduces every one of those 'unrelated' entries -- CtcssTones.h, SystemInfo.h, SystemInfoCollector.h, ThreadCpuRing.h, ConnectionSharingPolicy.h, RadioCapabilities.h -- and every drifted includer count. main's committed manifest is stale, and gen_touchpoint_manifest.py --check exits 1 there. That half is genuine generator output and has to stay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) 1. Wire setAutoReconnect(). m_autoReconnect defaults to false and every armReconnect() site is guarded on it, so the reconnect machinery this branch copied from AcomConnection -- and that the design note's audit table lists as "Adopt" -- was unreachable. Added to the startup propagation block in MainWindow_Wiring.cpp and to the live toggle in RadioSetupDialog.cpp. Narrower than the review framed it: PeripheralSettings::autoReconnect() defaults to False, so a default-configured operator saw peer-equivalent behaviour. It bit exactly the operator who had turned the setting ON, for whom every other peripheral honoured it and this one silently did not. Noted in a comment while there: m_vkamp is missing from the same toggle, pre-existing from aethersdr#4919. Left alone -- filed separately rather than bundled. 2. Bound ResponseParser::feed(). The existing cap only runs on the no-marker path; once m_buf starts with ';' that indexOf always succeeds, so a device that goes malformed AFTER talking properly would append forever. A body past four records' worth now drops its marker and resyncs at the next one. 3. Fixed reflectedWattsFromSwr()'s comment, which directed callers to "the per-field liveness tracking in LpMeterConnection". No such thing was ever built -- the plan proposed it, Reading::coherent replaced it. Now points at decodeReading()/applyDimming(), and says outright that having no v1 caller is deliberate. 4. An operator lowering a ceiling after auto-expansion silently no-opped. RangeTracker::setCeilings() served both a config re-read and a context-menu edit, and the up-only guard is right for the first and wrong for the second: the ceiling did not move, so gaugeCeilingChanged() never fired and the operator got no change AND no feedback. Now takes a CeilingSource; an OperatorEdit always wins. Honouring a mistaken edit is self-correcting -- power above the new ceiling re-expands within kCeilingExpandRecords records. 5. m_gateSettleTimer restarted itself forever. The '|| !isActive()' clause re-armed it on the first tick after each expiry, costing a timer event every ~1.6 s for the life of the connection while the timeout early- returned. Restart on an actual state change only -- plus a latched first-report clause, because a pure change-only test would never fire at connect (the gate agrees with the initial value) and would have dropped the opening POLLING/SHARED line from the support bundle. Not taken: the setDeviceInt truncation nit. The cast is already explicit at the call site and QInputDialog::getDouble is called with 0 decimals, so a fractional full scale is unreachable twice over. Added a comment saying so instead. Tests: 49 -> 56 assertions. Three rows pin the CeilingSource split in both directions so the paths cannot be merged back, one pins the parser bound, one pins that it still resyncs afterwards. Colour ratchet +0/+0/+0, manifest --check clean, full app builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All four inline findings are fixed in Blocker and nits
Tests go 49 → 56 assertions, all passing. Colour ratchet
|
|
@AetherClaude you may re-review at your convenience |
There was a problem hiding this comment.
Human-directed independent review (Claude / @NF0T)
1. Issue fit
Yes, fully — and it settles the open questions #5315 left open rather than papering over them. Every acceptance item in the issue maps to a hunk: LpMeterProtocol pure + unit-tested (tests/lp100a_protocol_test.cpp, registered at tests/tests.cmake:1982-1988, links Qt6::Core only as promised), LpMeterConnection under src/core/ outside the radio seam, a dedicated LpMeterApplet, markHardwareConditional("LP100") + Metering category + absent from kDefaultOrder (AppletPanel.cpp:830-834, :1350), Peripherals row, peripheral(lp100a) touchpoint tag. Both "decisions I'd like settled" went the way the issue proposed (#4944-consistent placement; ceilings in the applet context menu, LpMeterApplet.cpp:368-408).
The issue's own 41-vs-42 open question is resolved by capture and — correctly — by not validating on length: looksLikeRecord() checks field count, separator placement (only at the known-good length), per-field parseability and physical range, and the test pins the manual's literal 41-char example decoding alongside the 42-char capture and a hypothetical 43-char wide-dBm record. That is the right shape.
2. Scope
Three files in this diff have nothing to do with the LP-100A, and two of them are governance-owned.
| File / group | What it changes | Claimed by title/body? | Verdict |
|---|---|---|---|
src/core/LpMeterProtocol.{h,cpp} |
Pure protocol layer | Yes | In scope |
src/core/LpMeterConnection.{h,cpp} |
Serial/TCP transport | Yes | In scope |
src/gui/LpMeterApplet.{h,cpp} |
The applet | Yes | In scope |
src/gui/AppletPanel.{h,cpp}, MainWindow{,_Wiring}.{h,cpp}, RadioSetupDialog.{h,cpp} |
Registration, wiring, Peripherals row 8 | Yes | In scope |
CMakeLists.txt, tests/tests.cmake, tests/lp100a_protocol_test.cpp |
Build + unit test | Yes | In scope (Tier 2) |
THIRD_PARTY_LICENSES, docs/architecture/* |
Provenance + design note + tag | Yes | In scope (Tier 1/2) |
tree.json (+42,301 lines) |
Generated QWidget tree dump from tools/automation_probe.py:485, committed at repo root |
No | Blocker — remove |
.claude/settings.local.json.bak-20260725-202211 |
A personal Claude Code settings backup | No | Blocker — remove |
.claude/commands/review-pr.md (new, 75 lines) |
A new PR-review slash command | No | Blocker — unbundle |
docs/architecture/aetherd-touchpoints.md (6 unrelated header rows, AppSettings.h 102→101) |
Manifest regeneration drift | Partially | Non-blocking, see nits |
3. Blockers
1. tree.json — a 42,301-line generated artifact committed at the repo root. (inline)
git log puts it in 6c5deb5c "fix(lp100a): five defects from the #5320 review", whose message does not mention it. It is the output of tools/automation_probe.py (tree_path = os.path.join(args.out, "tree.json"), line 485) — a dump of a running app's widget hierarchy, with geometry and objectName for every widget on that machine. It is not in .gitignore, so once merged every future probe run leaves a dirty tree for everyone. Delete it from the branch and add tree.json to .gitignore in a separate one-liner.
2. .claude/settings.local.json.bak-20260725-202211 — a stray local backup. (inline)
Four lines granting WebSearch, dated a month before this work. .gitignore:44 ignores .claude/launch.json specifically and its comment explains the reasoning ("per-developer and per-worktree… a committed copy is wrong for everybody else") — which applies verbatim to a settings.local.json backup. Remove it.
3. .claude/commands/review-pr.md is unrelated new tooling in a CODEOWNERS-gated path. (inline)
A complete 75-line PR-review workflow command, bundled into a wattmeter feature PR. .github/CODEOWNERS:156 assigns .claude/commands/ to @aethersdr/infrastructure, and there is a comment at :152 explaining that line exists specifically so this directory is not swept up by a broader pattern. There is a certain irony in the file's own Tier table listing .claude/commands/ as Tier 1 while arriving inside a Tier 3 feature PR. It may well be a good command — it needs its own PR and its own approver.
None of the three is a code defect; all three are unbundling asks, and the LP-100A code itself is not blocked by them.
4. Nits (non-blocking)
docs/architecture/aetherd-touchpoints.mdcarries manifest drift beyond this PR. The totals go 199→207, but only two of the eight new headers are yours (LpMeterConnection.h,LpMeterProtocol.h).CtcssTones.h,SystemInfo.h,SystemInfoCollector.h,ConnectionSharingPolicy.h,RadioCapabilities.handThreadCpuRing.hare pre-existing headers the regeneration swept in, andAppSettings.hdrops an includer (102→101) which nothing in this diff can explain. That is regeneration correcting accumulated drift, not a problem — but the body should say so, because a reviewer diffing the totals against your two files will not reconcile them.PollGate::m_lastForeignMsandm_prevForeignMsare always assigned the same value (LpMeterProtocol.cpp:377-378) and only the former is read. One of them is redundant state.setCeilings(OperatorEdit)clearsm_autoExpandedeven when the edited range is not the displayed one. Editing Low while displaying an auto-expanded High discards the expansion for High. Self-correcting withinkCeilingExpandRecords, and the header already argues that self-correction is acceptable — noting it only because the comment reasons about the same-range case.docs/architecture/README.md: the new bullet is missing the blank line its neighbours have before the next entry.
5. Bot findings I could not confirm
- CodeGuard CG-PATH-001 ×5,
src/gui/MainWindow.cpp:8806/8918/9037-9039— refuted, all five. Those lines are untouched by this PR. 8806 is the NR2 wisdom dialog'sQLabeltext; 9037-9039 are the macOS relaunch path walking up fromQCoreApplication::applicationFilePath()to the.appbundle. No attacker-controlled path component, and no line in the LP-100A diff appears in the finding set. - The two earlier
aethersdr-agentblockers are genuinely fixed, verified independently rather than taken on the author's word:m_lpMeterConn.setAutoReconnect(ar)is now in the startup block (MainWindow_Wiring.cpp:6049) and in the live toggle (RadioSetupDialog.cpp:7964); and the unbounded-buffer branch now drops its marker and resyncs atLpMeterProtocol.cpp:247-256, with a regression row atlp100a_protocol_test.cpp:512-530that feeds 12.8 kB ofxafter a bare;and assertsbufferedBytes() <= kRecordLength * 4 + 1. The bot's third note (the stale "per-field liveness tracking inLpMeterConnection" comment) is fixed atLpMeterProtocol.h:216-219and now points atLpMeterApplet::applyDimming(), which is wherezLive = live && m_reading.coherentactually lives (LpMeterApplet.cpp:322).
6. What I tried to break — and failed
M_PIon MSVC.LpMeterProtocol.cpp:302and the test at line 118 use bareM_PIwith only<cmath>, which is the recurringcheck-windowsbreak on this project. Not a finding here:src/core/MaidenheadLocator.h:112,RttyDecoder.cpp:107andIcomCivBackend.cpp:3077use bareM_PIwith the same<QString>/<cmath>include shape and build on Windows today, because Qt 6's platform target supplies_USE_MATH_DEFINESto everything linking Qt. Both the library andlp100a_protocol_testlinkQt6::Core, so both inherit it.- Attacked the tests rather than trusting them. They are not self-referential: the manual's example is quoted verbatim rather than produced by our own encoder, the captures are literal bytes, and the physics cross-check at lines 114-128 re-derives SWR from |Z|∠φ independently of
swrFromImpedance()— so it would catch a field-order error the parser and the test agreed on. The 42-char-with-misplaced-separator row (line 173) fails against alength == 42implementation, and the negative-dBm row fails against an unsigned parse.looksLikeRecordreduced to a length check would fail at least four rows. - Parser resync and buffer growth, per above — and I walked the fixed-length emit path (
:224-232) for double-emission:consume = 1 + kRecordLengthfollowed by the trailingindexOfrealignment leaves no window for a record to be decoded twice. - Reconnect ordering.
connectNetwork()setsm_deliberateDisconnect = falsebeforeteardownDevice(), so aborting a live socket to reconnect can arm the 5 s reconnect timer just afterm_reconnectTimer.stop(). Real, but byte-identical toAcomConnection.cpp:127-129— a pre-existing pattern, not this PR's to change. - Constitution / AGENTS.md. Nothing radio-authoritative is cached or re-asserted (Principle II is not engaged — this device is outside the radio seam entirely). No TX keying, no
markTxKeying()surface. Persistence goes throughPeripheralSettings::setDeviceInt/deviceStringunder a"Lp100a"device key, matching the ACOM/SPE/VKAMP peripherals; no credentials, noradio_settingsscope needed. NoCHANGELOG.mdentry — correct. GOVERNANCE.md's "new applets or dialogs that don't change existing UX" exemption is quoted accurately in #5315; no RFC required. - Personal-preference check: clean. Every changed line in an existing file is additive registration or wiring. No default altered, no existing UI restyled, no confirmation removed. The one deliberate divergence from a peer (leaving DTR/RTS alone, unlike
AcomConnection) is documented at its point of use with the measurement behind it. - Could not check: anything at runtime. No build, no test execution, no GUI — every finding above is reasoned from the head checkout at
/tmp/aetherclaude/pr-5320, not reproduced. I also cannot independently verify the hardware captures, the 954-record counts, or the 60-records-in-6-s shared-transport measurement; those rest on the author's bench and are stated as such in the design note, which is the right way to carry them.
7. Recommendation
Request changes — for the bundling only. The LP-100A implementation is the most carefully evidenced peripheral integration I have reviewed on this project: the design note distinguishes what was measured from what remains unverified, names where the manufacturer's manual is wrong and why the wire wins, audits each borrowed behaviour with an explicit adopt/improve/reject verdict, and the two protocol facts that read as gaps (unsigned phase, non-snapshot records) are documented at their point of use with tests pinning them. I found no correctness defect in the LP-100A code.
What blocks it is three files that are not the LP-100A: a 42k-line generated tree.json, a stray .claude settings backup, and a new CODEOWNERS-gated slash command. Drop the first two, split the third into its own PR for @aethersdr/infrastructure, and this is ready.
Review event is COMMENT — a human maintainer makes the merge call. Nice work, and thank you for bringing hardware to it.
🤖 aethersdr-agent · cost: $5.3607 · model: claude-opus-5
GitHub rejected the inline anchors (GitHub API 422: Unprocessable Entity); findings below:
tree.json:1
Blocker — generated artifact, 42,301 lines, unrelated to the LP-100A.
This is the output of tools/automation_probe.py (tree_path = os.path.join(args.out, "tree.json"), line 485): a dump of a running app's QWidget hierarchy including per-widget geometry and objectName from whichever machine ran the probe. git log --oneline -1 -- tree.json puts it in 6c5deb5c "fix(lp100a): five defects from the #5320 review", which does not mention it — so it looks like an accidental git add.
It is also not in .gitignore, so after merge every future probe run leaves a dirty working tree for every developer.
Please drop it from the branch. Adding tree.json to .gitignore is worth a separate one-liner so this cannot recur.
.claude/settings.local.json.bak-20260725-202211:1
Blocker — a personal machine artifact, unrelated to this PR.
A four-line backup of a local Claude Code settings file, timestamped 20260725, roughly a month before this work started.
.gitignore:44 ignores .claude/launch.json and the comment above it states the principle exactly: "Per-developer and per-worktree… a committed copy is wrong for everybody else." A settings.local.json backup is the same category — settings.local.json is per-developer by construction, which is what the .local infix means.
Please remove it from the branch.
.claude/commands/review-pr.md:1
Blocker — unrelated new tooling, and in a CODEOWNERS-gated path.
A complete 75-line PR-review workflow command, bundled into a TelePost wattmeter feature PR. Nothing in #5315 or the PR title asks for it.
Two reasons it needs its own PR rather than a fixup here:
.github/CODEOWNERS:156assigns.claude/commands/to@aethersdr/infrastructure, and the comment at:152says that line exists specifically so this directory is not swallowed by a broader pattern — i.e. the gating is deliberate, not incidental. A reviewer approving the LP-100A code is not thereby approving this.- A shared review methodology deserves discussion on its own merits. Buried at the bottom of a 24-file feature diff, it will be approved by inattention.
Worth noting, gently: this file's own Tier table lists .claude/commands/ as Tier 1 requiring @ten9876, while arriving inside a Tier 3 feature PR. That is the rule it states, applied to itself.
No objection to the content — please split it out.
src/core/LpMeterProtocol.cpp:378
Nit, non-blocking: m_lastForeignMs and m_prevForeignMs are unconditionally assigned the same value on every foreign record, and only m_lastForeignMs is ever read (shouldPoll(), line 383). m_prevForeignMs is read at line 369 — but only to compute a gap against a value it is about to be set to, which m_lastForeignMs would serve identically.
The two-name split reads as if it encodes a distinction (last-vs-previous) that the code does not actually make, which will cost the next reader a minute. Either collapse them, or add a line saying they are deliberately kept separate for a future change.
|
Cisco CodeGuard — static analysis of this PR (5 finding(s))
Automated static scan by Cisco DefenseClaw CodeGuard on the changed files. Advisory — some may be false positives; the review above verifies them. 🤖 aethersdr-agent · cost: $6.3093 · model: claude-opus-5 |
|
Antares Detector — candidate vulnerable file(s)
The WsjtxClient uses QDataStream to parse binary protocol data, and its readQString helper reads raw bytes from the stream into a QString buffer without validating that the serialized length is bounded by available memory or the expected packet size. This can lead to out-of-bounds allocation/copying when parsing malformed packets. Localized by Cisco Foundation AI Antares-1B running locally in the AetherClaude sandbox, seeded by the Cartographer security map. Advisory only — please verify before acting. 🤖 aethersdr-agent · cost: $6.3093 · model: claude-opus-5 |
) 1. Wire setAutoReconnect(). m_autoReconnect defaults to false and every armReconnect() site is guarded on it, so the reconnect machinery this branch copied from AcomConnection -- and that the design note's audit table lists as "Adopt" -- was unreachable. Added to the startup propagation block in MainWindow_Wiring.cpp and to the live toggle in RadioSetupDialog.cpp. Narrower than the review framed it: PeripheralSettings::autoReconnect() defaults to False, so a default-configured operator saw peer-equivalent behaviour. It bit exactly the operator who had turned the setting ON, for whom every other peripheral honoured it and this one silently did not. Noted in a comment while there: m_vkamp is missing from the same toggle, pre-existing from aethersdr#4919. Left alone -- filed separately rather than bundled. 2. Bound ResponseParser::feed(). The existing cap only runs on the no-marker path; once m_buf starts with ';' that indexOf always succeeds, so a device that goes malformed AFTER talking properly would append forever. A body past four records' worth now drops its marker and resyncs at the next one. 3. Fixed reflectedWattsFromSwr()'s comment, which directed callers to "the per-field liveness tracking in LpMeterConnection". No such thing was ever built -- the plan proposed it, Reading::coherent replaced it. Now points at decodeReading()/applyDimming(), and says outright that having no v1 caller is deliberate. 4. An operator lowering a ceiling after auto-expansion silently no-opped. RangeTracker::setCeilings() served both a config re-read and a context-menu edit, and the up-only guard is right for the first and wrong for the second: the ceiling did not move, so gaugeCeilingChanged() never fired and the operator got no change AND no feedback. Now takes a CeilingSource; an OperatorEdit always wins. Honouring a mistaken edit is self-correcting -- power above the new ceiling re-expands within kCeilingExpandRecords records. 5. m_gateSettleTimer restarted itself forever. The '|| !isActive()' clause re-armed it on the first tick after each expiry, costing a timer event every ~1.6 s for the life of the connection while the timeout early- returned. Restart on an actual state change only -- plus a latched first-report clause, because a pure change-only test would never fire at connect (the gate agrees with the initial value) and would have dropped the opening POLLING/SHARED line from the support bundle. Not taken: the setDeviceInt truncation nit. The cast is already explicit at the call site and QInputDialog::getDouble is called with 0 decimals, so a fractional full scale is unreachable twice over. Added a comment saying so instead. Tests: 49 -> 56 assertions. Three rows pin the CeilingSource split in both directions so the paths cannot be merged back, one pins the parser bound, one pins that it still resyncs afterwards. Colour ratchet +0/+0/+0, manifest --check clean, full app builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…thersdr#5315) The VKAMP note added in 6c5deb5 ended "Filed separately." No issue was filed. The observation itself is accurate -- m_vkamp really is missing from the auto-reconnect toggle, pre-existing from aethersdr#4919 -- but stating a filing that did not happen is exactly the kind of claim a future reader would believe and act on, and would waste time searching for. Reworded to say what is true: it needs its own one-liner, and it is not bundled here because bundling an unrelated shipped-code fix into a feature PR is what the scope-discipline rule exists to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b1569df to
54e55a7
Compare
1. Collapsed m_prevForeignMs into m_lastForeignMs. They were initialised together, reset together and assigned the same value on adjacent lines, and each was read in exactly one place. Two names implying a last-vs-previous distinction the code never made. onRecord() reads the single timestamp to compute the gap before overwriting it, which is the same order of operations the pair had. 2. An operator edit now only takes effect when it moved the range actually on screen. The context menu edits one range at a time while the gauge follows whichever the meter reports, so editing Low while High was displayed and auto-expanded overwrote High's expanded ceiling with its unchanged configured value and cleared the flag. Self-correcting within kCeilingExpandRecords, but a scale that visibly jumps because the operator edited a DIFFERENT range is its own defect. Two test rows: an edit to another range leaves the displayed expansion alone, an edit to the displayed range still applies -- so this cannot widen into "operator edits never apply". 3. Blank line after the docs/architecture/README.md entry, matching its neighbours. Tests 56 -> 58 assertions. Ratchet +0/+0/+0, manifest --check clean, full app builds. Staged with explicit paths. The three stray files in the previous revision of this branch came from 'git add -A' after reviewing with 'git diff', which reports tracked changes only -- the review could not have shown what the commit was about to add. They are gone from history now; the habit is the actual fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thank you — this is the review that caught the thing I couldn't see. All three blockers are gone from history, not just from the tip, and the three nits are fixed. Head is The three files, and how they got inThey are not artifacts of this work. All three were already untracked in my working tree before the branch existed — The mechanism is worth writing down because it is reusable and it is not "I didn't look":
The review step and the commit step had different scopes, so the check I ran was structurally incapable of showing what the commit was about to do. And because all three files pre-dated the branch, nothing in my own work drew attention to them. You spotted the tell I should have: The other ten commits used explicit paths and were clean — I verified each one. Removed by history rewrite, not a follow-up commit, so Not bundled, per your reasoning on both:
Nits — all three fixed
Tests 56 → 58 assertions. Ratchet On the manifest driftAgreed, and I have the control for it. Regenerating on a pristine Root cause beyond this PR: Independent agreement on two of your other points, checked before reading yours: CodeGuard's five are all pre-existing ( 73, Ryan NF0T 👨🏼💻 Co-authored by Claude Opus 5 |
rfoust
left a comment
There was a problem hiding this comment.
Thanks for the thorough implementation and for addressing the earlier review threads. I reviewed the current-main merge result at base b32d035a / head 07cc2585, built the full application locally on ARM64, ran the focused protocol test, and ran the strict boundary, registration, accessibility, and diff checks. CI is also green on Linux, macOS, Windows, and static checks.
I found three correctness blockers:
-
Reject unbounded and noncanonical numeric fields before they reach the gauges.
looksLikeRecord()enforces separator/field widths only when the body is exactly 42 bytes, whileparseDouble()accepts exponent notation and any finitedouble. I reproduced an oversized parser-bounded record reachingReadingas1e+100watts; a canonical-width exponent-form value can reach the same path. After two readings,RangeTrackerauto-expands the ceiling,LpMeterAppletconverts it tofloatinfinity, andevenTicks()converts infinity/NaN toint. Since this protocol has no checksum, the boundary validation is the integrity mechanism. Please require field-specific lexical formats and documented widths/variants, impose generous physical maxima, defensively reject nonfinite GUI values, and add regressions for exponent-form, oversized-power, and extra-field-byte records. (src/core/LpMeterProtocol.cpp:25,:91;src/gui/LpMeterApplet.cpp:56,:193.) -
Correct return loss at SWR 1.0.
returnLossDb()returns 0 dB for a perfect match, and the focused test explicitly pins that result. At SWR 1, the reflection coefficient is zero, so return loss approaches positive infinity; 0 dB represents total reflection. Please represent the limit safely—either infinity with appropriate formatting or the meter's documented display ceiling—and update the test. (src/core/LpMeterProtocol.cpp:291;tests/lp100a_protocol_test.cpp:249.) -
Cancel a pending retry when auto-reconnect is disabled.
setAutoReconnect()only flips the flag, while an already armed timer callback never rechecks it. If the operator disables the option during the five-second delay, the meter still reconnects once. Please stop the timer when disabling and/or guard the callback, and add a timer-lifecycle regression. (src/core/LpMeterConnection.h:74;src/core/LpMeterConnection.cpp:53.)
The overall issue fit and scope are strong: protocol, serial/raw-TCP transport, read-only polling, applet/settings integration, tests, and provenance/design documentation all belong here. I found no remaining unrelated artifacts in the current delta. The four existing review concerns appear addressed in the current code, although their threads remain unresolved.
After these correctness fixes, the disclosed range-default and applet/menu-placement choices still need maintainer/CODEOWNERS judgment.
No application launch, hardware contact, or TX activity was performed during this review.
…sdr#5315) 1. BOUNDARY VALIDATION -- a malformed record reached the GUI as infinity. QString::toDouble() accepts exponent notation, and no field had an upper bound. The case that defeats the obvious defence: '01e+100' is EXACTLY seven characters, the canonical Power width, so it passed the separator- offset check too. It decoded to 1e100 W, propagated into RangeTracker's ceiling, reached the applet as a float infinity, and evenTicks()'s static_cast<int> on it is UNDEFINED BEHAVIOUR -- inf*0.0f is NaN, so even the first tick was UB. Reproduced against the real library before fixing. Fixed with per-field strict lexical form (digits, at most one decimal point, sign only on dBm, no exponent/hex/whitespace) plus a per-field width cap. The width cap IS the value bound -- n characters cannot express 10^n -- so the maxima are read off the measured wire format rather than invented. evenTicks() also rejects a non-finite scale on its own account. Three gaps the review did not name, found while reproducing: SWR, Z and dBm were equally unbounded (only power was cited), and callsign had no length cap -- a 200-character callsign was accepted straight into a QLabel. All four closed by the same width table. Also unified the sign policy behind signAllowedFor(). looksLikeRecord() and decodeReading() parse the same fields and MUST agree; they briefly diverged while this was being written, and because decodeReading() ignored parseDouble()'s return value it published a silently defaulted dBm rather than refusing. It now checks the returns. 2. RETURN LOSS was inverted. returnLossDb() special-cased SWR <= 1.0 to return 0.0 dB -- the value for TOTAL REFLECTION. A matched load and a dead short rendered identically, on screen throughout receive, because the meter idles at SWR 1.00. The comment called it "the division-by-zero edge every naive implementation gets wrong", wrong twice over: gamma at SWR 1 is (1-1)/(1+1) = 0, so nothing is divided by zero; the divergence is log10(0). The function now returns +infinity, which is correct. The applet shows 'RL >52 dB', because the 2-decimal SWR field means a reported 1.00 is [0.995, 1.005) and justifies no more -- a bound derived from the field's own quantisation, not chosen. What the LP-100A itself displays at a perfect match is undocumented and unobserved; recorded in the design note's unverified list, and if it has a house convention we should match the instrument. A unit test PINNED the wrong value, so the suite protected the defect. The replacement asserts monotonicity -- a near-match must be LARGE -- so an inverted implementation cannot pass by hitting one point. 3. AUTO-RECONNECT could not be cancelled. setAutoReconnect() only flipped the flag and the armed timer's callback never re-read it, so disabling the option during the five-second delay still produced one reconnect. The setter now stops the timer AND the callback re-reads the flag. Context the review did not have: AcomConnection, SpeConnection and VkampConnection are byte-identical and have the same defect. Theirs to fix, not this PR's -- but copying it would have been exactly the mistake the design note's standing rule exists to prevent. This is now a deliberate divergence from three siblings and is documented as one. New target lp100a_reconnect_test covers the timer lifecycle through the public API, arming the retry from a loopback port nothing listens on. It SKIPS (exit 77) where loopback is unavailable rather than failing, because a flaky red is worse than a gap. Declared in tests/tests.cmake per aethersdr#5002. Tests 58 -> 75 assertions plus 4 in the new target. Ratchet +0/+0/+0, manifest --check clean, full app builds, full ctest shows no new failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thank you — all three are real, all three are fixed in 1. Boundary validation — confirmed, and broader than your descriptionYou framed this as offsets being enforced only at exactly 42 bytes. That's true, but the case that matters defeats the obvious fix:
Your UB call is right, and it's worse than one cast: Three gaps beyond the ones you cited, found while reproducing:
Credit where the existing guard held: On "generous physical maxima": I went a slightly different way, because I couldn't justify any specific invented number. The bounds are now read off the measured wire format — n characters cannot express a magnitude of 10ⁿ, and a 7-character Power field in One thing the fix itself exposed. 2. Return loss — you are right, and it was wrong twice overThis is the catch of the review. Two AI reviews passed over it. SWR 1.001 gives 66 dB; SWR 1e9 — near-total reflection — gives ≈0 dB. The special case snapped a perfect match to the value for a dead short, and rendered it throughout receive, since the meter idles at SWR 1.00. The comment made it worse by asserting the opposite while claiming the edge was handled: "0.00 dB at a perfect match, which is the division-by-zero edge every naive implementation gets wrong." It isn't a division by zero — γ = (1−1)/(1+1) = 0 — the divergence is On the display, I took your second option but derived the ceiling rather than picking one. The function now returns What the LP-100A itself shows at a perfect match is undocumented and unobserved by us — it's now in the design note's unverified list. If it has a house convention, matching the instrument beats our derivation and I'll change it. 3. Reconnect cancel — confirmed, with context you didn't haveFixed both halves: the setter stops the timer, and the callback re-reads the flag. All three sibling peripherals are byte-identical and have the same defect: That doesn't excuse it here — copying a working sibling's defect is exactly what the design note's standing rule exists to prevent, and I'd audited that class and missed it. But it does mean this is now a deliberate divergence from three peers, which is documented as such rather than left to look like an inconsistency. Their identical bug is a separate follow-up, not this PR's to fix. Regression added as you asked: VerificationTests 58 → 75 assertions in the protocol suite plus 4 in the new target. Colour ratchet Design note updated in three places: the boundary-validation reasoning in §3 (including why The four earlier threads are unresolved because I can't resolve my own — happy for them to be closed out whenever you or the bot are satisfied. 73, Ryan NF0T 👨🏼💻 Co-authored by Claude Opus 5 |
Summary
Closes #5315. Adds support for the TelePost LP-100A digital vector RF
wattmeter as a station peripheral — live power, SWR, impedance, phase and dBm
over the meter's RS-232 port, from either a local serial port or a raw-TCP
serial proxy (ser2net / Lantronix / Digi). Requested by users in the Facebook
community.
It follows the four-peripheral precedent already in
main—AcomConnection,SpeConnection,VkampConnection,GreenHeronModel— rather than inventinganything: a pure protocol layer, a transport, a dedicated applet, and a
Peripherals row. Not behind
IRadioBackend; the LP-100A is a standaloneinstrument the radio has never heard of.
Read-only in v1. The meter's three write commands (
A/M/F) aredeferred deliberately — every one is a blind increment with no acknowledgement,
so a UI built on them must never show a commanded state, only what the next
poll reports. That rule is written into the design note now so v2 inherits it.
Developed against real hardware throughout. Every wire fact below was measured,
not taken from a document — and the manual turned out to be wrong about
three of them.
What the hardware settled that the manual did not
sends 42. The extra character is in the Z field, which is zero-padded to
5 where the manual prints 4 — not, as an early draft of this work assumed,
a missing pad on the Power field. Both readings fit the length equally well,
which is exactly why it needed a capture rather than an inference.
peak-hold modes. Cycling
AandFthrough full rotations gives six(0–5) and three (0–2).
transmits it. The manual (p.12) has the operator recover it by QSY-ing
~100 kHz and watching the reactance slope, and LP-Plot automates that only
"since it can control your transmitter's frequency". 954 captured records
across three frequencies contained no sign character. So the applet renders
|Z| and |phase| and never signed reactance or an
R+jXform.Three design points worth a reviewer's attention
A record is not a coherent snapshot. At key-up the meter holds power and
dBm for ~1.7 s while Z, phase and SWR revert to idle: 54 of 894 captured
records carry real power beside
SWR 1.00. So nothing cross-derives betweenfields — that is why there is no reflected-power readout, which
P_fwd·Γ²would render as 0 W against 8.77 W forward. Detection is fromphysics (|Z|∠φ must reproduce the reported SWR) rather than from the peak-hold
field, because the mechanism is "power and impedance have different time
constants" and Avg mode has its own hold.
The wire is commonly shared, so the poll loop is gated. Other clients often
poll the same ser2net port, and a second connection receives their replies
(measured: 60 complete records in 6 s while sending nothing). Polling blindly
on top of that doubles the meter's work and makes any blind-read client
misattribute our replies to its own polls.
PollGaterides along when someoneelse is polling and polls when the wire is quiet. The subtlety: gating on "no
record in the last N ms" does not work, because our own replies reset the
same timestamp — measured against a 100 ms foreign cadence, N=130 ms caps solo
polling at 7.7 Hz while N=100 ms polls over the other client 48.5% of the time.
Gating on foreign records breaks that coupling.
There is no checksum in this protocol, so
looksLikeRecord()— fieldcount, separator positions, per-field parse, physical range checks — is the
entire integrity mechanism. The reference implementation consulted validates
length alone, which accepts any single flipped digit.
Constitution principle honored
byte stream is untrusted and unchecksummed.
looksLikeRecord()bounds thebuffer, validates field shape and ranges, and fails closed; the parser
self-heals on garbage (it silently absorbs the ser2net connect banner) and
caps its buffer when no frame marker ever arrives.
All settings go through
PeripheralSettingsunder one nestedLp100aobject. No new flat-key
AppSettingscalls.manufacturer's published manual plus our own Node-RED flow and observation of
the wire. No vendor code. Provenance recorded in
THIRD_PARTY_LICENSES.poll byte and nothing else; it touches no transmit path.
Test plan
aethercoreandAetherSDRclean, no new warningsthe idle and transmit paths, including a range transition observed during
an FT8 CQ at 43.10 W (meter reported Range Mid; the applet scaled to the
Mid ceiling)
ctestsuite run; the new test passes and nofailure is attributable to this branch (see Pre-existing failures below)
lp100a_protocol_test, 49 assertions, declared intests/tests.cmake, links onlyQt6::Core, needs no hardware and nodisplay
The protocol tests run against literal captured bytes and the manual's own
example rather than against our own encoder — the property that lets them catch
a real bug. They also pin each place this implementation deliberately diverges
from the reference flow, so a later "simplification" back to it fails loudly.
An independent cross-check worth stating: |Z| and phase reproduce the SWR the
meter separately reports to a mean absolute error of 0.0038 over 218
records under drive. That validated the field interpretation against physics
rather than against another document — and it is what surfaced the coherence
problem above, after the aggregate first came back wrong.
Checklist
AppSettingscalls — nested JSON under one key(Principle V)
MeterSmoother— both gauges areHGauge, whichdrives its animation through it
docs/architecture/lp-100a-wattmeter-design.md,docs/architecture/README.mdentry,peripheral(lp100a)touchpoint tagwith the manifest regenerated, and
THIRD_PARTY_LICENSES.No
CHANGELOG.mdentry.ThemeManager{{color.*}}tokens only, so the colour ratchet countszero new literals. This deliberately does not reuse the amplifier
family's
makeValueLabel(), which carries one.Pre-existing failures in the suite
Three tests fail on this branch. None is caused by it, and I checked rather
than assumed:
bridge_docs_checkupstream/mainwith none of this code present — "docs verb table is STALE (71 verbs in the registry)". This branch adds no automation verbs.phone_tx_filter_numeric_entry_testqasciikey.cpp:470, a key-mapping assert under the offscreen platform.hl2_state_restore_testupstream/mainworktree. It linksaethercore, to which this branch adds two sources, so it was the one that genuinely needed the control rather than a file-list argument.bridge_docs_checklooks worth a separate fix; it is a stale generated table,not a real regression, but it will fail for everyone until someone regenerates
it.
Notes for review
Three questions remain genuinely open, and are documented as open rather
than rounded off (design note §8):
8.62–8.93 W against a 10 W TUNE at SWR ≈ 1.68; forward implies 0.53 dB of
coax and calibration loss, net implies 0.24 dB, and both are ordinary. It
needs a deliberately high SWR to discriminate. Until then the applet labels
the field "PWR" as the meter does and claims nothing, and nothing derives
from it in a way the answer would change.
zero-padded. This meter idles at −2.3 and peaks at 39.5, but a value in
−99.9…−10.0 would be 5 characters. The parser keys on field shape rather
than a hard length, so such a record degrades to a rejection, not a
misparse.
proxy refuses a second client or serialises per connection, the gate simply
never trips and we poll — the plain design. It is an optimisation that
engages where the transport allows and costs nothing where it does not.
On #4944 (where an undiscoverable peripheral gets configured): I raised a
third question on the issue — whether to gate the tile on stored configuration
rather than on the connection — and then withdrew it. The objection to
gate-on-connect is that a configured-but-unreachable device shows no tile at
all, but that does not bite here:
LpMeterConnectiondeliberately does notdrop the link when the meter stops answering, so a wedged meter keeps its tile
and shows
NO DATAinside it. This therefore follows ACOM/SPE/VKAMP exactlyand adds no further divergence.
That state is not hypothetical — during bring-up the meter wedged with the
serial link perfectly healthy (TCP up, ser2net serving its banner, zero records
until it was power-cycled).
VkampConnection's dead-link watchdog aborts itssocket in that situation, which hides its applet at the moment the operator
most needs the explanation. Worth a follow-up on its own.
Section 9 of the design note audits every borrowed behaviour with an
adopt / improve / reject verdict and a reason — including the rejections, and
including one against our own
AcomConnection: itskAutoRangeConsecutiveFrames = 2was chosen against an 8-bit checksum, andthis protocol has none, so the same constant is defensible here only because
looksLikeRecord()carries the weight instead. That record exists so a latercontributor does not "restore" the originals.
73,
Ryan Butler NF0T
👨🏼💻 Co-authored by Claude Opus 5