diff --git a/.github/apply-main-ruleset.sh b/.github/apply-main-ruleset.sh new file mode 100755 index 0000000..1fc28fb --- /dev/null +++ b/.github/apply-main-ruleset.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Protect main. ⚠️ ONLY WORKS ON A PUBLIC REPO (or GitHub Pro) - the API answers +# 403 "Upgrade to GitHub Pro or make this repository public" otherwise, which is +# why this is a script to run at the moment of flipping rather than a setting +# somebody is supposed to remember afterwards. +# +# What it does and does NOT do, deliberately: +# * requires the three CI checks to pass - the gates are the point +# * blocks force-push and deletion of main - the two irreversible ones +# * requires a pull request, with ZERO approvals - a solo maintainer cannot +# approve their own PR, so requiring one would lock the repo against its +# only committer. The PR requirement still buys the CI gate and a diff to +# read before merging. +# * lets the repo ADMIN bypass - so a broken CI config can +# never leave you unable to fix your own repository. +set -euo pipefail +REPO="${1:-jwussler/hamdeck-cpp}" +cd "$(dirname "$0")" +echo "applying the main ruleset to $REPO" +gh api --method POST "repos/$REPO/rulesets" --input main-ruleset.json \ + --jq '"created ruleset \(.id): \(.name) (\(.enforcement))"' +echo "verifying it is actually there:" +gh api "repos/$REPO/rulesets" --jq '.[] | " \(.name) \(.enforcement)"' diff --git a/.github/main-ruleset.json b/.github/main-ruleset.json new file mode 100644 index 0000000..786f6e8 --- /dev/null +++ b/.github/main-ruleset.json @@ -0,0 +1,34 @@ +{ + "name": "main", + "target": "branch", + "enforcement": "active", + "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "build-and-run" }, + { "context": "macos-universal" }, + { "context": "pusher" } + ] + } + } + ], + "bypass_actors": [ + { "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" } + ] +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d573ac..a6bd635 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ name: release # ⚠️ EVERY JOB RUNS THE BINARY BEFORE PACKAGING IT. A green build proves the code # compiles and proves nothing about whether the program starts - the .NET client # shipped a release that could not launch at all while every test passed -# (CARRYOVER.md section 8). `--selftest` walks the startup path and exits, and it +# (docs/internal/CARRYOVER.md section 8). `--selftest` walks the startup path and exits, and it # runs under a timeout because a hang is a failure too. on: diff --git a/CLAUDE.md b/CLAUDE.md index d38dfdd..f59c7ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Windows client stops working. ## Read these first -- **`CARRYOVER.md`** (this repo) — the API surface, the audio chain with measured numbers, +- **`docs/internal/CARRYOVER.md`** (this repo) — the API surface, the audio chain with measured numbers, the three PTT traps, and a list of things that are **not possible** so they don't get retried. Written from measurements on the live station, not recollection. - **`~/hamdeck-site/brand/BRAND.md`** — the visual identity. **Anything with a user @@ -38,7 +38,7 @@ Judge proposals on whether they make the C++ build better, not on whether C++ wa a single density scale (`Theme.u()`/`f()`, from `Backend::uiScale`) *and* reflow against the width actually available (`Theme.cols()`). No unscaled pixel constants. Prove it with `--check-resolutions`, which measures every key at seven screen sizes — and look at the - PNGs it writes. See WIP.md §8d, including the four ways that walk passed while measuring + PNGs it writes. See docs/internal/WIP.md §8d, including the four ways that walk passed while measuring nothing. - Suggested libraries, all OSI-approved so code signing stays possible: miniaudio (audio), cpp-httplib (REST), IXWebSocket, nlohmann/json, Dear ImGui if a lighter UI is wanted. @@ -58,7 +58,7 @@ now rather than later: 96 kB/s; the crypto is the load. Size the board for that. A Pi Zero 2 W or better is the realistic floor. - The USB codec (TI PCM2903C) and the CP2105 CAT bridge both need `linux-modules-extra` on a - stripped kernel — see CARRYOVER.md. Do not assume the Pi image ships `snd-usb-audio`. + stripped kernel — see docs/internal/CARRYOVER.md. Do not assume the Pi image ships `snd-usb-audio`. ## Non-negotiables @@ -76,7 +76,7 @@ now rather than later: Six bugs stood between "it compiles" and "a voice on the air". **Not one was a language or a build problem, and every single one looked healthy to the checks that existed.** Full account in -`WIP.md` §8f–§8h; these are the rules that came out of it. +`docs/internal/WIP.md` §8f–§8h; these are the rules that came out of it. ### Counting is not checking Frames accepted, `hw_ptr` advancing at 48 kHz, zero drops, a queue behaving — **all of it reads @@ -93,7 +93,7 @@ the project, because a zero-height item lays out and paints perfectly well. `tes drags one with synthetic mouse events. Do that for any control that matters. ### Comparing route INVENTORIES is not comparing behaviour -`AUDIT-CSHARP.md` ticked `/api/remote-tx/on` because the route existed. It answered `200` and +`docs/internal/AUDIT-CSHARP.md` ticked `/api/remote-tx/on` because the route existed. It answered `200` and changed nothing, and the status route beside it **invented all three of its fields** so the two agreed with each other. ⚠️ **The test that catches this: call the route, then read the radio back through something that is NOT the route under test.** diff --git a/CMakeLists.txt b/CMakeLists.txt index 5213c7c..8d08b6e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,7 +43,7 @@ add_executable(hamdeck-host src/main.cpp src/log.cpp src/amp_tuner.cpp src/api.c target_link_libraries(hamdeck-host PRIVATE civetweb-c-library OpenSSL::Crypto nlohmann_json::nlohmann_json asound) target_compile_options(hamdeck-host PRIVATE -Wall -Wextra) -# CI must RUN things, not just build them (CARRYOVER.md section 8): the .NET +# CI must RUN things, not just build them (docs/internal/CARRYOVER.md section 8): the .NET # client shipped a release that could not launch while every test passed. enable_testing() add_executable(test_staleness tests/test_staleness.cpp src/cat_sim.cpp src/radio.cpp src/session_stats.cpp) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..260cc7f --- /dev/null +++ b/LICENSE @@ -0,0 +1,33 @@ +MIT License + +Copyright (c) 2026 Henry Wussler (WA0O) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +Third-party components distributed with the binaries: + + Qt 6 LGPL-3.0. Linked DYNAMICALLY and its licence text ships with + every package, which is what keeps this MIT project able to + distribute it. Do not link Qt statically without a commercial + licence. + SIL OFL fonts SIL Open Font License 1.1. The licence travels with any binary + that embeds them; see client/fonts/OFL.txt. + civetweb MIT. nlohmann/json MIT. OpenSSL Apache-2.0. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d02174 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# HamDeck + +Operate an HF station from another room, or another country. A small always-on +host sits at the radio doing CAT control and audio; a desktop client shows the +panel and keys the transmitter. + +![The HamDeck client, connected](docs/images/panel.png) + +Built for and tested against a **Yaesu FTDX-101MP**. Other Yaesu CAT radios are +likely close, but nothing else has been on the air with it — see +[What is actually tested](#what-is-actually-tested). + +## Download + +Installers for every platform are on the +[latest release](https://github.com/jwussler/hamdeck-releases/releases/latest). + +| | | +|---|---| +| **Windows** | `HamDeck-win-Setup.exe` — client **and** the Wavelog pusher, one installer, updated together | +| **Windows, client only** | `HamDeckRemote-win-Setup.exe` — just the radio panel, no auto-update | +| **macOS** | `HamDeckRemote-macOS.dmg` — signed, notarised and stapled; drag to Applications | +| **Linux** | `hamdeck-client__amd64.deb` / `_arm64.deb` | + +Everything is code-signed. On Windows the publisher reads **Henry Wussler**; +SmartScreen may still warn, because it asks *"have I seen this file before?"* +rather than *"is this safe?"*, and a new release from a small publisher always +starts at no. + +## How it fits together + +``` + radio ──USB──┤ host ├── CAT + audio ──── HTTP / WebSocket ────┤ client │ + (CAT + (any always-on Linux box; (Windows, macOS, + USB audio) a Raspberry Pi is the target) Linux) +``` + +**The host is the authority. The client is a display that asks.** Every limit +that matters lives at the radio, because a client can be closed, crashed, or run +from a laptop that goes to sleep mid-transmission: + +- a **transmit watchdog** drops PTT after a timeout and confirms with the radio + that it actually stopped +- **power returns to the local cap** when a remote client disconnects, so nobody + walks up to a radio and drives an amplifier with twice the power they expect +- RX is **muted while you are keyed** — hearing your own voice back at ~220 ms is + delayed auditory feedback, and it makes people slur and stutter + +## What it does + +- Full panel: twin VFO, band and mode, S-meter, filters, RIT/XIT, AGC, + attenuator, preamp, noise blanker and reduction, antenna selection +- **Receive audio** streamed to the client, and **transmit audio** back to the rig +- **PTT** by hotkey or on-screen, with the watchdog above behind it +- **Recording** — continuous, plus a pre-trigger replay buffer that saves what + happened *before* you pressed anything. Every recording writes a JSON sidecar + with UTC times, frequency and mode, so it can be matched to a log later +- **Wavelog** integration on Windows: the log follows the radio +- A local REST API on loopback, so **Stream Deck** buttons work + +## What is actually tested + +Being straight about this, because a remote transmitter is not a good place for +optimism: + +| | | +|---|---| +| Radio | Yaesu FTDX-101MP, over its USB CAT + audio codec | +| Host | Ubuntu 24.04, x86-64. ARM64 builds; a Pi is the intended home but has not run a station yet | +| Client | Windows x64, macOS (universal), Linux x64/ARM64 | +| Not implemented | CW keyer, voice memories, RX antenna switching — those buttons say so rather than failing silently | +| Not built | Windows on ARM native (x64 runs under emulation) | + +## Building + +```sh +# host (Linux) +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build + +# client (Windows, macOS, Linux) — needs Qt 6.8+ +cmake -S client -B client/build -DCMAKE_BUILD_TYPE=Release && cmake --build client/build +ctest --test-dir client/build +``` + +## Before you expose it + +Read [SECURITY.md](SECURITY.md). The short version: the API port is meant for +**loopback**, the dashboard needs a session, and anything reachable from outside +your LAN belongs behind a tunnel or a reverse proxy that terminates TLS. + +Remote operation does not change whose callsign is on the air. Control of the +transmitter, and the obligation to identify and stay in band, stay with the +operator. + +## Licence + +MIT — see [LICENSE](LICENSE). Qt is LGPL-3.0 and is linked dynamically, with its +licence text shipped alongside every binary. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e833e70 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,58 @@ +# Security + +HamDeck keys a transmitter over a network. That is not a normal web app threat model, +so this page says plainly what is protected, what is not, and what must never be +exposed. Read it before putting any part of this on a public address. + +## Reporting a problem + +Open a GitHub issue for anything already public. For something that would let a +stranger transmit on someone else's licence, **do not open an issue** — use GitHub's +private vulnerability reporting on this repository so there is time to ship a fix. + +## The trust model, in one line + +**The host is the authority. The client is a display that asks.** Every limit that +matters — the transmit watchdog, the local power cap, who may transmit at all — lives +in the host, because a client can be closed, crashed, or driven from a laptop that +went to sleep mid-transmission. + +## What is authenticated + +| surface | who can reach it | +|---|---| +| dashboard port, `/ws`, `/ws/tx` | session required (login, or a `hamdeck_session` cookie) | +| `/api/health` | **no session** — liveness and tuner state only, deliberately | +| the API port | intended for **loopback**: local tools such as Stream Deck | +| `/api/admin/*` | an admin account | +| amp tune | local console, or an account explicitly marked as the station | + +⚠️ **`allow_anonymous_status` removes the session requirement from receive audio.** +It exists for a reason and it is off by default. Turning it on to make a page work is +the wrong fix — log in instead. + +## What must not be exposed + +- **Do not put the API port on a LAN address or the internet.** It is designed for + loopback. An earlier .NET version of this project bound its Wavelog bridge to + `http://+:54321/` with no authentication at all, where a bare `GET /14074000` + retuned the radio. That is the mistake this section exists to prevent. +- **Put any remote access behind a tunnel or a reverse proxy that terminates TLS.** + The host speaks plain HTTP and WebSockets and does not pretend otherwise. +- **Do not expose the CAT proxy.** It is a serial port to the radio wearing a socket. + +## Safety properties you should not remove + +- **The transmit watchdog** drops PTT after `ptt_timeout_seconds` (default 180) and + confirms with the radio that it actually stopped. Without it, a dropped link leaves + the rig keyed with nobody watching. +- **Power returns to the local cap when a remote client disconnects**, so nobody walks + up to a radio and drives an amplifier with twice the power they expect. +- **Recording is off unless a path is configured**, and PTT auto-record is off unless + it is switched on. It records whoever you are talking to. + +## Licensed-operator responsibility + +Remote operation does not change whose callsign is on the air. Control of the +transmitter, and the obligation to identify and to stay in band and licence class, stay +with the operator. Nothing here supervises that for you. diff --git a/client/qml/HamDeck/ConnectPanel.qml b/client/qml/HamDeck/ConnectPanel.qml index 11860cf..1f70b7a 100644 --- a/client/qml/HamDeck/ConnectPanel.qml +++ b/client/qml/HamDeck/ConnectPanel.qml @@ -6,7 +6,7 @@ import HamDeck // The connect screen. // // ⚠️ THERE IS NO DEFAULT HOST, AND THERE MUST NOT BE. A hostname compiled into a -// public repo points every install at that station (CARRYOVER.md section 6). The +// public repo points every install at that station (docs/internal/CARRYOVER.md section 6). The // field starts empty on a fresh install and is remembered afterwards. // // ⚠️ THE PASSWORD IS NEVER REMEMBERED. Host and username are; the password costs diff --git a/client/src/ptt_hotkey.h b/client/src/ptt_hotkey.h index 0c09fff..1b2e8a2 100644 --- a/client/src/ptt_hotkey.h +++ b/client/src/ptt_hotkey.h @@ -23,7 +23,7 @@ // // ⚠️ AUTO-REPEAT MUST BE SUPPRESSED. A held key repeats at the OS repeat rate, // and without filtering that flaps the transmitter on and off many times a -// second (CARRYOVER.md section 6 records this as mandatory on Windows via +// second (docs/internal/CARRYOVER.md section 6 records this as mandatory on Windows via // MOD_NOREPEAT). Handled here for every platform, not just Windows. #include diff --git a/client/src/rx_audio.h b/client/src/rx_audio.h index 461207e..aaa4492 100644 --- a/client/src/rx_audio.h +++ b/client/src/rx_audio.h @@ -21,7 +21,7 @@ class RxAudio : public QObject { ~RxAudio() override; // `device_name` is a device DESCRIPTION, not an index. Empty means the system - // default. CARRYOVER.md section 6: indices shift when USB devices come and go, + // default. docs/internal/CARRYOVER.md section 6: indices shift when USB devices come and go, // which is what produced a dead microphone; and index 0 is not "the default", // it is arbitrary and out of range when there are no devices at all. void Start(const QString& ws_url, const QString& device_name); @@ -32,7 +32,7 @@ class RxAudio : public QObject { // ⚠️ Muted while the rig is keyed. Hearing yourself at the round-trip delay is // delayed auditory feedback - it disrupts speech so reliably that speech labs // use it deliberately. The operator slurs, hears themselves doing it, and - // reports the link as broken (CARRYOVER.md section 4c). + // reports the link as broken (docs/internal/CARRYOVER.md section 4c). // // Driven off the rig's own tx state so every PTT source behaves alike, and // DROPS what queued on unmute so they come back live rather than replaying. diff --git a/client/src/settings.h b/client/src/settings.h index 98b20db..47c7acc 100644 --- a/client/src/settings.h +++ b/client/src/settings.h @@ -2,7 +2,7 @@ // Client settings. // -// ⚠️ THREE RULES FROM CARRYOVER.md SECTION 6, ALL LEARNED THE HARD WAY: +// ⚠️ THREE RULES FROM docs/internal/CARRYOVER.md SECTION 6, ALL LEARNED THE HARD WAY: // // 1. Settings live OUTSIDE the install directory, so an update cannot overwrite // them. QSettings with the platform config location does this. diff --git a/docs/AUDIT-AUDIO-ATTRIBUTION.md b/docs/AUDIT-AUDIO-ATTRIBUTION.md index 739af81..73284d3 100644 --- a/docs/AUDIT-AUDIO-ATTRIBUTION.md +++ b/docs/AUDIT-AUDIO-ATTRIBUTION.md @@ -78,4 +78,4 @@ the NetLogger sync) show medians of 206s and 63s and only ~13% under 20s, while the single biggest term in the slicing error and it has not been measured. Related: `qsl-card-system` (the QR spot on the card is the consumer of this), -[[netlogger-xml-api]], and section 1 of CARRYOVER.md for the recorder itself. +[[netlogger-xml-api]], and section 1 of docs/internal/CARRYOVER.md for the recorder itself. diff --git a/docs/images/panel.png b/docs/images/panel.png new file mode 100644 index 0000000..9d31294 Binary files /dev/null and b/docs/images/panel.png differ diff --git a/AUDIT-CSHARP.md b/docs/internal/AUDIT-CSHARP.md similarity index 100% rename from AUDIT-CSHARP.md rename to docs/internal/AUDIT-CSHARP.md diff --git a/AUDIT-WAVELOG.md b/docs/internal/AUDIT-WAVELOG.md similarity index 100% rename from AUDIT-WAVELOG.md rename to docs/internal/AUDIT-WAVELOG.md diff --git a/CARRYOVER.md b/docs/internal/CARRYOVER.md similarity index 100% rename from CARRYOVER.md rename to docs/internal/CARRYOVER.md diff --git a/DAY-08-30-2026.md b/docs/internal/DAY-08-30-2026.md similarity index 100% rename from DAY-08-30-2026.md rename to docs/internal/DAY-08-30-2026.md diff --git a/docs/internal/README.md b/docs/internal/README.md new file mode 100644 index 0000000..62cdeae --- /dev/null +++ b/docs/internal/README.md @@ -0,0 +1,21 @@ +# Internal engineering notes + +Working notes, not documentation. They are written to whoever is next at this +keyboard — candid, dated, full of things that turned out to be wrong, and +addressed to the operator by name in places. + +They are kept public deliberately. The reason a fix worked, and the measurement +that proved it, is more useful than a tidy summary that leaves out how long it +took to find. Several of these files exist because a green build lied. + +| file | what it is | +|---|---| +| `CARRYOVER.md` | the API surface, the audio chain with measured numbers, and a list of things that are **not possible** so nobody retries them | +| `WIP.md` | running build log — every trap, in the order it bit | +| `AUDIT-CSHARP.md` | walking the C# implementation down before writing anything | +| `AUDIT-WAVELOG.md` | the same for the Wavelog bridge | +| `DAY-08-30-2026.md` | one day's account, kept because the failure modes repeat | + +⚠️ Nothing station-specific belongs in this repository — no hostnames, +addresses, VM ids or tunnel details. Site detail lives in a gitignored +`SITE.md`. That applies to commit messages too. diff --git a/WIP.md b/docs/internal/WIP.md similarity index 99% rename from WIP.md rename to docs/internal/WIP.md index bceb5bd..d6ecb24 100644 --- a/WIP.md +++ b/docs/internal/WIP.md @@ -2,9 +2,12 @@ Mid-build handover. Written 08/30/2026. Read §1 and §2 before touching anything. -> **THIS REPO IS PUBLIC.** Nothing station-specific goes in it — no hostnames, addresses, VM -> ids, tunnel details, or live readings that say what the station was doing. That applies to -> **commit messages** too. Site detail lives in the gitignored `SITE.md`. +> **TREAT THIS REPO AS PUBLIC.** Nothing station-specific goes in it — no hostnames, +> addresses, VM ids, tunnel details, or live readings that say what the station was doing. +> That applies to **commit messages** too. Site detail lives in the gitignored `SITE.md`. +> ⚠️ This banner said "THIS REPO IS PUBLIC" for months while the repo was private. The rule +> was right and the fact was wrong, which is how a rule stops being taken seriously right +> before it starts mattering. It is public now; the wording is the instruction either way. > CARRYOVER.md §6 states the narrow version: *a hostname in a public repo points every install > at that station.* It generalises. diff --git a/packaging/hamdeck.iss b/packaging/hamdeck.iss index c1b6778..fe2a09a 100644 --- a/packaging/hamdeck.iss +++ b/packaging/hamdeck.iss @@ -3,7 +3,7 @@ ; ⚠️ THIS IS BUILT ON WINDOWS, NOT CROSS-COMPILED. Qt does not cross-compile ; comfortably, and more importantly a Windows binary that has never run on ; Windows is exactly the release the .NET client shipped that could not launch at -; all while every test passed (CARRYOVER.md section 8). The CI job runs the +; all while every test passed (docs/internal/CARRYOVER.md section 8). The CI job runs the ; binary on the runner before packaging it. ; ; ⚠️ UNSIGNED. Windows SmartScreen will warn on first run. Saying so is the diff --git a/pusher/CARRYOVER-WINDOWS.md b/pusher/CARRYOVER-WINDOWS.md index a34e5c3..07be4cd 100644 --- a/pusher/CARRYOVER-WINDOWS.md +++ b/pusher/CARRYOVER-WINDOWS.md @@ -134,7 +134,7 @@ including this pusher**. It converts a transient process conflict into a permane ### The actual fix Stop the legacy C# host. Checks done first, so this is safe rather than hopeful: `rig_connected:false` (the rig lives on **deck**, `192.168.40.64` now), and the local -cloudflared tunnel that used to publish its `:5002` as `radio.wa0o.com` **is not running on +cloudflared tunnel that used to publish its `:5002` as the station hostname **is not running on this PC** — so nothing public depends on it. Stop-Process -Name HamDeck -Force @@ -360,7 +360,7 @@ misleading. Do not re-run it as written; read §4 first.** ### Next, in order 1. 🔴 **Remove `HamDeck.lnk` from `shell:startup`** (or the C# host retakes 5001 at the next logon and the deck dies again with the same misleading error). Joe's call, because it also - ends the local `radio.wa0o.com` origin — which is already dead here anyway, since + ends the local the station hostname origin — which is already dead here anyway, since `cloudflared` is not running on this PC. 2. **Press real Stream Deck buttons and report which work.** Expect **CW keyer**, **voice memories** and **RX antenna** to fail — known unported features (§6), not new bugs. diff --git a/src/alsa_audio.cpp b/src/alsa_audio.cpp index eba4f5b..d367f25 100644 --- a/src/alsa_audio.cpp +++ b/src/alsa_audio.cpp @@ -117,7 +117,7 @@ bool AlsaPlayback::Open(const std::string& device, int sample_rate) { return false; } // ⚠️ A GENEROUS BUFFER, WITH THE FILL LEVEL CONTROLLED ELSEWHERE. - // CARRYOVER.md section 3: give the device room, then manage how full it is. + // docs/internal/CARRYOVER.md section 3: give the device room, then manage how full it is. // A small buffer just trades latency for underruns, and an underrun mid-word // is worse than 100 ms of delay. error_ = Configure(pcm_, sample_rate, sample_rate / 2, sample_rate / 50); diff --git a/src/alsa_audio.h b/src/alsa_audio.h index ca85a48..4fb3db7 100644 --- a/src/alsa_audio.h +++ b/src/alsa_audio.h @@ -12,7 +12,7 @@ // 8000 returns 11025 worth of data, silently. // playback device altsets advertise 32000, 44100, 48000 only. // default playback buffer measured 24048 frames at 48 kHz = 501 ms, matching -// the ~500 ms CARRYOVER.md section 3 records. +// the ~500 ms docs/internal/CARRYOVER.md section 3 records. #include @@ -60,7 +60,7 @@ class AlsaPlayback : public TxAudioSink { // that is always ~0, because the sender sends at real time - it reports // "nothing queued" while half a second sits in ALSA, and an estimate whose // failure mode is zero looks exactly like a working measurement - // (CARRYOVER.md section 3). That bug cut the end off every transmission. + // (docs/internal/CARRYOVER.md section 3). That bug cut the end off every transmission. long QueuedFrames() const; int QueuedMs() const override; long Xruns() const override { return xruns_; } diff --git a/src/api.cpp b/src/api.cpp index 5af3422..bab7bf9 100644 --- a/src/api.cpp +++ b/src/api.cpp @@ -237,7 +237,7 @@ std::string Pad(long long v, int width) { // ⚠️ POWER CAP, PORTED FAITHFULLY AND DELIBERATELY NOT "FIXED". // In the C# host a LOCAL caller is capped at 100 W while a remote caller gets // 200 W - which reads backwards, so it is exactly the kind of thing to port -// as-is and ask about rather than quietly invert. Flagged in WIP.md. +// as-is and ask about rather than quietly invert. Flagged in docs/internal/WIP.md. // Hard ceiling on how long unkeying may be delayed, whatever the buffer says. constexpr int kMaxDrainMs = 1200; @@ -357,7 +357,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, // checkable answer is. The reference host does not serve this route, so it // 404s there and any such tool must refuse on 404 rather than assume. // ── Capability reporting ─────────────────────────────────────────────────── - // ⚠️ CARRYOVER.md section 1: on the reference Linux build /api/record/start + // ⚠️ docs/internal/CARRYOVER.md section 1: on the reference Linux build /api/record/start // answers {"status":"ok","recording":true} while Start() sets IsRecording = // false. A 200 there means the route exists, not that anything is recording. // @@ -367,11 +367,11 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, // reads the same shape. The VALUES are honest: nothing is recording and there // is no capture backend, which is what available:false says. // - // ⚠️ CARRYOVER.md section 1: the reference /api/record/start answers + // ⚠️ docs/internal/CARRYOVER.md section 1: the reference /api/record/start answers // {"status":"ok","recording":true} while Start() sets IsRecording = false. The // only honest signal is this route's file_recording. Do not reproduce the lie. // ⚠️ Every field here is derived from what ACTUALLY happened, never from - // having been asked. CARRYOVER.md section 1: the reference /api/record/start + // having been asked. docs/internal/CARRYOVER.md section 1: the reference /api/record/start // answers ok/recording:true while Start() sets IsRecording = false. { Recorder* rec = deps.recorder; @@ -467,7 +467,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, } // Voice keyer: present on the reference host and answering, contrary to the - // note in CARRYOVER.md section 1 that lists it among the null services. + // note in docs/internal/CARRYOVER.md section 1 that lists it among the null services. server.Get("/api/voice/status", [](const HttpRequest&, HttpResponse& res) { WriteJson(res, 200, R"({"status":"ok","playing":false})"); }); @@ -737,7 +737,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, JsonBool(ok && deps.auth->IsAdmin(token)), JsonBool(ok && deps.auth->CanTransmit(token)), // ⚠️ So a client can GREY THE AMP TUNE BUTTON instead of showing a - // live one that answers 403. CARRYOVER.md section 2: "a button that + // live one that answers 403. docs/internal/CARRYOVER.md section 2: "a button that // always errors is worse than a missing one" - and it is also the // only way to confirm the right is live without keying an amplifier // to find out. @@ -768,7 +768,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, return; } - // The token goes in the cookie and NOT in the body - CARRYOVER.md section 2. + // The token goes in the cookie and NOT in the body - docs/internal/CARRYOVER.md section 2. // HttpOnly keeps it away from page scripts; SameSite=Strict closes the // top-level-navigation CSRF vector on state-changing GETs, of which this API // has many (/api/ptt/on is a GET). @@ -831,7 +831,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, // ⚠️ PTT ON is here; PTT OFF is below, because unkeying is not instant. Unkeying must wait for the audio still // queued in the ALSA buffer or the tail of every transmission is lost - // (CARRYOVER.md section 4a), and that wait needs the real device depth from + // (docs/internal/CARRYOVER.md section 4a), and that wait needs the real device depth from // /proc/asound. Shipping an unkey that drops PTT immediately would look // like it works and quietly cut the end off every over - the exact bug that // took a report from a net to find. It lands with the audio work. @@ -1086,7 +1086,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, // The last fraction of a second of every transmission is sitting in the ALSA // buffer when the operator releases PTT. Drop the carrier first and that audio // is never transmitted - the end of every over is cut off, and it sounds like - // the other station stopped listening. CARRYOVER.md section 4a; the bug it + // the other station stopped listening. docs/internal/CARRYOVER.md section 4a; the bug it // describes took a report from a net to find. // // ⚠️ WAIT THE DEPTH AT THIS MOMENT, NOT "UNTIL EMPTY". The microphone stays @@ -1444,7 +1444,7 @@ void InstallRoutes(HttpServer& server, Listener listener, int bound_port, hz, mode); }}); // ── Tuners: THREE different things, and confusing them is expensive ──────── - // ⚠️ /api/tune is the RIG'S INTERNAL ATU (AC002;). CARRYOVER.md section 2 is + // ⚠️ /api/tune is the RIG'S INTERNAL ATU (AC002;). docs/internal/CARRYOVER.md section 2 is // explicit that it is the WRONG tuner for this station; the right one is // /api/tune/tgxl. They are kept separate and each names itself in its reply so // a confirmation dialog cannot say "tuning" and leave the operator guessing diff --git a/src/audio.h b/src/audio.h index 6435ebc..a95a6ba 100644 --- a/src/audio.h +++ b/src/audio.h @@ -9,7 +9,7 @@ // ⚠️ The queue MUST be bounded and MUST drop, not block or grow. An unbounded // queue does not avoid loss, it converts loss into ever-growing latency, and // latency on a receive stream is indistinguishable from a broken link to the -// operator. CARRYOVER.md section 6 says it for the client side; it is just as +// operator. docs/internal/CARRYOVER.md section 6 says it for the client side; it is just as // true here. // // Which end gets dropped matters. Dropping the OLDEST keeps the stream current: diff --git a/src/cat.h b/src/cat.h index 21aff17..9da20a0 100644 --- a/src/cat.h +++ b/src/cat.h @@ -8,7 +8,7 @@ // station stays on the air. Assume it will outlive the port and be the thing CI // runs against, because CI will never have a radio. // -// ⚠️ The serial lock is NOT re-entrant across threads (CARRYOVER.md section 5). +// ⚠️ The serial lock is NOT re-entrant across threads (docs/internal/CARRYOVER.md section 5). // Nothing on a request thread may call into this. Only the poller does. #include diff --git a/src/cat_sim.cpp b/src/cat_sim.cpp index 1995321..e18ca22 100644 --- a/src/cat_sim.cpp +++ b/src/cat_sim.cpp @@ -17,7 +17,7 @@ std::string Pad(long long v, int width) { std::optional SimulatedRig::Exchange(const std::string& cmd) { std::lock_guard lock(mu_); - // ID; is the ONLY safe probe. CARRYOVER.md section 9: probing with a control + // ID; is the ONLY safe probe. docs/internal/CARRYOVER.md section 9: probing with a control // route once changed the operating mode mid-session. 0682 is the FTDX-101MP. if (cmd == "ID;") return "ID0682;"; // ⚠️ CW KEYER. Modelled because a route that CANNOT be exercised is a route nobody has diff --git a/src/cat_sim.h b/src/cat_sim.h index cbcaf82..9819f56 100644 --- a/src/cat_sim.h +++ b/src/cat_sim.h @@ -9,7 +9,7 @@ // // It deliberately does NOT simulate: audio (there is no audio over CAT), amp or // TGXL tuning (those are separate hardware), or timing. Anything about latency, -// buffer depth or PTT tail MUST be measured on the real station - CARRYOVER.md +// buffer depth or PTT tail MUST be measured on the real station - docs/internal/CARRYOVER.md // section 3 is explicit that estimates whose failure mode is zero look exactly // like working measurements. diff --git a/src/config.h b/src/config.h index 69e2d26..cb4aafc 100644 --- a/src/config.h +++ b/src/config.h @@ -7,7 +7,7 @@ // // ⚠️ NO DEFAULT SHIPS AN ADDRESS OR A HOSTNAME. The reference host's config // carries station LAN addresses as compiled-in defaults, and that source is -// public - CARRYOVER.md section 6 says a hostname in a public repo points every +// public - docs/internal/CARRYOVER.md section 6 says a hostname in a public repo points every // install at that station, and an address does the same. Here every such field // defaults to EMPTY, which means "not configured, feature off". An operator who // wants the feature says where it lives. diff --git a/src/http.h b/src/http.h index c2f2e52..361dd0f 100644 --- a/src/http.h +++ b/src/http.h @@ -48,7 +48,7 @@ using PreRouting = std::function; // ⚠️ Writes are serialised per connection. Two threads writing the same socket // interleave frames and corrupt the stream - the C# client hit the same wall from // the other side, where overlapping SendAsync calls are rejected outright -// (CARRYOVER.md section 6). Here the lock is taken inside Send*. +// (docs/internal/CARRYOVER.md section 6). Here the lock is taken inside Send*. class WsConnection { public: explicit WsConnection(mg_connection* conn) : conn_(conn) {} diff --git a/src/main.cpp b/src/main.cpp index 17f744f..a802448 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,7 +1,7 @@ // HamDeck C++ host. // // Route surface is in api.cpp; this file owns process startup and the listener -// split. See WIP.md for the road map and CARRYOVER.md for the traps. +// split. See docs/internal/WIP.md for the road map and docs/internal/CARRYOVER.md for the traps. #include #include @@ -56,7 +56,7 @@ void OnStopSignal(int) { // The control port trusting every caller is exactly why it must be bound to the // loopback address: the trust boundary is then enforced by the kernel, not by a // check somebody can forget to write. /api/tune/amp must refuse every remote -// caller (CARRYOVER.md section 2), and "is this caller local" is answered by +// caller (docs/internal/CARRYOVER.md section 2), and "is this caller local" is answered by // WHICH SOCKET ACCEPTED IT - never by a header, which the caller controls. constexpr const char* kControlAddr = "127.0.0.1"; // ⚠️ DEFAULTS ONLY. The real values come from the config - api_port and @@ -71,7 +71,7 @@ constexpr int kDashPort = 5002; } // namespace // Walks the whole startup path - poller, audio, auth, both listeners - proves the -// process actually serves a request, and exits. CARRYOVER.md section 8: the .NET +// process actually serves a request, and exits. docs/internal/CARRYOVER.md section 8: the .NET // client shipped a release that could not launch at all while every test passed, // because CI built the artifact and never ran it. // @@ -186,7 +186,7 @@ int main(int argc, char** argv) { // Synthetic RX audio: the codec is passed through to the reference host, so // there is no real capture device here. 22050 Hz mono/16-bit matches the wire - // format the client expects (CARRYOVER.md section 2). + // format the client expects (docs/internal/CARRYOVER.md section 2). // RX source: the real codec when one is named, a tone otherwise. A failure to // open a NAMED device is fatal, for the same reason a missing CAT device is: // a host that silently substitutes a test tone for the receiver would have the diff --git a/src/radio.h b/src/radio.h index 6099528..cdf83d9 100644 --- a/src/radio.h +++ b/src/radio.h @@ -4,7 +4,7 @@ // // ⚠️ THE POINT OF THIS FILE. /api/status is served ENTIRELY from this cache and // never touches the serial port from a request thread - the serial lock is not -// re-entrant across threads (CARRYOVER.md section 5). The C# Linux host shipped +// re-entrant across threads (docs/internal/CARRYOVER.md section 5). The C# Linux host shipped // with no poller at all, so /api/status served a frequency 3.6 HOURS stale and a // tx:true left over from a tune while the rig was receiving. A cache with nothing // refreshing it is worse than no cache: it answers confidently and wrongly. @@ -79,7 +79,7 @@ class RadioPoller { long long CacheAgeMs() const; // ⚠️ THE ONLY WAY A REQUEST THREAD MAY TOUCH THE RADIO. - // The serial lock is not re-entrant across threads (CARRYOVER.md section 5), + // The serial lock is not re-entrant across threads (docs/internal/CARRYOVER.md section 5), // so exactly one thread - the poller - ever speaks to the port. Handlers queue // a command and return; the poller drains the queue at the top of each cycle. // Queueing also gives commands a natural ordering, which matters for pairs @@ -96,7 +96,7 @@ class RadioPoller { // Transmit watchdog. Zero disables it. // - // ⚠️ THIS MUST LIVE NEXT TO THE RADIO (CARRYOVER.md section 4b). A timeout in + // ⚠️ THIS MUST LIVE NEXT TO THE RADIO (docs/internal/CARRYOVER.md section 4b). A timeout in // the client or the browser protects nothing: close the tab, sleep the laptop // or lose the link while keyed and the rig stays keyed with nobody watching. // The Linux host shipped without this for months because it existed only in diff --git a/src/recorder.h b/src/recorder.h index 311dc5e..60d9024 100644 --- a/src/recorder.h +++ b/src/recorder.h @@ -2,7 +2,7 @@ // Recording, and the replay buffer. // -// ⚠️ CARRYOVER.md section 1 is about THIS FEATURE. On the reference Linux build +// ⚠️ docs/internal/CARRYOVER.md section 1 is about THIS FEATURE. On the reference Linux build // /api/record/start answers {"status":"ok","recording":true} while Start() sets // IsRecording = false. A 200 there means the route exists, not that anything is // recording, and the only honest signal is file_recording in the status route. diff --git a/src/serial_cat.h b/src/serial_cat.h index ea07836..27afe99 100644 --- a/src/serial_cat.h +++ b/src/serial_cat.h @@ -9,7 +9,7 @@ // would have caught for free. // // ⚠️ Only ONE thread may ever call this. The serial lock is not re-entrant -// across threads (CARRYOVER.md section 5); RadioPoller owns it and request +// across threads (docs/internal/CARRYOVER.md section 5); RadioPoller owns it and request // threads queue commands instead. #include @@ -27,7 +27,7 @@ class SerialCat : public CatTransport { // Tries each candidate and keeps the first that answers the identity probe. // - // ⚠️ The probe is `ID;` and ONLY `ID;`. CARRYOVER.md section 9: probing with a + // ⚠️ The probe is `ID;` and ONLY `ID;`. docs/internal/CARRYOVER.md section 9: probing with a // control route once changed the operating mode mid-session. This also matters // because the CP2105 is a DUAL UART - one physical device enumerates two // serial ports and only one of them is CAT - so the port has to be identified diff --git a/src/tgxl.h b/src/tgxl.h index 94550f1..9572f70 100644 --- a/src/tgxl.h +++ b/src/tgxl.h @@ -2,7 +2,7 @@ // TGXL — the external antenna tuner, reached over TCP. // -// ⚠️ THIS IS THE RIGHT TUNER FOR THIS STATION. CARRYOVER.md section 2 is +// ⚠️ THIS IS THE RIGHT TUNER FOR THIS STATION. docs/internal/CARRYOVER.md section 2 is // explicit: /api/tune is the rig's INTERNAL ATU and is the wrong one. They are // kept separate and each names itself in its reply, so a confirmation can never // say just "tuning" and leave the operator guessing which box is about to key up. diff --git a/src/tx_audio.cpp b/src/tx_audio.cpp index ae498ff..ecdbc54 100644 --- a/src/tx_audio.cpp +++ b/src/tx_audio.cpp @@ -65,7 +65,7 @@ bool TxAudioReceiver::Accept(const char* data, size_t bytes, bool keyed) { std::lock_guard lock(mu_); if (queue_.size() >= kMaxQueuedChunks) { - // ⚠️ TRIM ONLY BETWEEN OVERS (CARRYOVER.md section 3). Dropping audio while + // ⚠️ TRIM ONLY BETWEEN OVERS (docs/internal/CARRYOVER.md section 3). Dropping audio while // the rig is KEYED is audible - a syllable vanishes mid-sentence. Between // overs it costs nothing, and with the mic open there is always idle time. // So while keyed we accept the latency and let the queue run long; the diff --git a/src/tx_audio.h b/src/tx_audio.h index 79697b6..a965a1b 100644 --- a/src/tx_audio.h +++ b/src/tx_audio.h @@ -5,7 +5,7 @@ // ⚠️ THIS PATH PUTS A HUMAN VOICE ON THE AIR. Every rule here is about not // transmitting the wrong thing, or transmitting nothing while appearing to work. // -// Wire format is 48000 Hz / 16-bit / mono (CARRYOVER.md section 2). RX is 22050 +// Wire format is 48000 Hz / 16-bit / mono (docs/internal/CARRYOVER.md section 2). RX is 22050 // and TX is 48000 because the codec's capture supports 8000-48000 but its // PLAYBACK only supports 32000-48000 - the asymmetry is the device's, not a // choice. @@ -117,7 +117,7 @@ class TxAudioReceiver { // never grows, so the deadlock never happens. static constexpr size_t kMaxQueuedChunks = 50; - // ⚠️ ADAPTIVE BUFFERING (CARRYOVER.md section 3), and it is not optional. + // ⚠️ ADAPTIVE BUFFERING (docs/internal/CARRYOVER.md section 3), and it is not optional. // // Writing each chunk to the device the moment it arrives leaves no cushion: // the device runs dry between chunks and underruns. Measured on the real diff --git a/sync.sh b/sync.sh index 7134ad6..4c3c149 100755 --- a/sync.sh +++ b/sync.sh @@ -3,7 +3,7 @@ # The VM is the build host on purpose: it is where the ALSA and serial work will # run, so it is the only place a green build means anything. # -# No default host is baked in. CARRYOVER.md section 6: never ship a default host - +# No default host is baked in. docs/internal/CARRYOVER.md section 6: never ship a default host - # a hostname in a public repo points every install at that station. Set # HAMDECK_BUILD_HOST to an ssh target; the site's value is in the gitignored SITE.md. set -e diff --git a/tests/test_recorder.cpp b/tests/test_recorder.cpp index d97f4da..3e86377 100644 --- a/tests/test_recorder.cpp +++ b/tests/test_recorder.cpp @@ -1,6 +1,6 @@ // Recorder tests. // -// ⚠️ The point of these is CARRYOVER.md section 1: the reference host answers +// ⚠️ The point of these is docs/internal/CARRYOVER.md section 1: the reference host answers // "recording":true from a Start() that set the flag false. So every test here // checks the REPORTED state against what is actually on disk, not against what // the call returned. diff --git a/tests/test_staleness.cpp b/tests/test_staleness.cpp index 43213b1..b7ad00d 100644 --- a/tests/test_staleness.cpp +++ b/tests/test_staleness.cpp @@ -3,7 +3,7 @@ // The obvious black-box test - freeze the process and re-query - is WORTHLESS: // SIGSTOP freezes the HTTP server too, so the poller refreshes the cache the // instant the process resumes and the answer comes back fresh. It looks like a -// pass and measures nothing. That is the same failure shape as CARRYOVER.md +// pass and measures nothing. That is the same failure shape as docs/internal/CARRYOVER.md // section 3's byte-count latency estimate, which read ~0 in steady state while // 435ms sat in the ALSA buffer. // diff --git a/tests/test_watchdog.cpp b/tests/test_watchdog.cpp index bba8937..b5f5a03 100644 --- a/tests/test_watchdog.cpp +++ b/tests/test_watchdog.cpp @@ -2,7 +2,7 @@ // // The watchdog is the safety property in the whole host: if it does not fire, // a lost link or a slept laptop leaves the rig keyed with nobody watching -// (CARRYOVER.md section 4b). So the test asserts the RADIO actually stopped +// (docs/internal/CARRYOVER.md section 4b). So the test asserts the RADIO actually stopped // transmitting, read back through CAT - not merely that a trip counter moved. // A counter is a claim; TX; returning 0 is the outcome. diff --git a/tools/__pycache__/parity_check.cpython-312.pyc b/tools/__pycache__/parity_check.cpython-312.pyc index 8169537..983c6fb 100644 Binary files a/tools/__pycache__/parity_check.cpython-312.pyc and b/tools/__pycache__/parity_check.cpython-312.pyc differ diff --git a/tools/backup.sh b/tools/backup.sh index 2d94211..daa2b82 100755 --- a/tools/backup.sh +++ b/tools/backup.sh @@ -7,7 +7,7 @@ # ⚠️ THE BUNDLE CONTAINS THE FULL HISTORY, INCLUDING SITE DETAIL that has been # removed from the working tree but is still in older commits. It is safe on # private storage and MUST NOT be published. That is also why this script has no -# GitHub path in it - see the push blocker in WIP.md. +# GitHub path in it - see the push blocker in docs/internal/WIP.md. # # No host or path is baked in: this repo is public. # HAMDECK_BACKUP_HOST= HAMDECK_BACKUP_PATH= tools/backup.sh diff --git a/tools/parity_check.py b/tools/parity_check.py index d0bbe92..98a4754 100755 --- a/tools/parity_check.py +++ b/tools/parity_check.py @@ -8,7 +8,7 @@ A parity walker that simply GETs every route would key the transmitter, change the operating mode and retune the amplifier on a live station - most of this API -is state-changing, and many of those routes are GETs. CARRYOVER.md section 9 +is state-changing, and many of those routes are GETs. docs/internal/CARRYOVER.md section 9 records that probing with a control route once changed the operating mode mid-session, with a human at the radio. diff --git a/tools/walk_all_routes.py b/tools/walk_all_routes.py index 5d7da9f..2e23f88 100755 --- a/tools/walk_all_routes.py +++ b/tools/walk_all_routes.py @@ -5,7 +5,7 @@ ⚠️ THIS TOOL FIRES STATE-CHANGING ROUTES. It will key PTT, change mode, switch antennas and move the frequency. Against a real station that is exactly the -accident CARRYOVER.md section 9 records - a control-route probe that changed the +accident docs/internal/CARRYOVER.md section 9 records - a control-route probe that changed the operating mode mid-session. So it refuses to run unless the target PROVES it is a simulator: