Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/apply-main-ruleset.sh
Original file line number Diff line number Diff line change
@@ -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)"'
34 changes: 34 additions & 0 deletions .github/main-ruleset.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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.**
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
98 changes: 98 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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_<version>_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.
58 changes: 58 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion client/qml/HamDeck/ConnectPanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion client/src/ptt_hotkey.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <QKeySequence>
Expand Down
4 changes: 2 additions & 2 deletions client/src/rx_audio.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion client/src/settings.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/AUDIT-AUDIO-ATTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Binary file added docs/images/panel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
21 changes: 21 additions & 0 deletions docs/internal/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading