{"id":50965024,"url":"https://github.com/aurimasniekis/cpp-antenna-switcher-client","last_synced_at":"2026-06-18T19:01:23.520Z","repository":{"id":362544251,"uuid":"1259161607","full_name":"aurimasniekis/cpp-antenna-switcher-client","owner":"aurimasniekis","description":"Async C++17 client for the antenna-switcher device over the ESPHome native API","archived":false,"fork":false,"pushed_at":"2026-06-04T18:37:49.000Z","size":80,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-04T21:14:03.055Z","etag":null,"topics":["cpp","cpp17"],"latest_commit_sha":null,"homepage":"https://aurimasniekis.github.io/cpp-antenna-switcher-client/","language":"C++","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/aurimasniekis.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-04T08:45:34.000Z","updated_at":"2026-06-04T18:36:47.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/aurimasniekis/cpp-antenna-switcher-client","commit_stats":null,"previous_names":["aurimasniekis/cpp-antenna-switcher-client"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/aurimasniekis/cpp-antenna-switcher-client","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aurimasniekis%2Fcpp-antenna-switcher-client","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aurimasniekis%2Fcpp-antenna-switcher-client/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aurimasniekis%2Fcpp-antenna-switcher-client/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aurimasniekis%2Fcpp-antenna-switcher-client/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/aurimasniekis","download_url":"https://codeload.github.com/aurimasniekis/cpp-antenna-switcher-client/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/aurimasniekis%2Fcpp-antenna-switcher-client/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34503511,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-18T02:00:06.871Z","response_time":128,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cpp","cpp17"],"created_at":"2026-06-18T19:01:22.463Z","updated_at":"2026-06-18T19:01:23.510Z","avatar_url":"https://github.com/aurimasniekis.png","language":"C++","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Antenna Switcher Client\n\n[![CI](https://github.com/aurimasniekis/cpp-antenna-switcher-client/actions/workflows/ci.yml/badge.svg)](https://github.com/aurimasniekis/cpp-antenna-switcher-client/actions/workflows/ci.yml)\n[![Docs](https://github.com/aurimasniekis/cpp-antenna-switcher-client/actions/workflows/docs.yml/badge.svg)](https://aurimasniekis.github.io/cpp-antenna-switcher-client/)\n\nAn asynchronous, domain-specific **C++17 client library** for the `antenna-switcher` ESP32 device spoken over the **ESPHome native API** (TCP + protobuf, optionally Noise-encrypted).\n\nIt is a thin wrapper over [esphome-api-client](https://github.com/aurimasniekis/cpp-esphome-api) \n(`esphome::api`): the generic\nESPHome client stays an internal dependency, and this library exposes only the antenna-switcher \nsurface.\n\n\u003e Looking for the command-line tool? See **[`bin/README.md`](bin/README.md)** for the full\n\u003e `antenna-switcher-cli` reference (Docker, Homebrew, every command and flag).\n\n## Quick example\n\n```cpp\n#include \u003cantenna_switcher/client.hpp\u003e\n\n#include \u003ciostream\u003e\n\nint main() {\n    antenna_switcher::Options opts;\n    opts.host = \"10.28.0.2\";\n    // The base64 api.encryption.key from the device firmware YAML. Leave empty\n    // for a plaintext device.\n    opts.noise_psk = \"0a2wipu2cBWSNiaJ2z4bYvdCaRTcgPaJtS535m3IP1g=\";\n\n    try {\n        antenna_switcher::AntennaSwitcher dev(opts);\n\n        // State updates arrive on the worker thread; keep this callback short.\n        dev.onStateChanged([](antenna_switcher::Channel ch,\n                              const antenna_switcher::ChannelState\u0026 s) {\n            std::cout \u003c\u003c \"channel #\" \u003c\u003c static_cast\u003cint\u003e(ch)\n                      \u003c\u003c \" input=\" \u003c\u003c s.activeInput\n                      \u003c\u003c \" bearing=\" \u003c\u003c s.bearing \u003c\u003c \"deg\\n\";\n        });\n\n        dev.connect();  // blocks until connected + entities discovered; throws on failure\n\n        dev.setInput(antenna_switcher::Channel::One, 3);  // sends \"set:3\"\n\n        antenna_switcher::ChannelState now = dev.state(antenna_switcher::Channel::One);\n        std::cout \u003c\u003c \"current input on #1: \" \u003c\u003c now.activeInput \u003c\u003c \"\\n\";\n\n        dev.disconnect();\n    } catch (const std::exception\u0026 e) {\n        std::cerr \u003c\u003c \"error: \" \u003c\u003c e.what() \u003c\u003c \"\\n\";\n        return 1;\n    }\n    return 0;\n}\n```\n\nWhy this works:\n\n- `Options` carries everything needed to reach the device. Only `host` is required; `port` defaults\n  to `6053` and an empty `noise_psk` means plaintext.\n- Constructing `AntennaSwitcher` starts a background worker thread but does **not** connect yet.\n- `connect()` blocks until the handshake, entity enumeration, and per-channel discovery finish, then\n  returns. On any failure it throws — so the call belongs inside `try`/`catch`.\n- `setInput` and the other actions are safe to call from your thread; internally they are marshalled\n  onto the worker's event loop.\n- `state()` returns a thread-safe snapshot you can read at any time after `connect()`.\n\n## Installation\n\nRequires CMake ≥ 3.25 and a C++17 compiler. The library target is `antenna_switcher::client` and the\npublic header is `\u003cantenna_switcher/client.hpp\u003e`.\n\n### CMake FetchContent (recommended)\n\nPin the release tarball with a checksum:\n\n```cmake\ncmake_minimum_required(VERSION 3.25)\nproject(example LANGUAGES CXX)\n\nset(CMAKE_CXX_STANDARD 17)\nset(CMAKE_CXX_STANDARD_REQUIRED ON)\n\ninclude(FetchContent)\nFetchContent_Declare(antenna-switcher-client\n    URL      https://github.com/aurimasniekis/cpp-antenna-switcher-client/archive/refs/tags/v0.5.0.tar.gz\n    URL_HASH SHA256=6fc51e2b23f6e38bbb971e4bcf3b90f2c4aef22c107db67f73a88969be99da5e\n)\nFetchContent_MakeAvailable(antenna-switcher-client)\n\nadd_executable(example main.cpp)\ntarget_link_libraries(example PRIVATE antenna_switcher::client)\n```\n\n### CMake add_subdirectory\n\n```cmake\nadd_subdirectory(third_party/antenna-switcher-client)\ntarget_link_libraries(example PRIVATE antenna_switcher::client)\n```\n\n### Installed package (find_package)\n\n```cmake\nfind_package(antenna-switcher-client CONFIG REQUIRED)\ntarget_link_libraries(example PRIVATE antenna_switcher::client)\n```\n\nInstall rules are generated only when esphome-api-client is provided as an **installed** package\n(`find_package`) rather than fetched from source. When the dependency is built from source (the\ndefault), `ANTENNA_SWITCHER_INSTALL` is auto-disabled because a source-built dependency cannot be\nre-exported. For FetchContent / add_subdirectory consumption this does not matter — you link the\ntarget directly.\n\n## Requirements\n\n- **Language:** C++17. (The project sets `CMAKE_CXX_STANDARD 17` / `target_compile_features(... cxx_std_17)`.)\n- **Build system:** CMake ≥ 3.25.\n- **Required dependency:** [esphome-api-client](https://github.com/aurimasniekis/cpp-esphome-api)\n  (`esphome::api`), resolved via FetchContent from the `v0.5.0` release tarball. As of v0.3.0 the\n  protobuf wire layer and the Noise crypto are carried in-tree, so the only thing fetched for the\n  library is header-only Asio — there are no system `protobuf`/`libsodium` prerequisites.\n\n\n## Core concepts\n\n### `antenna_switcher::Options`\n\nConnection settings passed to the controller's constructor.\n\n```cpp\nantenna_switcher::Options opts;\nopts.host      = \"antenna.local\";  // hostname or IP (required)\nopts.port      = 6053;             // ESPHome native API port (default)\nopts.noise_psk = \"\";               // base64 Noise key; empty =\u003e plaintext\n// opts.client_info defaults to \"antenna-switcher-client\"\n```\n\n### `antenna_switcher::AntennaSwitcher`\n\nThe controller. It owns a background thread that runs the ESPHome event loop. Construction starts the\nthread; `connect()` performs the blocking handshake + discovery. It is **non-copyable**.\n\n```cpp\nantenna_switcher::AntennaSwitcher dev(opts);\ndev.connect();\n// ... use dev ...\ndev.disconnect();\n```\n\nLifetime: keep the `AntennaSwitcher` alive for as long as you use it; the destructor stops the worker\nthread. Do not keep references to a `ChannelState` past the call that produced it — copy what you\nneed (it is a small value type).\n\n### `Channel`\n\nWhich of the two switchers a call targets. The enum value is the channel number used in the device's\nentity object-ids.\n\n```cpp\nusing antenna_switcher::Channel;\ndev.setInput(Channel::One, 1);  // switcher #1\ndev.setInput(Channel::Two, 4);  // switcher #2\n```\n\n### `ChannelState`\n\nA snapshot of one channel's live state. Returned by `state()` and delivered to your `onStateChanged`\ncallback.\n\n```cpp\nstruct ChannelState {\n    int bearing;                 // compass bearing, degrees\n    int activeInput;             // selected input 1..10, 0 if unknown\n    int angleOffset;             // compass offset for input 1, degrees 0..359\n    long intervalUs;             // auto-cycle interval, microseconds\n    Mode mode;                   // Manual / Auto / Plan / Unknown\n    std::vector\u003cint\u003e activeInputs;  // inputs in the current auto cycle\n};\n```\n\n### `Mode`\n\n`Manual`, `Auto`, `Plan`, or `Unknown` — the device's current operating mode for a channel.\n\n### `PlanStep` and `TimeUnit`\n\nA plan is an ordered list of steps. Each step either selects an input or waits a delay. `TimeUnit` is\n`Ms` (milliseconds) or `Us` (microseconds).\n\n```cpp\nusing antenna_switcher::PlanStep;\nusing antenna_switcher::TimeUnit;\n\nstd::vector\u003cPlanStep\u003e steps{\n    PlanStep::input_step(1),                  // switch to input 1\n    PlanStep::delay_step(100, TimeUnit::Ms),  // wait 100 ms\n    PlanStep::input_step(2),                  // switch to input 2\n    PlanStep::delay_step(50, TimeUnit::Us),   // wait 50 µs\n};\n```\n\n### Thread-safety model\n\n- Public methods (`setInput`, `startAuto`, `runPlan`, `stop`, `setAngleOffset`, `state`,\n  `isConnected`) are safe to call from any thread; commands are marshalled onto the event loop.\n- `onStateChanged` fires **on the worker (loop) thread**. Keep it short and do not call back into a\n  blocking `connect()` / `disconnect()` from inside it.\n- `state()` returns a mutex-guarded copy.\n\n## Common usage patterns\n\n### Connect and read initial state\n\n```cpp\n#include \u003cantenna_switcher/client.hpp\u003e\n#include \u003ciostream\u003e\n\nint main() {\n    antenna_switcher::Options opts;\n    opts.host = \"10.28.0.2\";\n\n    antenna_switcher::AntennaSwitcher dev(opts);\n    try {\n        dev.connect();\n    } catch (const std::exception\u0026 e) {\n        std::cerr \u003c\u003c \"connect failed: \" \u003c\u003c e.what() \u003c\u003c \"\\n\";\n        return 1;\n    }\n\n    for (auto ch : {antenna_switcher::Channel::One, antenna_switcher::Channel::Two}) {\n        auto s = dev.state(ch);\n        std::cout \u003c\u003c \"#\" \u003c\u003c static_cast\u003cint\u003e(ch) \u003c\u003c \" mode=\" \u003c\u003c static_cast\u003cint\u003e(s.mode)\n                  \u003c\u003c \" input=\" \u003c\u003c s.activeInput \u003c\u003c \"\\n\";\n    }\n    dev.disconnect();\n}\n```\n\nScenario: normal successful usage. After `connect()` returns, both channels already have a known\nsnapshot.\n\n### Select an input (manual mode)\n\n```cpp\ndev.setInput(antenna_switcher::Channel::One, 5);  // wire command: \"set:5\"\n```\n\nInputs are `1..10`. Selecting an input puts the channel in manual mode.\n\n### Auto-cycle inputs\n\n```cpp\nusing antenna_switcher::Channel;\nusing antenna_switcher::TimeUnit;\n\n// Cycle inputs 1,2,3 every 250 ms  -\u003e \"auto:250:1,2,3\"\ndev.startAuto(Channel::One, 250, TimeUnit::Ms, {1, 2, 3});\n\n// Cycle every input every 5000 microseconds  -\u003e \"auto:5000u\"\ndev.startAuto(Channel::One, 5000, TimeUnit::Us, {});\n```\n\nEdge case: a selection of **1..9** inputs becomes an explicit cycle order; an **empty or full (all\nten)** selection cycles every input and emits no CSV suffix. This is verified in the tests\n(`StartAutoFullSelectionDropsCsv`).\n\n### Run a plan\n\n```cpp\nusing antenna_switcher::PlanStep;\nusing antenna_switcher::TimeUnit;\n\ndev.runPlan(antenna_switcher::Channel::One, {\n    PlanStep::input_step(1),\n    PlanStep::delay_step(100, TimeUnit::Ms),\n    PlanStep::input_step(2),\n    PlanStep::delay_step(50, TimeUnit::Us),\n}, /*repeat=*/true);   // wire command: \"plan:1,s100,2,s50u,r\"\n```\n\nPass `repeat=false` (the default) to run the plan once.\n\n### Stop auto/plan activity\n\n```cpp\ndev.stop(antenna_switcher::Channel::One);  // wire command: \"stop\"\n```\n\n### Set the compass angle offset\n\n```cpp\ndev.setAngleOffset(antenna_switcher::Channel::One, 45);  // degrees 0..359 (number entity)\n```\n\nUnlike the other actions, this writes the device's `number` entity directly rather than sending a\ncommand string.\n\n### React to live updates\n\n```cpp\n#include \u003cantenna_switcher/client.hpp\u003e\n#include \u003catomic\u003e\n#include \u003cchrono\u003e\n#include \u003ciostream\u003e\n#include \u003cthread\u003e\n\nint main() {\n    antenna_switcher::Options opts;\n    opts.host = \"10.28.0.2\";\n    antenna_switcher::AntennaSwitcher dev(opts);\n\n    std::atomic\u003cint\u003e updates{0};\n    dev.onStateChanged([\u0026](antenna_switcher::Channel ch,\n                           const antenna_switcher::ChannelState\u0026 s) {\n        // Runs on the worker thread — do minimal work here.\n        ++updates;\n        std::cout \u003c\u003c \"#\" \u003c\u003c static_cast\u003cint\u003e(ch) \u003c\u003c \" bearing=\" \u003c\u003c s.bearing \u003c\u003c \"\\n\";\n    });\n\n    try {\n        dev.connect();\n    } catch (const std::exception\u0026 e) {\n        std::cerr \u003c\u003c e.what() \u003c\u003c \"\\n\";\n        return 1;\n    }\n\n    dev.startAuto(antenna_switcher::Channel::One, 250, antenna_switcher::TimeUnit::Ms, {1, 2, 3});\n    std::this_thread::sleep_for(std::chrono::seconds(3));  // let updates arrive\n\n    dev.stop(antenna_switcher::Channel::One);\n    dev.disconnect();\n    std::cout \u003c\u003c \"received \" \u003c\u003c updates.load() \u003c\u003c \" updates\\n\";\n}\n```\n\nScenario: event-driven usage. The callback uses an atomic because it runs on a different thread than\n`main`.\n\n## Command grammar\n\nThe wire strings are replicated verbatim from the device web UI and pinned by the offline tests. All\ncommand strings are written to the channel's `text` command entity; the angle offset is written to\nthe `number` entity.\n\n| Method                         | Command string     | Example                |\n|--------------------------------|--------------------|------------------------|\n| `setInput(ch, n)`              | `set:N`            | `set:5`                |\n| `startAuto(ch, v, Ms, inputs)` | `auto:DELAY[:csv]` | `auto:100:1,2,3`       |\n| `startAuto(ch, v, Us, {})`     | `auto:DELAYu`      | `auto:5000u`           |\n| `runPlan(ch, steps, repeat)`   | `plan:part,…[,r]`  | `plan:1,s100,2,s50u,r` |\n| `stop(ch)`                     | `stop`             | `stop`                 |\n| `setAngleOffset(ch, deg)`      | *(number entity)*  | —                      |\n\nRules:\n\n- **auto** — delay is `\u003cv\u003e` for milliseconds or `\u003cv\u003eu` for microseconds. A selection of 1..9 inputs\n  is appended as a CSV cycle order; an empty or full (10) selection cycles every input.\n- **plan** — each step is either an input number (`N`) or a delay (`sV` ms / `sVu` µs); append `r` to\n  repeat.\n\nThe pure builders behind these are available in `antenna_switcher::detail`\n(`build_set_input`, `build_start_auto`, `build_run_plan`, `build_stop`) and are unit-testable without\na device.\n\n## Error handling\n\nThe library reports errors with **exceptions**:\n\n- `connect()` throws on handshake failure, an authentication/encryption error\n  (`esphome::api::ApiError` subclasses), or a `std::runtime_error` if an expected entity is missing\n  after discovery. Always call it inside `try`/`catch`.\n- The action methods (`setInput`, etc.) marshal work onto the loop and do not block on a device\n  response, so they generally do not throw synchronously; observe their effect through `state()` or\n  `onStateChanged`.\n- `state()` and `isConnected()` do not throw.\n\nSuccess and failure:\n\n```cpp\nantenna_switcher::AntennaSwitcher dev(opts);\ntry {\n    dev.connect();                 // success path\n    dev.setInput(antenna_switcher::Channel::One, 2);\n    dev.disconnect();\n} catch (const std::exception\u0026 e) {\n    // failure path: bad host, refused connection, wrong/empty PSK, missing entity, ...\n    std::cerr \u003c\u003c \"error: \" \u003c\u003c e.what() \u003c\u003c \"\\n\";\n}\n```\n\n## API overview\n\n| API                              | Purpose                     | Notes                                                       |\n|----------------------------------|-----------------------------|-------------------------------------------------------------|\n| `Options`                        | Connection settings         | `host` required; `port`=6053; empty `noise_psk` ⇒ plaintext |\n| `AntennaSwitcher(Options)`       | Construct controller        | Starts worker thread; non-copyable                          |\n| `connect()`                      | Connect + discover entities | Blocks; throws on failure                                   |\n| `disconnect()`                   | Stop and disconnect         | —                                                           |\n| `isConnected()`                  | Connection status           | Does not throw                                              |\n| `setInput(ch, n)`                | Select input 1..10          | Manual mode                                                 |\n| `startAuto(ch, v, unit, inputs)` | Auto-cycle                  | 1..9 inputs ⇒ explicit order; empty/10 ⇒ all                |\n| `runPlan(ch, steps, repeat)`     | Run an ordered plan         | `repeat` defaults to `false`                                |\n| `stop(ch)`                       | Stop auto/plan              | —                                                           |\n| `setAngleOffset(ch, deg)`        | Set compass offset 0..359   | Writes the `number` entity                                  |\n| `state(ch)`                      | Thread-safe snapshot        | Returns a `ChannelState` by value                           |\n| `onStateChanged(cb)`             | Register state listener     | Fires on the worker thread                                  |\n\n## Examples\n\n| Example                                        | Demonstrates                                                                                                   |\n|------------------------------------------------|----------------------------------------------------------------------------------------------------------------|\n| [`examples/control.cpp`](examples/control.cpp) | Connect, exercise set / auto / plan / stop / angle-offset on channel #1, and print each `onStateChanged` event |\n\nBuild and run it against a live device:\n\n```sh\nmake examples\n./build/examples/control 10.28.0.2 --key 0a2wipu2cBWSNiaJ2z4bYvdCaRTcgPaJtS535m3IP1g=\n```\n\n## Command-line tool\n\nA ready-to-use CLI, `antenna-switcher-cli`, ships in [`bin/`](bin). It wraps this library and adds\ntext/JSON output, a config of saved devices, a streaming `watch` mode, and an interactive live panel.\n\n```sh\n# Build it from source\nmake cli\n./build/bin/antenna-switcher-cli --help\n\n# Or run the published Docker image\ndocker run --rm --network host aurimasniekis/antenna-switcher-cli -k \u003cpsk\u003e 10.28.0.2 state\n\n# Or install via Homebrew\nbrew install aurimasniekis/tap/antenna-switcher-cli\n```\n\nThe CLI's own dependencies (CLI11, spdlog, nlohmann/json) are private to the tool and never reach the\nlibrary target. **See [`bin/README.md`](bin/README.md) for the complete command reference, flags,\nenvironment variables, output formats, and interactive-mode keys.**\n\n## Building and testing\n\nThe build is driven by CMake; a `Makefile` and `CMakePresets.json` memorize the common invocations.\n\n```sh\nmake test          # configure + build + run the offline tests (Debug)\nmake release       # optimized build + tests\nmake examples      # build the example programs\nmake cli           # build the antenna-switcher-cli tool\nmake ci            # format-check + clang-tidy + tests + sanitizers + release\n```\n\n…or with plain CMake / presets:\n\n```sh\ncmake -B build \u0026\u0026 cmake --build build \u0026\u0026 ctest --test-dir build\n# or\ncmake --preset debug \u0026\u0026 cmake --build --preset debug \u0026\u0026 ctest --preset debug\n```\n\n## Contributing\n\nContributions to the library are welcome! If you encounter any issues or have suggestions for\nimprovements,\nplease feel free to submit a pull request or open an issue on the project's repository.\n\n## License\n\nThis project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faurimasniekis%2Fcpp-antenna-switcher-client","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Faurimasniekis%2Fcpp-antenna-switcher-client","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Faurimasniekis%2Fcpp-antenna-switcher-client/lists"}