https://github.com/aurimasniekis/cpp-antenna-switcher-client
Async C++17 client for the antenna-switcher device over the ESPHome native API
https://github.com/aurimasniekis/cpp-antenna-switcher-client
cpp cpp17
Last synced: 20 days ago
JSON representation
Async C++17 client for the antenna-switcher device over the ESPHome native API
- Host: GitHub
- URL: https://github.com/aurimasniekis/cpp-antenna-switcher-client
- Owner: aurimasniekis
- License: mit
- Created: 2026-06-04T08:45:34.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-04T18:37:49.000Z (about 1 month ago)
- Last Synced: 2026-06-04T21:14:03.055Z (about 1 month ago)
- Topics: cpp, cpp17
- Language: C++
- Homepage: https://aurimasniekis.github.io/cpp-antenna-switcher-client/
- Size: 78.1 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Antenna Switcher Client
[](https://github.com/aurimasniekis/cpp-antenna-switcher-client/actions/workflows/ci.yml)
[](https://aurimasniekis.github.io/cpp-antenna-switcher-client/)
An asynchronous, domain-specific **C++17 client library** for the `antenna-switcher` ESP32 device spoken over the **ESPHome native API** (TCP + protobuf, optionally Noise-encrypted).
It is a thin wrapper over [esphome-api-client](https://github.com/aurimasniekis/cpp-esphome-api)
(`esphome::api`): the generic
ESPHome client stays an internal dependency, and this library exposes only the antenna-switcher
surface.
> Looking for the command-line tool? See **[`bin/README.md`](bin/README.md)** for the full
> `antenna-switcher-cli` reference (Docker, Homebrew, every command and flag).
## Quick example
```cpp
#include
#include
int main() {
antenna_switcher::Options opts;
opts.host = "10.28.0.2";
// The base64 api.encryption.key from the device firmware YAML. Leave empty
// for a plaintext device.
opts.noise_psk = "0a2wipu2cBWSNiaJ2z4bYvdCaRTcgPaJtS535m3IP1g=";
try {
antenna_switcher::AntennaSwitcher dev(opts);
// State updates arrive on the worker thread; keep this callback short.
dev.onStateChanged([](antenna_switcher::Channel ch,
const antenna_switcher::ChannelState& s) {
std::cout << "channel #" << static_cast(ch)
<< " input=" << s.activeInput
<< " bearing=" << s.bearing << "deg\n";
});
dev.connect(); // blocks until connected + entities discovered; throws on failure
dev.setInput(antenna_switcher::Channel::One, 3); // sends "set:3"
antenna_switcher::ChannelState now = dev.state(antenna_switcher::Channel::One);
std::cout << "current input on #1: " << now.activeInput << "\n";
dev.disconnect();
} catch (const std::exception& e) {
std::cerr << "error: " << e.what() << "\n";
return 1;
}
return 0;
}
```
Why this works:
- `Options` carries everything needed to reach the device. Only `host` is required; `port` defaults
to `6053` and an empty `noise_psk` means plaintext.
- Constructing `AntennaSwitcher` starts a background worker thread but does **not** connect yet.
- `connect()` blocks until the handshake, entity enumeration, and per-channel discovery finish, then
returns. On any failure it throws — so the call belongs inside `try`/`catch`.
- `setInput` and the other actions are safe to call from your thread; internally they are marshalled
onto the worker's event loop.
- `state()` returns a thread-safe snapshot you can read at any time after `connect()`.
## Installation
Requires CMake ≥ 3.25 and a C++17 compiler. The library target is `antenna_switcher::client` and the
public header is ``.
### CMake FetchContent (recommended)
Pin the release tarball with a checksum:
```cmake
cmake_minimum_required(VERSION 3.25)
project(example LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
FetchContent_Declare(antenna-switcher-client
URL https://github.com/aurimasniekis/cpp-antenna-switcher-client/archive/refs/tags/v0.5.0.tar.gz
URL_HASH SHA256=6fc51e2b23f6e38bbb971e4bcf3b90f2c4aef22c107db67f73a88969be99da5e
)
FetchContent_MakeAvailable(antenna-switcher-client)
add_executable(example main.cpp)
target_link_libraries(example PRIVATE antenna_switcher::client)
```
### CMake add_subdirectory
```cmake
add_subdirectory(third_party/antenna-switcher-client)
target_link_libraries(example PRIVATE antenna_switcher::client)
```
### Installed package (find_package)
```cmake
find_package(antenna-switcher-client CONFIG REQUIRED)
target_link_libraries(example PRIVATE antenna_switcher::client)
```
Install rules are generated only when esphome-api-client is provided as an **installed** package
(`find_package`) rather than fetched from source. When the dependency is built from source (the
default), `ANTENNA_SWITCHER_INSTALL` is auto-disabled because a source-built dependency cannot be
re-exported. For FetchContent / add_subdirectory consumption this does not matter — you link the
target directly.
## Requirements
- **Language:** C++17. (The project sets `CMAKE_CXX_STANDARD 17` / `target_compile_features(... cxx_std_17)`.)
- **Build system:** CMake ≥ 3.25.
- **Required dependency:** [esphome-api-client](https://github.com/aurimasniekis/cpp-esphome-api)
(`esphome::api`), resolved via FetchContent from the `v0.5.0` release tarball. As of v0.3.0 the
protobuf wire layer and the Noise crypto are carried in-tree, so the only thing fetched for the
library is header-only Asio — there are no system `protobuf`/`libsodium` prerequisites.
## Core concepts
### `antenna_switcher::Options`
Connection settings passed to the controller's constructor.
```cpp
antenna_switcher::Options opts;
opts.host = "antenna.local"; // hostname or IP (required)
opts.port = 6053; // ESPHome native API port (default)
opts.noise_psk = ""; // base64 Noise key; empty => plaintext
// opts.client_info defaults to "antenna-switcher-client"
```
### `antenna_switcher::AntennaSwitcher`
The controller. It owns a background thread that runs the ESPHome event loop. Construction starts the
thread; `connect()` performs the blocking handshake + discovery. It is **non-copyable**.
```cpp
antenna_switcher::AntennaSwitcher dev(opts);
dev.connect();
// ... use dev ...
dev.disconnect();
```
Lifetime: keep the `AntennaSwitcher` alive for as long as you use it; the destructor stops the worker
thread. Do not keep references to a `ChannelState` past the call that produced it — copy what you
need (it is a small value type).
### `Channel`
Which of the two switchers a call targets. The enum value is the channel number used in the device's
entity object-ids.
```cpp
using antenna_switcher::Channel;
dev.setInput(Channel::One, 1); // switcher #1
dev.setInput(Channel::Two, 4); // switcher #2
```
### `ChannelState`
A snapshot of one channel's live state. Returned by `state()` and delivered to your `onStateChanged`
callback.
```cpp
struct ChannelState {
int bearing; // compass bearing, degrees
int activeInput; // selected input 1..10, 0 if unknown
int angleOffset; // compass offset for input 1, degrees 0..359
long intervalUs; // auto-cycle interval, microseconds
Mode mode; // Manual / Auto / Plan / Unknown
std::vector activeInputs; // inputs in the current auto cycle
};
```
### `Mode`
`Manual`, `Auto`, `Plan`, or `Unknown` — the device's current operating mode for a channel.
### `PlanStep` and `TimeUnit`
A plan is an ordered list of steps. Each step either selects an input or waits a delay. `TimeUnit` is
`Ms` (milliseconds) or `Us` (microseconds).
```cpp
using antenna_switcher::PlanStep;
using antenna_switcher::TimeUnit;
std::vector steps{
PlanStep::input_step(1), // switch to input 1
PlanStep::delay_step(100, TimeUnit::Ms), // wait 100 ms
PlanStep::input_step(2), // switch to input 2
PlanStep::delay_step(50, TimeUnit::Us), // wait 50 µs
};
```
### Thread-safety model
- Public methods (`setInput`, `startAuto`, `runPlan`, `stop`, `setAngleOffset`, `state`,
`isConnected`) are safe to call from any thread; commands are marshalled onto the event loop.
- `onStateChanged` fires **on the worker (loop) thread**. Keep it short and do not call back into a
blocking `connect()` / `disconnect()` from inside it.
- `state()` returns a mutex-guarded copy.
## Common usage patterns
### Connect and read initial state
```cpp
#include
#include
int main() {
antenna_switcher::Options opts;
opts.host = "10.28.0.2";
antenna_switcher::AntennaSwitcher dev(opts);
try {
dev.connect();
} catch (const std::exception& e) {
std::cerr << "connect failed: " << e.what() << "\n";
return 1;
}
for (auto ch : {antenna_switcher::Channel::One, antenna_switcher::Channel::Two}) {
auto s = dev.state(ch);
std::cout << "#" << static_cast(ch) << " mode=" << static_cast(s.mode)
<< " input=" << s.activeInput << "\n";
}
dev.disconnect();
}
```
Scenario: normal successful usage. After `connect()` returns, both channels already have a known
snapshot.
### Select an input (manual mode)
```cpp
dev.setInput(antenna_switcher::Channel::One, 5); // wire command: "set:5"
```
Inputs are `1..10`. Selecting an input puts the channel in manual mode.
### Auto-cycle inputs
```cpp
using antenna_switcher::Channel;
using antenna_switcher::TimeUnit;
// Cycle inputs 1,2,3 every 250 ms -> "auto:250:1,2,3"
dev.startAuto(Channel::One, 250, TimeUnit::Ms, {1, 2, 3});
// Cycle every input every 5000 microseconds -> "auto:5000u"
dev.startAuto(Channel::One, 5000, TimeUnit::Us, {});
```
Edge case: a selection of **1..9** inputs becomes an explicit cycle order; an **empty or full (all
ten)** selection cycles every input and emits no CSV suffix. This is verified in the tests
(`StartAutoFullSelectionDropsCsv`).
### Run a plan
```cpp
using antenna_switcher::PlanStep;
using antenna_switcher::TimeUnit;
dev.runPlan(antenna_switcher::Channel::One, {
PlanStep::input_step(1),
PlanStep::delay_step(100, TimeUnit::Ms),
PlanStep::input_step(2),
PlanStep::delay_step(50, TimeUnit::Us),
}, /*repeat=*/true); // wire command: "plan:1,s100,2,s50u,r"
```
Pass `repeat=false` (the default) to run the plan once.
### Stop auto/plan activity
```cpp
dev.stop(antenna_switcher::Channel::One); // wire command: "stop"
```
### Set the compass angle offset
```cpp
dev.setAngleOffset(antenna_switcher::Channel::One, 45); // degrees 0..359 (number entity)
```
Unlike the other actions, this writes the device's `number` entity directly rather than sending a
command string.
### React to live updates
```cpp
#include
#include
#include
#include
#include
int main() {
antenna_switcher::Options opts;
opts.host = "10.28.0.2";
antenna_switcher::AntennaSwitcher dev(opts);
std::atomic updates{0};
dev.onStateChanged([&](antenna_switcher::Channel ch,
const antenna_switcher::ChannelState& s) {
// Runs on the worker thread — do minimal work here.
++updates;
std::cout << "#" << static_cast(ch) << " bearing=" << s.bearing << "\n";
});
try {
dev.connect();
} catch (const std::exception& e) {
std::cerr << e.what() << "\n";
return 1;
}
dev.startAuto(antenna_switcher::Channel::One, 250, antenna_switcher::TimeUnit::Ms, {1, 2, 3});
std::this_thread::sleep_for(std::chrono::seconds(3)); // let updates arrive
dev.stop(antenna_switcher::Channel::One);
dev.disconnect();
std::cout << "received " << updates.load() << " updates\n";
}
```
Scenario: event-driven usage. The callback uses an atomic because it runs on a different thread than
`main`.
## Command grammar
The wire strings are replicated verbatim from the device web UI and pinned by the offline tests. All
command strings are written to the channel's `text` command entity; the angle offset is written to
the `number` entity.
| Method | Command string | Example |
|--------------------------------|--------------------|------------------------|
| `setInput(ch, n)` | `set:N` | `set:5` |
| `startAuto(ch, v, Ms, inputs)` | `auto:DELAY[:csv]` | `auto:100:1,2,3` |
| `startAuto(ch, v, Us, {})` | `auto:DELAYu` | `auto:5000u` |
| `runPlan(ch, steps, repeat)` | `plan:part,…[,r]` | `plan:1,s100,2,s50u,r` |
| `stop(ch)` | `stop` | `stop` |
| `setAngleOffset(ch, deg)` | *(number entity)* | — |
Rules:
- **auto** — delay is `` for milliseconds or `u` for microseconds. A selection of 1..9 inputs
is appended as a CSV cycle order; an empty or full (10) selection cycles every input.
- **plan** — each step is either an input number (`N`) or a delay (`sV` ms / `sVu` µs); append `r` to
repeat.
The pure builders behind these are available in `antenna_switcher::detail`
(`build_set_input`, `build_start_auto`, `build_run_plan`, `build_stop`) and are unit-testable without
a device.
## Error handling
The library reports errors with **exceptions**:
- `connect()` throws on handshake failure, an authentication/encryption error
(`esphome::api::ApiError` subclasses), or a `std::runtime_error` if an expected entity is missing
after discovery. Always call it inside `try`/`catch`.
- The action methods (`setInput`, etc.) marshal work onto the loop and do not block on a device
response, so they generally do not throw synchronously; observe their effect through `state()` or
`onStateChanged`.
- `state()` and `isConnected()` do not throw.
Success and failure:
```cpp
antenna_switcher::AntennaSwitcher dev(opts);
try {
dev.connect(); // success path
dev.setInput(antenna_switcher::Channel::One, 2);
dev.disconnect();
} catch (const std::exception& e) {
// failure path: bad host, refused connection, wrong/empty PSK, missing entity, ...
std::cerr << "error: " << e.what() << "\n";
}
```
## API overview
| API | Purpose | Notes |
|----------------------------------|-----------------------------|-------------------------------------------------------------|
| `Options` | Connection settings | `host` required; `port`=6053; empty `noise_psk` ⇒ plaintext |
| `AntennaSwitcher(Options)` | Construct controller | Starts worker thread; non-copyable |
| `connect()` | Connect + discover entities | Blocks; throws on failure |
| `disconnect()` | Stop and disconnect | — |
| `isConnected()` | Connection status | Does not throw |
| `setInput(ch, n)` | Select input 1..10 | Manual mode |
| `startAuto(ch, v, unit, inputs)` | Auto-cycle | 1..9 inputs ⇒ explicit order; empty/10 ⇒ all |
| `runPlan(ch, steps, repeat)` | Run an ordered plan | `repeat` defaults to `false` |
| `stop(ch)` | Stop auto/plan | — |
| `setAngleOffset(ch, deg)` | Set compass offset 0..359 | Writes the `number` entity |
| `state(ch)` | Thread-safe snapshot | Returns a `ChannelState` by value |
| `onStateChanged(cb)` | Register state listener | Fires on the worker thread |
## Examples
| Example | Demonstrates |
|------------------------------------------------|----------------------------------------------------------------------------------------------------------------|
| [`examples/control.cpp`](examples/control.cpp) | Connect, exercise set / auto / plan / stop / angle-offset on channel #1, and print each `onStateChanged` event |
Build and run it against a live device:
```sh
make examples
./build/examples/control 10.28.0.2 --key 0a2wipu2cBWSNiaJ2z4bYvdCaRTcgPaJtS535m3IP1g=
```
## Command-line tool
A ready-to-use CLI, `antenna-switcher-cli`, ships in [`bin/`](bin). It wraps this library and adds
text/JSON output, a config of saved devices, a streaming `watch` mode, and an interactive live panel.
```sh
# Build it from source
make cli
./build/bin/antenna-switcher-cli --help
# Or run the published Docker image
docker run --rm --network host aurimasniekis/antenna-switcher-cli -k 10.28.0.2 state
# Or install via Homebrew
brew install aurimasniekis/tap/antenna-switcher-cli
```
The CLI's own dependencies (CLI11, spdlog, nlohmann/json) are private to the tool and never reach the
library target. **See [`bin/README.md`](bin/README.md) for the complete command reference, flags,
environment variables, output formats, and interactive-mode keys.**
## Building and testing
The build is driven by CMake; a `Makefile` and `CMakePresets.json` memorize the common invocations.
```sh
make test # configure + build + run the offline tests (Debug)
make release # optimized build + tests
make examples # build the example programs
make cli # build the antenna-switcher-cli tool
make ci # format-check + clang-tidy + tests + sanitizers + release
```
…or with plain CMake / presets:
```sh
cmake -B build && cmake --build build && ctest --test-dir build
# or
cmake --preset debug && cmake --build --preset debug && ctest --preset debug
```
## Contributing
Contributions to the library are welcome! If you encounter any issues or have suggestions for
improvements,
please feel free to submit a pull request or open an issue on the project's repository.
## License
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.