Skip to content

Commit 29c3c92

Browse files
authored
Merge pull request #739 from Pulse-Eight/feature/rust-binding
Rust binding
2 parents 1838f41 + db0688b commit 29c3c92

32 files changed

Lines changed: 6968 additions & 10 deletions

.github/workflows/docs.yml

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ name: API docs
66
# DocFX -> .NET (src/dotnetlib)
77
# TypeDoc -> Node.js (src/nodejs/index.d.ts)
88
# Sphinx -> Python (SWIG-generated cec module from src/libcec/libcec.i)
9-
# A hand-written landing page (docs/api/landing) links the four together.
9+
# rustdoc -> Rust (src/rust)
10+
# A hand-written landing page (docs/api/landing) links the five together.
1011

1112
on:
1213
push:
@@ -15,6 +16,7 @@ on:
1516
- 'include/**'
1617
- 'src/dotnetlib/**'
1718
- 'src/nodejs/**'
19+
- 'src/rust/**'
1820
- 'src/libcec/libcec.i'
1921
- 'src/libcec/SwigHelper.h'
2022
- 'docs/api/**'
@@ -51,7 +53,7 @@ jobs:
5153
echo "Documenting libCEC ${maj}.${min}.${pat}"
5254
5355
- name: Prepare site tree
54-
run: mkdir -p site/cpp site/dotnet site/nodejs site/python
56+
run: mkdir -p site/cpp site/dotnet site/nodejs site/python site/rust
5557

5658
# --- C / C++ : Doxygen ------------------------------------------------
5759
- name: Install Doxygen
@@ -127,6 +129,22 @@ jobs:
127129
CEC_PY_MODULE_DIR: ${{ github.workspace }}/pygen
128130
run: sphinx-build -b html docs/api/python "${GITHUB_WORKSPACE}/site/python"
129131

132+
# --- Rust : rustdoc ---------------------------------------------------
133+
# No libCEC is installed on the runner and none is needed: build.rs only
134+
# emits link flags, and documenting a crate never links it. Warnings are
135+
# denied so a broken intra-doc link fails here rather than shipping.
136+
- name: Run rustdoc
137+
working-directory: src/rust
138+
env:
139+
RUSTDOCFLAGS: -D warnings
140+
run: |
141+
cargo doc --no-deps --offline
142+
cp -r target/doc/. "${GITHUB_WORKSPACE}/site/rust/"
143+
# rustdoc puts the crate under libcec/ and writes no root index when
144+
# only one crate is documented, so /rust/ needs this redirect.
145+
cp "${GITHUB_WORKSPACE}/docs/api/rust/index.html" \
146+
"${GITHUB_WORKSPACE}/site/rust/index.html"
147+
130148
# --- Assemble + upload -----------------------------------------------
131149
- name: Add landing page + shared assets
132150
run: |

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,5 +121,10 @@ src/libcec-wmc/obj
121121
/src/nodejs/node_modules
122122
/src/nodejs/package-lock.json
123123

124+
# Rust binding: cargo build output. Cargo.lock is not tracked - this is a
125+
# library crate, so the lock file of whatever builds it is the one that counts.
126+
/src/rust/target
127+
/src/rust/Cargo.lock
128+
124129
/windows/version.py
125130
/windows/__pycache__

CLAUDE.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## What this is
66

7-
libCEC is a cross-platform C++ library for controlling CEC-capable hardware (TVs, AV receivers, etc.) over HDMI, primarily via Pulse-Eight's USB-CEC adapter and SoC-native CEC on Linux/Raspberry Pi. It exposes C, C++, Python (via SWIG), .NET, and Node.js interfaces over the same core engine. The shared/static library output is named `cec` (`libcec.so` / `cec.dll`).
7+
libCEC is a cross-platform C++ library for controlling CEC-capable hardware (TVs, AV receivers, etc.) over HDMI, primarily via Pulse-Eight's USB-CEC adapter and SoC-native CEC on Linux/Raspberry Pi. It exposes C, C++, Python (via SWIG), .NET, Node.js and Rust interfaces over the same core engine. The shared/static library output is named `cec` (`libcec.so` / `cec.dll`).
88

99
The Node.js binding **`libcec`** lives in `src/nodejs` in this repo. It is a **native N-API addon** (`node-addon-api`, built with `node-gyp`) that binds libCEC via the C API (`include/cecc.h`) — the same surface the .NET binding uses. `src/nodejs/src/addon.cc` is the C++ addon (an `EventEmitter`-based `CecAdapter`); libCEC fires its `ICECCallbacks` from its own worker thread, so each C trampoline copies its payload and re-enters JS via a `Napi::ThreadSafeFunction`. The `commandHandler`/`menuStateChanged` callbacks return "not handled" (0) synchronously — honouring a JS return would mean blocking libCEC's callback thread on the event loop and racing its 1000ms timeout. `src/nodejs/lib/` is the JS wrapper (enums + the EventEmitter surface).
1010

@@ -14,6 +14,8 @@ The Node.js binding **`libcec`** lives in `src/nodejs` in this repo. It is a **n
1414

1515
The managed binding **`LibCecSharp`** lives in `src/dotnetlib` in this repo. It is a **pure C# assembly** (namespace `CecSharp`) that binds libCEC via P/Invoke over the C API (`include/cecc.h``LibCECC.cpp`), targets **net8.0**, and compiles with `dotnet build` (no MSVC `/clr`) — so unlike the old C++/CLI wrappers it works on Linux/macOS/RPi as well as Windows. It replaced two Windows-only C++/CLI wrappers (`LibCecSharp` for .NET Framework + `LibCecSharpCore` for net8.0), unifying them into one assembly. The `.NET` client apps (cec-tray, CecSharpTester) live in the `src/dotnet` git submodule (the `cec-dotnet` repo) and both reference this one binding; `cec-tray` is a Windows-only WinForms app (net8.0-windows), `CecSharpTester` is a cross-platform net8.0 console sample.
1616

17+
The Rust binding **`libcec`** lives in `src/rust`. It binds libCEC over the same C API, and like the .NET binding it mirrors `cectypes.h` by hand rather than generating anything — `src/rust/src/ffi.rs` is the raw surface, and `tests/layout.rs` asserts every struct size and field offset against numbers taken from a C compiler reading the real headers. **The crate has no dependencies at all**, deliberately: that is what lets `cargo build --offline` work, which is what lets cmake and the Debian package build it with no network and no vendored registry. Don't add one without a reason that outweighs that. The protocol enums (~290 values) are *generated* by `support/generate-rust-enums.py` from `cectypes.h` and checked in — re-run it after adding a value, it runs `rustfmt` itself. Two things it works around: CEC gives `UNREGISTERED` and `BROADCAST` the same value, so duplicates become associated constants aliasing the variant, and every enum has an `Other(i32)` catch-all with a total `from_raw`, because the bus carries whatever devices put on it. `Display` defers to libCEC's own `libcec_*_to_string` helpers so the names can't drift. Callbacks come two ways: implementing `CecCallbacks` runs on libCEC's worker thread and is the only way to *answer* `menu_state_changed`/`command_handler`, while `callbacks::channel()` gives an `mpsc::Receiver<CecEvent>` and is the sane default. libCEC keeps the addresses of the callback table and callback parameter, so both live in a pinned box whose `Drop` closes and destroys *before* the handler is dropped — `libcec_destroy` joins the worker thread, which is what makes that ordering sound. `open_first()` is a null port passed straight to `libcec_open`: `CLibCEC::Open` detects adapters itself when it isn't given one and opens the first that *opens*, skipping any another process holds, so the binding doesn't detect anything of its own.
18+
1719
## Submodules
1820

1921
The one remaining submodule, `src/dotnet` (the cec-dotnet .NET apps), is **only used by the Windows build** — init it there first:
@@ -44,6 +46,8 @@ Platform-native CEC backends are **off by default** and selected with cmake flag
4446

4547
The managed .NET binding is also a cmake option, **off by default** so a normal build never needs the .NET SDK: `-DENABLE_DOTNET_LIB=1` builds the pure-C# `LibCecSharp` binding via `dotnet build` (any platform with the SDK), and `-DENABLE_DOTNET_APPS=1` additionally builds the Windows-only .NET apps (cec-tray, CecSharpTester) and implies `ENABLE_DOTNET_LIB`. These are `dotnet build` custom targets in the top-level `CMakeLists.txt`; they never enter the native build graph. `DOTNET_ARCH` (default x64 on Windows, AnyCPU elsewhere) sets the managed `-p:Platform`.
4648

49+
The Rust binding is a cmake option too, **off by default**: `-DENABLE_RUST_LIB=1` builds the crate with `cargo build --offline` and installs its *sources* under `share/cargo/registry/libcec-<version>/` (Rust has no stable ABI, so a compiled rlib would only be usable by the compiler that built it; this is the layout `dh-cargo` produces and a cargo `directory` source consumes). It builds the **examples**, not just the library — an rlib is never linked, so `cargo build` alone would not prove the bindings resolve against libCEC. Unlike the Node option, the Debian package uses this one directly (`debian/rules`), because zero crate dependencies means no network.
50+
4751
The Node.js binding is likewise a cmake option, **off by default**: `-DENABLE_NODE_LIB=1` builds the native addon via `npm install` (runs `node-gyp`, compiling `src/nodejs` against a `pkg-config`-discoverable libCEC) and installs it as a global node module under `lib/node_modules/libcec`. Like the .NET targets it's a custom target, never in the native build graph. The Debian `node-libcec` package does **not** use this option (npm needs network); `debian/rules` builds the addon with `node-gyp` against the staged libCEC via `PKG_CONFIG_SYSROOT_DIR` instead.
4852

4953
### Windows
@@ -62,13 +66,13 @@ Useful flags: `-a {x64,x86,arm,arm64}` (default x64), `-m {Release,Debug,RelWith
6266
`project/libcec.sln` opens the C# `LibCecSharp` binding (`src/dotnetlib/LibCecSharp.csproj`, an SDK-style project) for development in Visual Studio; the .NET apps solution is `src/dotnet/project/cec-dotnet.sln` (cec-tray + CecSharpTester). The actual build goes through cmake's `ENABLE_DOTNET_*` targets — all SDK-based, no `/clr`.
6367

6468
### Debian / Ubuntu packaging
65-
`debian/` builds the `.deb` set (`dpkg-buildpackage`; see `docs/README.debian.md`). The runtime package is **`libcec8`** — named after the SONAME (`= LIBCEC_VERSION_MAJOR`), so it is renamed on every major bump and `Breaks`/`Replaces`/`Provides` the older `libcec4`-`libcec7` names; `libcec8-dev` likewise supersedes the old `-dev` names. The other packages are `cec-utils` (cec-client + cecc-client), `python-libcec`, `libcec-dotnet` (the managed binding + its NuGet package, built by passing `-DENABLE_DOTNET_LIB=1`), and a `libcec` meta package. `debian/rules` also enables the Linux/Exynos/AOCEC backends and reproducible-build flags. `debian/changelog.in` (not `changelog`) is the source — `#DIST#` is substituted per distribution at build time.
69+
`debian/` builds the `.deb` set (`dpkg-buildpackage`; see `docs/README.debian.md`). The Rust package is `librust-libcec-dev` (`Architecture: all`, crate sources only). The runtime package is **`libcec8`** — named after the SONAME (`= LIBCEC_VERSION_MAJOR`), so it is renamed on every major bump and `Breaks`/`Replaces`/`Provides` the older `libcec4`-`libcec7` names; `libcec8-dev` likewise supersedes the old `-dev` names. The other packages are `cec-utils` (cec-client + cecc-client), `python-libcec`, `libcec-dotnet` (the managed binding + its NuGet package, built by passing `-DENABLE_DOTNET_LIB=1`), and a `libcec` meta package. `debian/rules` also enables the Linux/Exynos/AOCEC backends and reproducible-build flags. `debian/changelog.in` (not `changelog`) is the source — `#DIST#` is substituted per distribution at build time.
6670

6771
### Releasing
68-
`support/release.py` cuts a release in one non-interactive run: it merges `master` into `release`, annotates the tag on the **merge commit**, pushes both, waits for Jenkins to build and sign the tag, downloads the signed artefacts and publishes the GitHub release with them. It needs `JENKINS_URL`/`JENKINS_USER`/`JENKINS_TOKEN` in the environment (`--insecure` for a self-signed controller certificate) and an authenticated `gh`; its only arguments are the tag and a markdown notes file. It refuses to start on a dirty tree, a tag that already exists, a tag that disagrees with `LIBCEC_VERSION_*`, or an existing GitHub release, and refuses to publish an installer whose Authenticode signature does not verify. Preparing `master` is still done by hand beforehand: `CMakeLists.txt` is the source of truth for the version, and the files that repeat it have to be bumped along with it — the new `debian/changelog.in` stanza, `src/nodejs/package.json`, and `src/dotnetlib/LibCecSharp.csproj` (generated from its `.in`, but tracked so Visual Studio can open it without a cmake run, so the tracked copy goes stale on its own). `release.py`'s `SATELLITE_VERSIONS` table lists them and the release stops if any disagrees — 8.1.4 shipped an npm module calling itself 8.1.0 before that check existed. **A new binding that carries its own version file adds a line to that table.**
72+
`support/release.py` cuts a release in one non-interactive run: it merges `master` into `release`, annotates the tag on the **merge commit**, pushes both, waits for Jenkins to build and sign the tag, downloads the signed artefacts and publishes the GitHub release with them. It needs `JENKINS_URL`/`JENKINS_USER`/`JENKINS_TOKEN` in the environment (`--insecure` for a self-signed controller certificate) and an authenticated `gh`; its only arguments are the tag and a markdown notes file. It refuses to start on a dirty tree, a tag that already exists, a tag that disagrees with `LIBCEC_VERSION_*`, or an existing GitHub release, and refuses to publish an installer whose Authenticode signature does not verify. Preparing `master` is still done by hand beforehand: `CMakeLists.txt` is the source of truth for the version, and the files that repeat it have to be bumped along with it — the new `debian/changelog.in` stanza, `src/nodejs/package.json`, and `src/dotnetlib/LibCecSharp.csproj` (generated from its `.in`, but tracked so Visual Studio can open it without a cmake run, so the tracked copy goes stale on its own). `release.py`'s `SATELLITE_VERSIONS` table lists them and the release stops if any disagrees — 8.1.4 shipped an npm module calling itself 8.1.0 before that check existed. **A new binding that carries its own version file adds a line to that table.** Publishing a binding to its language's registry (npm, NuGet, crates.io) is *not* automated and is a deliberate step: those versions are permanent and can only be yanked, never replaced.
6973

7074
### API documentation
71-
`docs/api/` holds the per-binding API reference, one best-of-breed generator each: **Doxygen** for C/C++ (`docs/api/doxygen/`, main page in `mainpage.md`), **DocFX** for .NET (`docs/api/dotnet/`, compiles a docs-only `docs.csproj` that globs the same `cs/` sources), **TypeDoc** for Node.js (`docs/api/nodejs/`, renders the hand-authored `src/nodejs/index.d.ts` — which also ships to consumers via `package.json` `types`), and **Sphinx** for Python (`docs/api/python/`, autodoc over a `swig -doxygen`-generated `cec.py` with the native `_cec` extension mocked, so no libCEC compile is needed). A landing page (`docs/api/landing/`) links the four; `docs/api/assets/` holds the shared Pulse-Eight logo. `.github/workflows/docs.yml` builds all four and deploys to GitHub Pages — **published at https://pulse-eight.github.io/libcec/**. Generated output is git-ignored; see `docs/api/README.md` for local build steps. The welcome pages carry the install+usage guide for each binding, so keep them current when an API surface changes.
75+
`docs/api/` holds the per-binding API reference, one best-of-breed generator each: **Doxygen** for C/C++ (`docs/api/doxygen/`, main page in `mainpage.md`), **DocFX** for .NET (`docs/api/dotnet/`, compiles a docs-only `docs.csproj` that globs the same `cs/` sources), **TypeDoc** for Node.js (`docs/api/nodejs/`, renders the hand-authored `src/nodejs/index.d.ts` — which also ships to consumers via `package.json` `types`), **Sphinx** for Python (`docs/api/python/`, autodoc over a `swig -doxygen`-generated `cec.py` with the native `_cec` extension mocked, so no libCEC compile is needed), and **rustdoc** for Rust (no config — the crate's own doc comments are the input; `docs/api/rust/index.html` is only a redirect, since rustdoc writes no root index for a single crate, and CI runs it with `-D warnings`). A landing page (`docs/api/landing/`) links the five; `docs/api/assets/` holds the shared Pulse-Eight logo. `.github/workflows/docs.yml` builds all four and deploys to GitHub Pages — **published at https://pulse-eight.github.io/libcec/**. Generated output is git-ignored; see `docs/api/README.md` for local build steps. The welcome pages carry the install+usage guide for each binding, so keep them current when an API surface changes.
7276

7377
### Tests
7478
There is no automated test suite. Verification is manual via the example clients run against real CEC hardware:

CMakeLists.txt

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ option(ENABLE_DOTNET_APPS "Build the managed .NET apps (cec-tray, CecSharpTester
4242
# so it never enters the native build graph either.
4343
option(ENABLE_NODE_LIB "Build the Node.js binding (native N-API addon)" OFF)
4444

45+
# The Rust binding, likewise OFF by default. It has no crate dependencies, so
46+
# unlike the Node option this one needs no network: cargo builds it --offline.
47+
option(ENABLE_RUST_LIB "Build the Rust binding" OFF)
48+
4549
if(NOT DISABLE_CLIENT)
4650
# cec-client
4751
add_subdirectory(src/cec-client)
@@ -169,6 +173,64 @@ if(ENABLE_NODE_LIB AND NOT DISABLE_CLIENT)
169173
DESTINATION lib/node_modules/libcec)
170174
endif()
171175

176+
# Rust binding (optional; OFF by default - see the option above). Built with
177+
# cargo, so like the .NET and Node targets it never enters the native build
178+
# graph. The crate has no dependencies, so --offline works and no vendored
179+
# registry or network access is needed - which is what lets the Debian package
180+
# use this option directly rather than driving the build itself.
181+
if(ENABLE_RUST_LIB AND NOT DISABLE_CLIENT)
182+
find_program(CARGO_EXECUTABLE NAMES cargo)
183+
if(NOT CARGO_EXECUTABLE)
184+
message(FATAL_ERROR "ENABLE_RUST_LIB requires cargo on PATH")
185+
endif()
186+
187+
set(_rust_srcdir ${CMAKE_CURRENT_SOURCE_DIR}/src/rust)
188+
set(LIBCEC_VERSION ${LIBCEC_VERSION_MAJOR}.${LIBCEC_VERSION_MINOR}.${LIBCEC_VERSION_PATCH})
189+
190+
# Build the examples, not just the library: an rlib is never linked, so
191+
# `cargo build` alone would compile the bindings without ever proving they
192+
# resolve against libCEC. The examples are what turns this into a link test.
193+
#
194+
# LIBCEC_LIB_DIR points build.rs at the library just built here rather than an
195+
# installed one, and CARGO_HOME keeps cargo out of the building user's home
196+
# directory, which a package build has no business writing to.
197+
add_custom_target(libcec-rust ALL
198+
COMMAND ${CMAKE_COMMAND} -E env
199+
LIBCEC_LIB_DIR=$<TARGET_FILE_DIR:cec-shared>
200+
CARGO_HOME=${CMAKE_CURRENT_BINARY_DIR}/cargo-home
201+
${CARGO_EXECUTABLE} build --offline --release --examples
202+
--manifest-path ${_rust_srcdir}/Cargo.toml
203+
--target-dir ${CMAKE_CURRENT_BINARY_DIR}/rust
204+
VERBATIM
205+
COMMENT "Building the Rust binding (cargo, offline)")
206+
add_dependencies(libcec-rust cec-shared)
207+
208+
# Install the crate *sources*, which is how a Rust library is shipped: there
209+
# is no stable Rust ABI, so a compiled rlib would only be usable by the exact
210+
# compiler that built it. This is the layout Debian's dh-cargo produces and
211+
# what a `[source] directory` replacement in .cargo/config.toml consumes.
212+
set(_rust_registry share/cargo/registry/libcec-${LIBCEC_VERSION})
213+
214+
# cargo's directory source wants this file next to the crate. An empty `files`
215+
# map means "verify nothing", which is right here: the sources are owned by
216+
# the package manager, not downloaded from crates.io.
217+
file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/cargo-checksum.json "{\"package\":null,\"files\":{}}")
218+
219+
install(DIRECTORY ${_rust_srcdir}/src/
220+
DESTINATION ${_rust_registry}/src)
221+
install(DIRECTORY ${_rust_srcdir}/examples/
222+
DESTINATION ${_rust_registry}/examples)
223+
install(DIRECTORY ${_rust_srcdir}/tests/
224+
DESTINATION ${_rust_registry}/tests)
225+
install(FILES ${_rust_srcdir}/Cargo.toml
226+
${_rust_srcdir}/build.rs
227+
${_rust_srcdir}/README.md
228+
DESTINATION ${_rust_registry})
229+
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/cargo-checksum.json
230+
DESTINATION ${_rust_registry}
231+
RENAME .cargo-checksum.json)
232+
endif()
233+
172234
# windows specific files
173235
if(WIN32)
174236
if(NOT DISABLE_CLIENT)

0 commit comments

Comments
 (0)