Skip to content

feat(kiwisdr): honest, API-policy-aware public-receiver browser#3679

Merged
ten9876 merged 5 commits into
mainfrom
feat/kiwi-public-directory
Jun 20, 2026
Merged

feat(kiwisdr): honest, API-policy-aware public-receiver browser#3679
ten9876 merged 5 commits into
mainfrom
feat/kiwi-public-directory

Conversation

@ten9876

@ten9876 ten9876 commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a "Browse public…" picker to the KiwiSDR add-antenna flow (Settings → Antennas) that lists public KiwiSDR receivers from kiwisdr.com/public — built from the ground up to honor each operator's external-API policy and to be a good network citizen.

The core idea: a KiwiSDR's ext_api value (published per-receiver in the directory and in /status) is the operator's external-API allowance. AetherSDR reads it and respects it before ever connecting.

How it honors operators

  • Web-only receivers are filtered out entirely. Receivers with ext_api=0 are never shown and never connected to — AetherSDR (an API client) simply doesn't offer them. The picker's status line reports how many were hidden.
  • Honest identity — fetches with an AetherSDR/<ver> User-Agent; never spoofs a browser to pass the directory's interactive gate. If an operator blocks us, that's their answer.
  • Manual, single-fetch — the directory is fetched only on an explicit "Browse public…" action, then session-cached (in-memory, never written to disk) so repeat opens re-serve it with no network request. A "Refresh list" button is the only way to re-fetch. No background polling, no enumeration, no caching-across-sessions.
  • Clean-room (Principle IV) — reads only server-published data (directory HTML comments + /status), never the KiwiSDR (GPL) source.

What's included

  • src/core/KiwiPublicDirectory.{h,cpp} — honest 3-step fetch (gate → token-unlock → list, auto-skips when the IP's already through) + parser; exposes apiPolicy() / mayConnectViaApi().
  • src/gui/KiwiPublicReceiverPicker.{h,cpp} — frameless (PersistentDialog) picker, API-only list, search, session cache, "Refresh list"; picking a receiver adds the profile immediately.
  • tools/kiwi_directory_poc.cpp (kiwi_directory_poc) — demonstration tool (live or offline) printing the per-operator policy breakdown + honor decision. This is the PoC shown to the KiwiSDR project.
  • tests/kiwi_public_directory_test.cpp — locks the parser + honor logic (a web-only receiver must never be API-connectable and is excluded by the picker filter).
  • docs/kiwisdr-public-directory.md — the good-citizen contract.

Verification

  • New test green; full app builds clean.
  • Live PoC run: 824 receivers parsed, 121 web-only (ext_api=0) correctly excluded from API use, identifying honestly as AetherSDR.
  • Picker verified in-app: API-only list, session cache (single fetch per browsing session), "Refresh list", direct add.

🤖 Generated with Claude Code

ten9876 and others added 4 commits June 19, 2026 22:42
Adds KiwiPublicDirectory: an honest reader of the public KiwiSDR directory
(kiwisdr.com/public) that surfaces each operator's external-API policy so
AetherSDR honors "web-only" receivers before ever attempting a connection.

- Parses the directory's per-receiver <!-- key=value --> metadata, exposing
  ext_api as KiwiPublicReceiver::apiPolicy() + mayConnectViaApi() (ext_api>0).
  ext_api=0 means the operator disabled the external API (web-only).
- fetch() does the honest interactive flow: gate -> token-unlock -> list, with
  an "AetherSDR/<ver>" User-Agent (never a spoofed browser), auto-skipping the
  token dance when the IP is already past the gate. Strictly manual — no
  polling, caching, or enumeration.
- tools/kiwi_directory_poc.cpp: demonstration tool (live fetch or offline parse)
  printing the per-operator policy breakdown + the honor decision per receiver —
  the proof-of-concept to show operators.
- tests/kiwi_public_directory_test.cpp: locks the parser + policy logic
  (web-only receiver must never be API-connectable; picker filter excludes it).
- docs/kiwisdr-public-directory.md: the good-citizen contract.

Reads only server-published data (directory HTML + /status), never the KiwiSDR
source (clean-room, Principle IV). Live run: 824 receivers, 121 web-only
correctly excluded from API use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds "Browse public…" to the KiwiSDR add-antenna row (Settings → Antennas).
It opens KiwiPublicReceiverPicker, which fetches the public directory on open
(one explicit user action) and lists ONLY receivers whose operator allows the
external API. Receivers with ext_api=0 are filtered out entirely and never
shown — AetherSDR honors "web-only" operators by not offering them. The status
line reports how many web-only receivers were hidden.

Picking a receiver fills the endpoint (host:port) and a suggested name into the
new-profile row so the user can confirm/rename before committing; the existing
addProfile flow is unchanged. Search filters by name/location/host. Built on the
tested KiwiPublicDirectory reader (honest UA, manual-only fetch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Don't hammer operators' servers: the public directory is fetched only on the
first "Browse public…" of a session. The result is held in a process-static
cache shared by every picker instance, so subsequent opens re-serve it with no
network request. The cache is in-memory only — never written to disk — so a new
app session always starts fresh.

The "Refresh" button is renamed "Refresh list" and is the sole way to force a
re-fetch (it overwrites the cache). The status line shows when the shown list is
cached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting a receiver in the picker filled the name/endpoint fields but didn't
commit them — the profile was only added once a field's editingFinished fired,
so the user had to Tab out to actually add it (bad discoverability).

Reorder so commitNewRow() is defined before the Browse button and call it
directly after the picker fills the fields, so picking a receiver adds the
profile on the spot. Manual entry (editingFinished/returnPressed) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ten9876 ten9876 requested review from a team as code owners June 20, 2026 06:14

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this, @ten9876 — this is a well-constructed feature. The "honor ext_api before we ever connect" framing is genuinely thoughtful, the clean-room sourcing is clearly documented, and the code follows AetherSDR conventions nicely: PersistentDialog (AppSettings-backed geometry, not raw QSettings), RAII via Qt parent ownership, namespaced, and a real unit test that locks the parser + the three ext_api regimes. Network errors are handled at every step of the fetch and the reply lambdas are bound to this as context, so in-flight requests disconnect safely on dialog teardown. CI is fully green. A few suggestions, none blocking:

1. Hardcoded/stale User-Agent version. KiwiPublicDirectory::userAgent() returns "AetherSDR/26.6 …" but the project is at 26.6.3 (project(AetherSDR VERSION 26.6.3) in CMakeLists.txt), and it'll drift on every bump. There's already an AETHERSDR_VERSION compile definition — but note it's currently set PRIVATE only on the AetherSDR target (CMakeLists.txt:1661), so it won't be visible to the new kiwi_public_directory_test / kiwi_directory_poc targets that compile KiwiPublicDirectory.cpp directly. Suggest adding AETHERSDR_VERSION to those two targets' target_compile_definitions and using it here with a literal fallback, so the honest identity stays honest after the next release.

2. No transfer timeout on the fetch. The 3-step gate→unlock→list flow has no timeout, so if a server stalls mid-handshake the picker sits on "Loading public receivers…" indefinitely with only Cancel as an escape. A req.setTransferTimeout(...) on each QNetworkRequest (and surfacing it through failed()) would close that boundary cleanly.

3. http:// for the directory. kDirectoryUrl is plain HTTP. For a feature whose whole premise is being a good, honest network citizen, preferring https://kiwisdr.com/public/ (with HTTP fallback if needed) avoids the cleartext fetch.

4. Minor: "web-only hidden" count under-reports. In onReady, m_hiddenWebOnly only increments for ApiPolicy::Disabled. Receivers with an unpublished policy (extApi == -1Unknown) also fail mayConnectViaApi() and are dropped from the list, but aren't counted anywhere, so the status line's hidden total can be short. The docs say ext_api is always published so this may be rare in practice — but a small "+N unknown" in the status would keep the accounting honest.

One scope note (not a change request): this PR honors the policy at the browse layer — a user can still hand-type an ext_api=0 host into the endpoint field. The connection-time enforcement appears to live in #3678's /status preflight, so the two landing together is what makes the "never connect to a web-only receiver" guarantee complete end-to-end.

Nice work overall.


🤖 aethersdr-agent · cost: $3.0933 · model: claude-opus-4-8

…icy hidden (#3679 review)

Review follow-ups (bot notes 1, 2, 4):

1. KiwiPublicDirectory::userAgent() now uses the build's AETHERSDR_VERSION
   instead of a hardcoded "26.6" that would drift each release; the test and
   PoC targets get the AETHERSDR_VERSION compile def so the honest identity is
   accurate everywhere (now "AetherSDR/26.6.3").
2. Each of the 3 fetch requests (gate / unlock / list) gets a 15s transfer
   timeout, so a stalled server surfaces via failed() instead of leaving the
   picker stuck on "Loading…" with only Cancel.
4. The picker now counts receivers dropped for an unpublished policy
   (ApiPolicy::Unknown), not just web-only (Disabled), and surfaces both in the
   status line ("N web-only, M policy-unknown hidden") so the accounting is honest.

Note 3 (prefer https for the directory) is not applicable — kiwisdr.com serves
the directory over http only (port 443 refuses), so https would break the fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ten9876

ten9876 commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review notes in c6e7d96: (1) the User-Agent now comes from the build's AETHERSDR_VERSION (no more hardcoded "26.6" drift — now AetherSDR/26.6.3, with the macro wired to the test/PoC targets too); (2) a 15s transfer timeout on each of the gate/unlock/list fetches so a stalled server surfaces via failed() instead of hanging the picker; (4) the picker now counts policy-unknown receivers in the hidden total, not just web-only.

On note (3) — preferring https:// for the directory: not applicable. kiwisdr.com serves the directory over HTTP only (port 443 refuses the connection), so switching to HTTPS would break the fetch. Kept HTTP.

@ten9876 ten9876 merged commit a71a1c8 into main Jun 20, 2026
6 checks passed
@ten9876 ten9876 deleted the feat/kiwi-public-directory branch June 20, 2026 15:13
ten9876 added a commit that referenced this pull request Jun 23, 2026
…3750 H1) (#3751)

Fixes the **H1** item from #3750 (SmartMTR fast-follow), introduced with
the SmartMTR meter view in #3723.

## Problem
`VfoWidget::pushSmartMtrInput()` hardcodes the FLEX dBm scale (`-127` S0
.. `-13` S9+60) for **every** source. When a KiwiSDR slice has no
calibrated meter, the receive path sets the sentinel `m_signalDbm =
-130` and the dBm label correctly shows **"Meter ---"** — but the
SmartMTR needle clamped `-130` to S0 and rendered a confident **S0**,
and the numeric value labels printed the bogus sub-S0 dBm.

The PR author wasn't aware of our KiwiSDR public-receiver feed (#3679),
so this interaction was on us (maintainers) to fix.

## Fix
Track whether the current signal reading is a real calibrated dBm:
- new `m_signalHasDbm` — `true` for FLEX (`setSignalLevel`), set from
the KiwiSDR meter capability in `setReceiveMeterReading`;
- feed it into `MeterInput::hasValue` for the `Signal` kind.

The widget **already** parks/fades the indicator and suppresses the
value labels when `hasValue == false` (see `indicatorPosition()` →
`kScaleMin`, the `kSignalFadeLoDbm` needle target, and the `if
(m_input.hasValue)` value-label guard). So no widget change is needed —
the SmartMTR view now shows no-data, matching the "Meter ---" label,
instead of a false S0. The mic (TX) path keeps `hasValue = true`.

## Scope
3 small edits in `VfoWidget.{cpp,h}`, behavior-neutral for FLEX (the
flag is always `true` there). No change to the S-meter path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant