antenna-switcher-client 0.5.0
Async C++17 client for the antenna-switcher device over the ESPHome native API
Loading...
Searching...
No Matches
Antenna Switcher Client

CI Docs

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 (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`** for the full antenna-switcher-cli reference (Docker, Homebrew, every command and flag).

Quick example

#include <iostream>
int main() {
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 {
// State updates arrive on the worker thread; keep this callback short.
dev.onStateChanged([](antenna_switcher::Channel ch,
std::cout << "channel #" << static_cast<int>(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"
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;
}
Async, event-driven controller for the antenna-switcher device.
Definition client.hpp:69
Domain-specific async client for the antenna-switcher ESP32 device.
Channel
One of the two switchers on the device.
Definition client.hpp:21
A snapshot of one channel's live state.
Definition client.hpp:47
int activeInput
Currently selected input (1..10), 0 if unknown.
Definition client.hpp:49
int bearing
Current compass bearing in degrees.
Definition client.hpp:48
Everything needed to reach and authenticate with the device.
Definition client.hpp:57
std::string host
Device hostname or IP.
Definition client.hpp:58
std::string noise_psk
base64 Noise key (api.encryption.key); empty ⇒ plaintext.
Definition client.hpp:60

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 <antenna_switcher/client.hpp>.

CMake FetchContent (recommended)

Pin the release tarball with a checksum:

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

add_subdirectory(third_party/antenna-switcher-client)
target_link_libraries(example PRIVATE antenna_switcher::client)

Installed package (find_package)

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 (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

<tt>antenna_switcher::Options</tt>

Connection settings passed to the controller's constructor.

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"
std::uint16_t port
ESPHome native API port.
Definition client.hpp:59

<tt>antenna_switcher::AntennaSwitcher</tt>

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.

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).

<tt>Channel</tt>

Which of the two switchers a call targets. The enum value is the channel number used in the device's entity object-ids.

dev.setInput(Channel::One, 1); // switcher #1
dev.setInput(Channel::Two, 4); // switcher #2

<tt>ChannelState</tt>

A snapshot of one channel's live state. Returned by state() and delivered to your onStateChanged callback.

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<int> activeInputs; // inputs in the current auto cycle
};

<tt>Mode</tt>

Manual, Auto, Plan, or Unknown — the device's current operating mode for a channel.

<tt>PlanStep</tt> and <tt>TimeUnit</tt>

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).

std::vector<PlanStep> 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
};
TimeUnit
Time unit for auto-cycle intervals and plan delays.
Definition client.hpp:24
One step of a plan: either switch to an input, or wait for a delay.
Definition client.hpp:30

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

#include <iostream>
int main() {
opts.host = "10.28.0.2";
try {
dev.connect();
} catch (const std::exception& e) {
std::cerr << "connect failed: " << e.what() << "\n";
return 1;
}
auto s = dev.state(ch);
std::cout << "#" << static_cast<int>(ch) << " mode=" << static_cast<int>(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)

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

// 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

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

dev.stop(antenna_switcher::Channel::One); // wire command: "stop"

Set the compass angle offset

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

#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
int main() {
opts.host = "10.28.0.2";
std::atomic<int> updates{0};
dev.onStateChanged([&](antenna_switcher::Channel ch,
// Runs on the worker thread — do minimal work here.
++updates;
std::cout << "#" << static_cast<int>(ch) << " bearing=" << s.bearing << "\n";
});
try {
dev.connect();
} catch (const std::exception& e) {
std::cerr << e.what() << "\n";
return 1;
}
std::this_thread::sleep_for(std::chrono::seconds(3)); // let updates arrive
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 <v> for milliseconds or <v>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:

try {
dev.connect(); // success path
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 Connect, exercise set / auto / plan / stop / angle-offset on channel #1, and print each onStateChanged event

Build and run it against a live device:

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.

# 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 <psk> 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` 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.

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:

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.