From 6563438231d339af7bd0eb975ab7dd2025d0a4a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:19:46 +0000 Subject: [PATCH 1/2] cymon-lib 0.2: C++20, interrupt-safe API, DSDL 0.1, UNIMOC-ready - C++20 baseline (was cxx_std_23 in CMake, C++17 in the README), CMake 3.20. - Variables are read through const float* instead of std::function: Tick() is allocation-free, lock-free and free of function pointers, so it can run in a zero-latency interrupt. No heap use left in the library. - Atomic capture state (Idle/Configured/Armed/Triggered/Complete) as the contract between Tick() and the Cyphal thread; RequestTrigger() and TakeTriggerEvent() for cross-node triggering. - Sample period rounded to a decimation of the tick period; the trigger is evaluated only after the pre-trigger part is recorded. - UNIMOC naming (PascalCase methods), .clang-format and .clang-tidy rules; clean under the UNIMOC warning set with -Werror. - DSDL 0.1 replaces 1.0 (the 1.0 files lacked @extent/@sealed and did not compile); new CaptureState, Error and TriggerMode.IMMEDIATE. - scripts/check_dsdl.sh: pydsdl/Nunavut C generation plus a compile of the integration guide example; CI runs it with GCC and Clang builds, tests, clang-tidy and clang-format 22.1.1. - README rewritten; docs/integration.md describes the firmware side. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKjxMYUjfvU5kDumYNNQY8 --- .clang-format | 22 +- .clang-tidy | 82 +++-- .github/workflows/ci.yml | 49 +-- CMakeLists.txt | 57 ++-- README.md | 197 ++++++++---- cmake/nunavut.cmake | 53 ---- docs/integration.md | 160 ++++++++++ dsdl/cymon/ArmTrigger.0.1.dsdl | 14 + dsdl/cymon/ArmTrigger.1.0.dsdl | 22 -- dsdl/cymon/CaptureState.0.1.dsdl | 11 + dsdl/cymon/Error.0.1.dsdl | 12 + dsdl/cymon/GetBufferInfo.0.1.dsdl | 12 + dsdl/cymon/GetBufferInfo.1.0.dsdl | 20 -- dsdl/cymon/GetVariableList.0.1.dsdl | 11 + dsdl/cymon/GetVariableList.1.0.dsdl | 22 -- dsdl/cymon/ReadSamples.0.1.dsdl | 17 + dsdl/cymon/ReadSamples.1.0.dsdl | 23 -- dsdl/cymon/SetupCapture.0.1.dsdl | 13 + dsdl/cymon/SetupCapture.1.0.dsdl | 22 -- dsdl/cymon/TriggerEvent.0.1.dsdl | 9 + dsdl/cymon/TriggerEvent.1.0.dsdl | 20 -- dsdl/cymon/TriggerMode.0.1.dsdl | 10 + dsdl/cymon/TriggerMode.1.0.dsdl | 19 -- include/cymon/config.hpp | 55 ++-- include/cymon/device.hpp | 235 ++++++++------ include/cymon/sample_buffer.hpp | 115 +++---- include/cymon/trigger.hpp | 64 ++-- scripts/check_dsdl.sh | 29 ++ src/cymon/device.cpp | 276 ++++++++-------- src/cymon/sample_buffer.cpp | 175 ++++------- src/cymon/trigger.cpp | 43 +-- tests/test_device.cpp | 466 ++++++++++++---------------- tests/test_sample_buffer.cpp | 258 +++++++-------- tests/test_trigger.cpp | 122 +++----- 34 files changed, 1416 insertions(+), 1299 deletions(-) delete mode 100644 cmake/nunavut.cmake create mode 100644 docs/integration.md create mode 100644 dsdl/cymon/ArmTrigger.0.1.dsdl delete mode 100644 dsdl/cymon/ArmTrigger.1.0.dsdl create mode 100644 dsdl/cymon/CaptureState.0.1.dsdl create mode 100644 dsdl/cymon/Error.0.1.dsdl create mode 100644 dsdl/cymon/GetBufferInfo.0.1.dsdl delete mode 100644 dsdl/cymon/GetBufferInfo.1.0.dsdl create mode 100644 dsdl/cymon/GetVariableList.0.1.dsdl delete mode 100644 dsdl/cymon/GetVariableList.1.0.dsdl create mode 100644 dsdl/cymon/ReadSamples.0.1.dsdl delete mode 100644 dsdl/cymon/ReadSamples.1.0.dsdl create mode 100644 dsdl/cymon/SetupCapture.0.1.dsdl delete mode 100644 dsdl/cymon/SetupCapture.1.0.dsdl create mode 100644 dsdl/cymon/TriggerEvent.0.1.dsdl delete mode 100644 dsdl/cymon/TriggerEvent.1.0.dsdl create mode 100644 dsdl/cymon/TriggerMode.0.1.dsdl delete mode 100644 dsdl/cymon/TriggerMode.1.0.dsdl create mode 100755 scripts/check_dsdl.sh diff --git a/.clang-format b/.clang-format index 792ef9b..abad956 100644 --- a/.clang-format +++ b/.clang-format @@ -1,3 +1,21 @@ ---- +# Use the Google style in this project. BasedOnStyle: Google -ColumnLimit: 140 + +# Some folks prefer to write "int& foo" while others prefer "int &foo". The +# Google Style Guide only asks for consistency within a project, we chose +# "int& foo" for this project: +DerivePointerAlignment: false +PointerAlignment: Left + +# The Google Style Guide only asks for consistency w.r.t. "east const" vs. +# "const west" alignment of cv-qualifiers. In this project we use "const west". +QualifierAlignment: Left + +IncludeBlocks: Merge + +CommentPragmas: '(@copydoc|@copybrief|@see|@overload|@snippet)' + +ColumnLimit: 110 +BinPackArguments: false +BinPackParameters: false +PenaltyBreakBeforeFirstCallParameter: 1000 \ No newline at end of file diff --git a/.clang-tidy b/.clang-tidy index ca7d9b2..098207c 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -1,29 +1,57 @@ ---- +# SPDX-License-Identifier: Apache-2.0 Checks: > - bugprone-*, - -bugprone-easily-swappable-parameters, - -bugprone-exception-escape, - google-build-explicit-make-pairs, - google-build-namespaces, - google-build-using-namespace, - google-explicit-constructor, - google-global-names-in-headers, - google-readability-braces-around-statements, - google-readability-casting, - google-readability-namespace-comments, - -google-readability-todo, - google-runtime-int, - google-runtime-operator, - modernize-use-nullptr, - modernize-use-override, - modernize-use-emplace, - performance-for-range-copy, - performance-unnecessary-copy-initialization, - readability-braces-around-statements, - readability-const-return-type, - readability-container-size-empty, - readability-delete-null-pointer, - readability-else-after-return + clang-diagnostic-*, + clang-analyzer-*, + cppcoreguidelines-*, + modernize-*, + performance-*, + readability-*, + -modernize-use-trailing-return-type, + -readability-magic-numbers, + -cppcoreguidelines-avoid-magic-numbers, + -cppcoreguidelines-pro-bounds-pointer-arithmetic, + -cppcoreguidelines-pro-type-reinterpret-cast, + -cppcoreguidelines-pro-type-union-access, + -cppcoreguidelines-pro-bounds-constant-array-index -WarningsAsErrors: '' -HeaderFilterRegex: 'include/cymon/.*' +# Indices into fixed-size arrays are loop counters bounded by the array size +# constant. The suggested replacement (gsl::at or .at()) adds an abort path to +# the zero-latency hot path, so the check is off. + +# The checks and naming rules of UNIMOC, so the library is clean when UNIMOC +# builds it. The public headers are checked with the sources. +HeaderFilterRegex: '.*/include/cymon/[^/]+\.hpp$' + +WarningsAsErrors: "" + +CheckOptions: + # Phase (a, b, c), axis (d, q), per-phase names (ia, da, ...) and the time + # step (dt) are the vocabulary of the motor-control code. + - key: readability-identifier-length.IgnoredVariableNames + value: "^(a|b|c|d|q|dt|ia|ib|ic|da|db|dc)$" + - key: readability-identifier-length.IgnoredParameterNames + value: "^(a|b|c|d|q|dt|ia|ib|ic|da|db|dc)$" + # Zephyr macros (LOG_*, K_USEC, zassert) would dominate the count. + - key: readability-function-cognitive-complexity.IgnoreMacros + value: true + - key: readability-identifier-naming.ClassCase + value: CamelCase + - key: readability-identifier-naming.FunctionCase + value: CamelCase + - key: readability-identifier-naming.VariableCase + value: lower_case + - key: readability-identifier-naming.ConstantCase + value: CamelCase + - key: readability-identifier-naming.ConstantPrefix + value: "k" + - key: readability-identifier-naming.MemberCase + value: lower_case + - key: readability-identifier-naming.MemberSuffix + value: "_" + - key: readability-identifier-naming.PublicMemberSuffix + value: "" + # Parameters are variables (snake_case), also when they are const. + - key: readability-identifier-naming.ParameterCase + value: lower_case + - key: readability-identifier-naming.ConstantParameterCase + value: lower_case diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eeaa17..a4bc686 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,45 +1,48 @@ name: CI on: push: - branches: [main, develop, "copilot/**"] + branches: [main, develop, "claude/**", "copilot/**"] pull_request: - branches: [main, develop, "copilot/**"] jobs: build-and-test: runs-on: ubuntu-latest + strategy: + matrix: + compiler: [g++, clang++] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - - name: Install system dependencies + - name: Install dependencies run: | sudo apt-get update -q - sudo apt-get install -y \ - ninja-build clang clang-format clang-tidy - - - name: Install CMake 4.x - run: pip install "cmake>=4,<5" --quiet + sudo apt-get install -y ninja-build clang clang-tidy - name: Configure run: | - cmake -S . -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Debug \ - -DCYMON_BUILD_TESTS=ON \ - -DCMAKE_CXX_COMPILER=clang++ \ - -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_COMPILER=${{ matrix.compiler }} -DCMAKE_EXPORT_COMPILE_COMMANDS=ON - - name: Build + - name: Build (warnings are errors) run: cmake --build build - name: Test run: ctest --test-dir build --output-on-failure - - name: clang-format check - run: | - find include src tests -name "*.hpp" -o -name "*.cpp" | \ - xargs clang-format --dry-run --Werror - - name: clang-tidy - run: | - find src -name "*.cpp" | \ - xargs clang-tidy -p build/ + if: matrix.compiler == 'clang++' + run: clang-tidy -p build --warnings-as-errors='*' src/cymon/*.cpp + + format-and-dsdl: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install tools + run: pip install clang-format==22.1.1 "nunavut>=2.3,<3" + - name: clang-format + run: git ls-files '*.hpp' '*.cpp' | xargs clang-format --dry-run --Werror + - name: DSDL and generated C serialization + run: scripts/check_dsdl.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index f268103..3674e20 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,39 +1,46 @@ +# SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Tecnologic # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# cymon-lib: device side of the cymon trace protocol (C++20, no heap, no +# Cyphal dependency). Built standalone (host tests) or with add_subdirectory() +# from a firmware build such as the UNIMOC Zephyr module, which then applies +# its own toolchain flags; this file adds warnings only when it is the top +# level project. + +cmake_minimum_required(VERSION 3.20) +project(cymon-lib VERSION 0.2.0 LANGUAGES CXX) -cmake_minimum_required(VERSION 3.20.0) -project(cymon-lib VERSION 0.1.0 LANGUAGES CXX) +set(CYMON_MAX_BUFFER_BYTES 4096 CACHE STRING "Capture buffer size in bytes") +set(CYMON_MAX_VARS 64 CACHE STRING "Variables the registry holds (1..255)") +set(CYMON_MAX_CHANNELS 8 CACHE STRING "Channels of one capture (1..16)") -# Library -add_library(cymon +add_library(cymon STATIC src/cymon/trigger.cpp src/cymon/sample_buffer.cpp src/cymon/device.cpp ) -target_compile_features(cymon PUBLIC cxx_std_23) -target_include_directories(cymon PUBLIC include) -target_compile_options(cymon PRIVATE -Wall -Wextra -Wpedantic) +add_library(cymon::cymon ALIAS cymon) +target_compile_features(cymon PUBLIC cxx_std_20) +target_include_directories(cymon PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_compile_definitions(cymon PUBLIC + CYMON_MAX_BUFFER_BYTES=${CYMON_MAX_BUFFER_BYTES}U + CYMON_MAX_VARS=${CYMON_MAX_VARS}U + CYMON_MAX_CHANNELS=${CYMON_MAX_CHANNELS}U +) + +# Directory of the DSDL root namespace for the firmware's Nunavut generation. +set(CYMON_DSDL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/dsdl/cymon CACHE INTERNAL "cymon DSDL root namespace") -# Optional nunavut DSDL code generation -option(CYMON_GENERATE_DSDL "Generate C++ types from DSDL via nunavut" OFF) -if(CYMON_GENERATE_DSDL) - include(cmake/nunavut.cmake) - generate_cyphal_types(cymon ${CMAKE_CURRENT_SOURCE_DIR}/dsdl) +if(PROJECT_IS_TOP_LEVEL) + set(CMAKE_CXX_EXTENSIONS OFF) + # The warning set of UNIMOC, so the library builds clean inside it. + target_compile_options(cymon PRIVATE + -Wall -Wextra -Wpedantic -Wshadow -Wnon-virtual-dtor -Wold-style-cast -Wcast-align -Wunused + -Woverloaded-virtual -Wconversion -Wsign-conversion -Wnull-dereference -Wdouble-promotion + -Wformat=2 -Werror) endif() -# Tests -option(CYMON_BUILD_TESTS "Build tests" OFF) +option(CYMON_BUILD_TESTS "Build the GoogleTest host tests" ${PROJECT_IS_TOP_LEVEL}) if(CYMON_BUILD_TESTS) enable_testing() add_subdirectory(tests) diff --git a/README.md b/README.md index 17c7268..3ef981d 100644 --- a/README.md +++ b/README.md @@ -2,84 +2,165 @@ [![CI](https://github.com/Tecnologic/cymon-lib/actions/workflows/ci.yml/badge.svg)](https://github.com/Tecnologic/cymon-lib/actions/workflows/ci.yml) -Platform-agnostic C++17 library for Cyphal-connected embedded nodes. -Exposes named scalar variables and oscilloscope-style capture via a Cyphal -CAN-FD service protocol. The library is pure C++ with no Cyphal/canard -dependency — the integrating application calls the library's handlers when -it receives a Cyphal service request. +Device side of the **cymon** trace protocol: a C++20 library that lets an +embedded Cyphal node offer named float variables and oscilloscope-style +captures of them. The host tool [cymon](https://github.com/Tecnologic/cymon) +lists the variables, configures and arms a capture, and reads it back over +Cyphal. + +The library has no Cyphal dependency. The firmware deserializes the cymon +service requests (DSDL in [`dsdl/cymon/`](dsdl/cymon)), calls the library and +serializes the results. [UNIMOC](https://github.com/Tecnologic/unimoc) is the +first user; see [docs/integration.md](docs/integration.md). ## Features -- **Named variable registry** — register up to `CYMON_MAX_VARS` (default 64) - scalar variables with a `std::function` getter and iterate them - via a `GetVariableList`-style index query. -- **Oscilloscope-style capture** — configurable number of channels, - sample count, pre-trigger depth, and trigger modes (rising, falling, - hysteresis). -- **Paged readout** — `ReadSamples` returns interleaved float frames in pages - so the caller can fit data into Cyphal transfer payloads. -- **Hardware-agnostic timer interface** — call `Device::Tick()` from your - timer ISR/task; report the actual achieved period with - `set_actual_sample_period_us()`. -- **Compile-time limits** — override `CYMON_MAX_BUFFER_BYTES` and - `CYMON_MAX_VARS` via preprocessor defines before including any header. +- **Variable registry**: up to `CYMON_MAX_VARS` (64) named float variables + with a unit. Each one is read through a `const float*`. +- **Capture**: up to `CYMON_MAX_CHANNELS` (8) channels into a + `CYMON_MAX_BUFFER_BYTES` (4 KiB) ring, with a pre-trigger depth. The sample + period is a whole multiple of the tick period (decimation). +- **Trigger**: rising, falling, rising with hysteresis, immediate, or + requested by the firmware (for example on another node's + `cymon.TriggerEvent`). The trigger is only evaluated once the pre-trigger + part has been recorded, so a complete capture contains recorded frames only. +- **Interrupt-safe sampling**: `Device::Tick()` does not allocate, take locks, + call function pointers or use libm. It can run in a zero-latency interrupt. +- **No heap, no exceptions, no RTTI needed**: all storage is inside the + `Device` object. The code is clean under the UNIMOC warning set (`-Wconversion`, + `-Wold-style-cast`, ... with `-Werror`) and its clang-tidy rules. ## Protocol (DSDL) -All DSDL types live under `dsdl/cymon/` and are **unregulated** (no numeric -port-ID prefix). - -| Service / Message | Description | -|-------------------------|--------------------------------------------------| -| `GetVariableList.1.0` | Iterator-style variable enumeration (index→name) | -| `GetBufferInfo.1.0` | Query buffer limits | -| `SetupCapture.1.0` | Configure channels, sample count, pretrigger | -| `ArmTrigger.1.0` | Arm/disarm trigger with level & mode | -| `ReadSamples.1.0` | Paged readout of captured samples | -| `TriggerEvent.1.0` | Broadcast message emitted when trigger fires | +All types are in the root namespace `cymon`, version **0.1**. Major version 0 +means the protocol may still change. None of them has a fixed port-ID: the +node assigns the IDs through the standard port registers, and a client finds +them through the `.type` registers. + +| Type | Kind | Port register | Purpose | +|---|---|---|---| +| `cymon.GetVariableList.0.1` | service | `uavcan.srv.cymon_variables.id` | Variable `index` → name and unit; an empty name ends the list | +| `cymon.GetBufferInfo.0.1` | service | `uavcan.srv.cymon_buffer_info.id` | Buffer size, channel limit, frames, tick period | +| `cymon.SetupCapture.0.1` | service | `uavcan.srv.cymon_setup.id` | Channels, sample period, samples, pre-trigger depth | +| `cymon.ArmTrigger.0.1` | service | `uavcan.srv.cymon_arm.id` | Arm (source, mode, level, band) or disarm | +| `cymon.ReadSamples.0.1` | service | `uavcan.srv.cymon_read.id` | State; once complete, the frames page by page | +| `cymon.TriggerEvent.0.1` | message | `uavcan.pub.cymon_trigger.id` / `uavcan.sub.cymon_trigger.id` | Published when a capture triggers; can trigger other nodes | +| `cymon.Error.0.1`, `cymon.TriggerMode.0.1`, `cymon.CaptureState.0.1` | enums | — | Shared constants (match `cymon::ErrorCode`, `TriggerMode`, `CaptureState`) | + +A client runs a capture like this: + +1. It walks `GetVariableList` from index 0 until it gets an empty name. +2. It calls `GetBufferInfo` once. +3. `SetupCapture`: the response contains the sample period actually used. +4. `ArmTrigger` with `arm = true`. +5. It polls `ReadSamples` with `max_frames = 0` until `state` is `COMPLETE`. +6. It reads the frames with `ReadSamples`, increasing `frame_offset`, until + `end_of_data`. Frame `pretrigger_samples` is the trigger sample. + +A `ReadSamples` response carries at most 64 floats (8 frames with 8 channels) +and fits in 5 CAN FD frames. `scripts/check_dsdl.sh` validates the types and +compiles the C serialization that Nunavut generates from them. + +## Usage -## Build +```cpp +#include "cymon/device.hpp" -```bash -cmake -B build -DCMAKE_BUILD_TYPE=Debug -cmake --build build -ctest --test-dir build --output-on-failure +namespace { +cymon::Device trace; +float i_q_measured; // written by the control loop +float speed; +} // namespace + +void SetupTrace() { + trace.RegisterVariable("i_q", "A", &i_q_measured); // ID 0 + trace.RegisterVariable("speed", "rad/s", &speed); // ID 1 + trace.SetTickPeriod(62.5F); // Tick() at 16 kHz +} + +// Sampling context (control-cycle interrupt), after the variables are updated: +void OnControlCycle() { trace.Tick(); } + +// Cyphal thread: one call per received request (see docs/integration.md). +// trace.GetVariable(index) -> GetVariableList response +// trace.HandleGetBufferInfo() -> GetBufferInfo response +// trace.HandleSetupCapture(config) -> SetupCapture response +// trace.HandleArmTrigger(config) -> ArmTrigger response +// trace.HandleReadSamples(off, n) -> ReadSamples response +// trace.TakeTriggerEvent(&event) -> publish cymon.TriggerEvent +// trace.RequestTrigger() <- received cymon.TriggerEvent of another node ``` -To build with a specific compiler: - -```bash -cmake -B build -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_BUILD_TYPE=Debug -cmake --build build -``` +### Execution contexts -To disable tests: +- `Tick()` runs in the sampling context, normally an interrupt at a fixed rate. +- Every other method runs in one thread, normally the Cyphal thread. + `Tick()` may preempt it at any point. The capture state is a lock-free + atomic. The thread changes the capture setup only while `Tick()` ignores it + (not armed), and publishes the new state last. `Tick()` and the thread must + not run in parallel on two cores. +- `RegisterVariable()` and `SetTickPeriod()` are start-up calls, made before + the first `Tick()`. +- The variable names, units and sources must outlive the `Device`. A `float` + written by another context is read with one 32-bit load, which is atomic on + Cortex-M and x86-64. -```bash -cmake -B build -DCYMON_BUILD_TESTS=OFF -cmake --build build -``` +### Limits -## Integration +Each limit is a CMake cache variable, which becomes a compile definition of +the `cymon` target. Without CMake, define the macro before you include any +cymon header. -```cpp -#include "cymon/device.hpp" +| Variable | Default | Effect | +|---|---|---| +| `CYMON_MAX_BUFFER_BYTES` | 4096 | Capture buffer size; at most 65535 floats | +| `CYMON_MAX_VARS` | 64 | Registry size (1..255) | +| `CYMON_MAX_CHANNELS` | 8 | Channels of one capture (1..16) | -cymon::Device device; +The `Device` object takes roughly `CYMON_MAX_BUFFER_BYTES + 24 * CYMON_MAX_VARS ++ 100` bytes of RAM. -// Register variables (call once at startup). -device.RegisterVariable("speed", "rpm", [] { return read_speed(); }); -device.RegisterVariable("current", "A", [] { return read_current(); }); +## Build and test -// In your Cyphal service handler: -auto resp = device.HandleSetupCapture(config); -auto resp = device.HandleArmTrigger(arm_cfg); -auto resp = device.HandleReadSamples(frame_offset, max_frames); +These steps need CMake 3.20 or later and a C++20 compiler. -// In your hardware timer ISR / task: -device.Tick(); +```bash +cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug # tests are on for the top-level build +cmake --build build +ctest --test-dir build --output-on-failure +clang-tidy -p build src/cymon/*.cpp # the UNIMOC rule set (.clang-tidy) +scripts/check_dsdl.sh # needs: pip install nunavut ``` +The style is the UNIMOC style, checked with `clang-format` 22.1.1 +(`pip install clang-format==22.1.1`). + +As a subproject (`add_subdirectory`), the library builds only the `cymon` +target (alias `cymon::cymon`), without tests or extra warning flags, so the +parent build's toolchain flags apply. The DSDL root namespace is exported as +`CYMON_DSDL_DIR`. + +## Changes in 0.2 + +0.2 changes the API and the protocol. Nothing used 0.1 in production. + +- C++20 (0.1 asked for C++23 in CMake and said C++17 in this README). + The minimum CMake version is 3.20. +- Variables take a `const float*` instead of a `std::function`, so + `Tick()` is interrupt-safe and the library has no heap use. +- Methods use PascalCase (`VariableCount()`, `SetTickPeriod()`, `State()`), + following UNIMOC. `ForceTrigger()` is now `RequestTrigger()`, and it is safe + to call from the thread. +- Handlers return `ErrorCode` instead of an `ok` flag plus a code. + `ReadSamples` returns the capture state and is the status poll. + `SetupCapture` rounds the sample period to whole ticks. +- DSDL 0.1 replaces 1.0. The 1.0 files had no `@extent`/`@sealed` and + were rejected by the DSDL compilers. `TriggerMode.IMMEDIATE` and + `CaptureState` are new. `TriggerEvent` no longer repeats the source + node-ID, which the transfer already carries. +- `pretrigger_samples` must be smaller than `num_samples`, so the trigger + sample is always part of the capture. + ## License Apache 2.0 — see [LICENSE](LICENSE). diff --git a/cmake/nunavut.cmake b/cmake/nunavut.cmake deleted file mode 100644 index cabcf85..0000000 --- a/cmake/nunavut.cmake +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright 2026 Tecnologic -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# generate_cyphal_types(target dsdl_dir) -# Generates C++ headers from DSDL files using nunavut and adds them to target. -function(generate_cyphal_types target dsdl_dir) - find_program(NUNAVUT_EXECUTABLE nunavut - HINTS "$ENV{HOME}/.local/bin" "/usr/local/bin" - DOC "nunavut DSDL code generation tool" - ) - - if(NOT NUNAVUT_EXECUTABLE) - message(FATAL_ERROR - "nunavut not found. Install it with: pip install nunavut\n" - "Then re-run cmake." - ) - endif() - - set(generated_dir "${CMAKE_BINARY_DIR}/generated") - file(MAKE_DIRECTORY "${generated_dir}") - - file(GLOB_RECURSE dsdl_files "${dsdl_dir}/*.dsdl") - - add_custom_command( - OUTPUT "${generated_dir}/.nunavut_stamp" - COMMAND "${NUNAVUT_EXECUTABLE}" - --target-language c++ - --outdir "${generated_dir}" - "${dsdl_dir}" - COMMAND "${CMAKE_COMMAND}" -E touch "${generated_dir}/.nunavut_stamp" - DEPENDS ${dsdl_files} - COMMENT "Generating Cyphal C++ types from DSDL" - VERBATIM - ) - - add_custom_target(nunavut_generate - DEPENDS "${generated_dir}/.nunavut_stamp" - ) - - add_dependencies(${target} nunavut_generate) - target_include_directories(${target} PUBLIC "${generated_dir}") -endfunction() diff --git a/docs/integration.md b/docs/integration.md new file mode 100644 index 0000000..03d671f --- /dev/null +++ b/docs/integration.md @@ -0,0 +1,160 @@ +# Integrating cymon-lib into a Cyphal firmware + +This guide connects `cymon::Device` to a Cyphal node that uses libcanard and +the C serialization Nunavut generates. That is the UNIMOC setup; the steps +for UNIMOC itself are listed in its `docs/cymon_integration_plan.md`. + +## 1. Build + +Add the library as a subproject and link it: + +```cmake +set(CYMON_MAX_BUFFER_BYTES 8192 CACHE STRING "" FORCE) # optional limits +add_subdirectory(path/to/cymon-lib ${CMAKE_CURRENT_BINARY_DIR}/cymon) +target_link_libraries(my_firmware PRIVATE cymon::cymon) +``` + +As a subproject, the library takes the parent build's flags and builds no +tests. It needs C++20 and no heap. + +## 2. Generate the serialization + +Add `dsdl/cymon` (exported as `CYMON_DSDL_DIR`) as one more root namespace +for Nunavut. It depends on no other namespace: + +```sh +python -m nunavut -O -l c --target-endianness little /dsdl/cymon +``` + +This produces `cymon/_0_1.h` with the usual +`cymon__Request_0_1_deserialize_()` and `..._Response_0_1_serialize_()` +functions, plus the `_EXTENT_BYTES_` and `_SERIALIZATION_BUFFER_SIZE_BYTES_` +constants. + +## 3. Port-IDs and registers + +The cymon types have no fixed port-IDs. Give each one a register, following +the Cyphal convention, so tools can find it: + +| Register (`natural16[1]`) | Read-only companion (`string`) | +|---|---| +| `uavcan.srv.cymon_variables.id` | `uavcan.srv.cymon_variables.type` = `cymon.GetVariableList.0.1` | +| `uavcan.srv.cymon_buffer_info.id` | `uavcan.srv.cymon_buffer_info.type` = `cymon.GetBufferInfo.0.1` | +| `uavcan.srv.cymon_setup.id` | `uavcan.srv.cymon_setup.type` = `cymon.SetupCapture.0.1` | +| `uavcan.srv.cymon_arm.id` | `uavcan.srv.cymon_arm.type` = `cymon.ArmTrigger.0.1` | +| `uavcan.srv.cymon_read.id` | `uavcan.srv.cymon_read.type` = `cymon.ReadSamples.0.1` | +| `uavcan.pub.cymon_trigger.id` | `uavcan.pub.cymon_trigger.type` = `cymon.TriggerEvent.0.1` | +| `uavcan.sub.cymon_trigger.id` | `uavcan.sub.cymon_trigger.type` = `cymon.TriggerEvent.0.1` | + +An ID of 65535 disables the port. Service-IDs must be in the unregulated +range 0..255. The suggested defaults are 120..124 for the five services in +the order of the table, with the trigger subject disabled. The cymon host +tool reads `uavcan.node.port.List` and the `.type` registers, so any IDs +work as long as they are unique on the node. + +Also add every server to the node's `uavcan.node.port.List` and subscribe the +service requests with the request extent (`..._Request_0_1_EXTENT_BYTES_`). + +## 4. Service handlers (Cyphal thread) + +One function per service: deserialize the request, call the device, +serialize the response. Error handling of the (de)serialization is left out +here. + +```cpp +#include "cymon/ReadSamples_0_1.h" +#include "cymon/SetupCapture_0_1.h" +#include "cymon/device.hpp" + +std::size_t OnSetupCapture(cymon::Device& device, std::span request, + std::span response) { + cymon_SetupCapture_Request_0_1 req{}; + std::size_t size = request.size(); + cymon_SetupCapture_Request_0_1_deserialize_(&req, reinterpret_cast(request.data()), &size); + + cymon::Device::CaptureConfig config; + // At most 16 IDs by the DSDL; more than kMaxChannels is answered with BAD_CONFIG. + config.num_channels = static_cast(req.channel_ids.count); + std::copy_n(req.channel_ids.elements, std::min(req.channel_ids.count, cymon::kMaxChannels), + config.channel_ids.begin()); + config.sample_period_us = req.sample_period_us; + config.num_samples = req.num_samples; + config.pretrigger_samples = req.pretrigger_samples; + const auto result = device.HandleSetupCapture(config); + + cymon_SetupCapture_Response_0_1 resp{}; + resp.result.value = static_cast(result.error); + resp.actual_sample_period_us = result.actual_sample_period_us; + size = response.size(); + cymon_SetupCapture_Response_0_1_serialize_(&resp, reinterpret_cast(response.data()), &size); + return size; +} + +std::size_t OnReadSamples(const cymon::Device& device, std::span request, + std::span response) { + cymon_ReadSamples_Request_0_1 req{}; + std::size_t size = request.size(); + cymon_ReadSamples_Request_0_1_deserialize_(&req, reinterpret_cast(request.data()), &size); + + const auto result = device.HandleReadSamples(req.frame_offset, req.max_frames); + cymon_ReadSamples_Response_0_1 resp{}; + resp.state.value = static_cast(result.state); + resp.num_channels = result.num_channels; + resp.num_samples = result.num_samples; + resp.pretrigger_samples = result.pretrigger_samples; + resp.sample_period_us = result.sample_period_us; + resp.end_of_data = result.end_of_data; + resp.samples.count = result.sample_count; + std::copy_n(result.samples.begin(), result.sample_count, resp.samples.elements); + size = response.size(); + cymon_ReadSamples_Response_0_1_serialize_(&resp, reinterpret_cast(response.data()), &size); + return size; +} +``` + +The other three services follow the same pattern: + +- `GetVariableList` uses `GetVariable(index)`. Copy `name` and `unit` into + the byte arrays; an empty name ends the list. +- `GetBufferInfo` uses `HandleGetBufferInfo()`. +- `ArmTrigger` uses `HandleArmTrigger()`. The trigger mode arrives as a raw + `uint8`; the device rejects values it does not know. + +On every spin of the Cyphal thread, publish a pending trigger event: + +```cpp +cymon::Device::TriggerEvent event; +if (device.TakeTriggerEvent(&event)) { /* serialize cymon_TriggerEvent_0_1 and publish */ } +``` + +When a `cymon.TriggerEvent.0.1` from another node arrives on the subscribed +trigger subject, call `device.RequestTrigger()`. + +## 5. Sampling (interrupt) + +Register the variables once at start-up and call `Tick()` at a fixed rate, +after the values of that period are written: + +```cpp +device.RegisterVariable("i_q", "A", &measurement.i_q); +device.SetTickPeriod(62.5F); // the rate Tick() is called at [us] +... +void OnControlCycle() { + // ... control ... + device.Tick(); +} +``` + +`Tick()` copies at most `CYMON_MAX_CHANNELS` floats and evaluates one +comparison. It reads the variables only through their pointers, so it is +allowed in a zero-latency interrupt. + +## 6. Check with the host tool + +```sh +cymon serve --iface canable:/dev/ttyACM0 --bitrate 1000000 # see the cymon README +``` + +On the **Scope** page of the tool, each node with cymon services shows its +variables. If a node is missing, its `uavcan.srv.cymon_*` registers are +probably absent or set to 65535. diff --git a/dsdl/cymon/ArmTrigger.0.1.dsdl b/dsdl/cymon/ArmTrigger.0.1.dsdl new file mode 100644 index 0000000..459d30c --- /dev/null +++ b/dsdl/cymon/ArmTrigger.0.1.dsdl @@ -0,0 +1,14 @@ + +# Arms the configured capture (restarting it if armed) or disarms it. +# Disarming discards a complete capture. +# Port: register uavcan.srv.cymon_arm.id (no fixed port-ID). + +bool arm +uint8 source_variable_id # ignored for IMMEDIATE +cymon.TriggerMode.0.1 mode +float32 level +float32 hysteresis_band # HYSTERESIS only +@extent 64 * 8 +--- +cymon.Error.0.1 result +@extent 64 * 8 diff --git a/dsdl/cymon/ArmTrigger.1.0.dsdl b/dsdl/cymon/ArmTrigger.1.0.dsdl deleted file mode 100644 index 67f0a16..0000000 --- a/dsdl/cymon/ArmTrigger.1.0.dsdl +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2026 Tecnologic -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -bool arm -uint8 source_variable_id -cymon.TriggerMode.1.0 mode -float32 level -float32 hysteresis_band ---- -bool ok -uint8 error_code diff --git a/dsdl/cymon/CaptureState.0.1.dsdl b/dsdl/cymon/CaptureState.0.1.dsdl new file mode 100644 index 0000000..fa703ca --- /dev/null +++ b/dsdl/cymon/CaptureState.0.1.dsdl @@ -0,0 +1,11 @@ + +# State of the capture of a node (cymon::CaptureState). + +uint8 IDLE = 0 # No capture configured. +uint8 CONFIGURED = 1 # Configured, not armed (also after a disarm). +uint8 ARMED = 2 # Recording the pre-trigger part, waiting for the trigger. +uint8 TRIGGERED = 3 # Triggered, recording the post-trigger part. +uint8 COMPLETE = 4 # Complete; ReadSamples returns the samples. + +uint8 value +@sealed diff --git a/dsdl/cymon/Error.0.1.dsdl b/dsdl/cymon/Error.0.1.dsdl new file mode 100644 index 0000000..66037fd --- /dev/null +++ b/dsdl/cymon/Error.0.1.dsdl @@ -0,0 +1,12 @@ + +# Result of a cymon request (cymon::ErrorCode). + +uint8 NONE = 0 +uint8 BAD_CHANNEL_ID = 1 # A channel or the trigger source is no registered variable. +uint8 BUFFER_TOO_SMALL = 2 # num_samples does not fit the buffer with this channel count. +uint8 NOT_CONFIGURED = 3 # ArmTrigger before a successful SetupCapture. +uint8 ALREADY_CAPTURING = 5 # SetupCapture while armed; disarm first. +uint8 BAD_CONFIG = 9 # No or too many channels, pretrigger >= num_samples, bad trigger mode. + +uint8 value +@sealed diff --git a/dsdl/cymon/GetBufferInfo.0.1.dsdl b/dsdl/cymon/GetBufferInfo.0.1.dsdl new file mode 100644 index 0000000..ba8b9ac --- /dev/null +++ b/dsdl/cymon/GetBufferInfo.0.1.dsdl @@ -0,0 +1,12 @@ + +# Capture limits of a node. +# Port: register uavcan.srv.cymon_buffer_info.id (no fixed port-ID). + +@extent 64 * 8 +--- +uint32 buffer_bytes # capture buffer size +uint8 max_channels # channels of one capture +uint16 max_frames # frames with one channel; with N channels max_frames / N +uint8 max_read_samples # samples one ReadSamples response carries +float32 tick_period_us # period of the sampling tick; sample periods are multiples of it (0: unknown) +@extent 64 * 8 diff --git a/dsdl/cymon/GetBufferInfo.1.0.dsdl b/dsdl/cymon/GetBufferInfo.1.0.dsdl deleted file mode 100644 index 3b5ba3d..0000000 --- a/dsdl/cymon/GetBufferInfo.1.0.dsdl +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2026 Tecnologic -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Empty request — returns capture buffer limits. - ---- -uint32 buffer_bytes -uint8 max_channels -uint16 max_frames # total frames storable; divide by your channel count to get per-channel depth diff --git a/dsdl/cymon/GetVariableList.0.1.dsdl b/dsdl/cymon/GetVariableList.0.1.dsdl new file mode 100644 index 0000000..ba887d8 --- /dev/null +++ b/dsdl/cymon/GetVariableList.0.1.dsdl @@ -0,0 +1,11 @@ + +# Iterates the variable registry of a node, like uavcan.register.List. +# The variable ID is the index; an empty name ends the list. +# Port: register uavcan.srv.cymon_variables.id (no fixed port-ID). + +uint16 index +@extent 64 * 8 +--- +uint8[<=32] name # UTF-8; empty past the last variable +uint8[<=8] unit # engineering unit, e.g. "A", "rad/s", "V"; may be empty +@extent 128 * 8 diff --git a/dsdl/cymon/GetVariableList.1.0.dsdl b/dsdl/cymon/GetVariableList.1.0.dsdl deleted file mode 100644 index a303979..0000000 --- a/dsdl/cymon/GetVariableList.1.0.dsdl +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2026 Tecnologic -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Iterator-style variable list request, similar to uavcan.register.List. -# Request the variable at the given index; empty name signals end of list. - -uint16 index ---- -uint8 id -uint8[<=32] name # UTF-8 variable name; empty = end of list sentinel -uint8[<=8] unit # engineering unit string, e.g. "rpm", "A", "V" diff --git a/dsdl/cymon/ReadSamples.0.1.dsdl b/dsdl/cymon/ReadSamples.0.1.dsdl new file mode 100644 index 0000000..99c5df8 --- /dev/null +++ b/dsdl/cymon/ReadSamples.0.1.dsdl @@ -0,0 +1,17 @@ + +# Capture status and paged read-out of a complete capture. Poll with +# max_frames = 0 for the status; once COMPLETE, page with frame_offset. +# Port: register uavcan.srv.cymon_read.id (no fixed port-ID). + +uint16 frame_offset # first frame; frame pretrigger_samples is the trigger sample +uint8 max_frames # limited by max_read_samples / num_channels +@extent 64 * 8 +--- +cymon.CaptureState.0.1 state +uint8 num_channels +uint16 num_samples +uint16 pretrigger_samples +float32 sample_period_us +bool end_of_data # the response holds the last frame +float32[<=64] samples # COMPLETE only: frames in time order, num_channels values per frame +@extent 384 * 8 diff --git a/dsdl/cymon/ReadSamples.1.0.dsdl b/dsdl/cymon/ReadSamples.1.0.dsdl deleted file mode 100644 index de1392e..0000000 --- a/dsdl/cymon/ReadSamples.1.0.dsdl +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright 2026 Tecnologic -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Paged read-out of captured samples. - -uint16 frame_offset # page index: 0 = first page, increment by max_frames to page through capture -uint8 max_frames ---- -bool triggered -bool end_of_data -uint8 num_channels -float32[<=64] samples # interleaved: num_channels floats per frame diff --git a/dsdl/cymon/SetupCapture.0.1.dsdl b/dsdl/cymon/SetupCapture.0.1.dsdl new file mode 100644 index 0000000..2dbbd54 --- /dev/null +++ b/dsdl/cymon/SetupCapture.0.1.dsdl @@ -0,0 +1,13 @@ + +# Configures the next capture. Refused while armed (disarm with ArmTrigger first). +# Port: register uavcan.srv.cymon_setup.id (no fixed port-ID). + +uint8[<=16] channel_ids # variable IDs, one per channel, at most GetBufferInfo.max_channels +float32 sample_period_us # rounded to a multiple of the tick period, never below it +uint16 num_samples # frames of the capture, pre-trigger part included +uint16 pretrigger_samples # frames before the trigger sample, < num_samples +@extent 64 * 8 +--- +cymon.Error.0.1 result +float32 actual_sample_period_us +@extent 64 * 8 diff --git a/dsdl/cymon/SetupCapture.1.0.dsdl b/dsdl/cymon/SetupCapture.1.0.dsdl deleted file mode 100644 index e83a924..0000000 --- a/dsdl/cymon/SetupCapture.1.0.dsdl +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2026 Tecnologic -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -uint8[<=16] channel_ids -float32 sample_period_us -uint16 num_samples -uint16 pretrigger_samples ---- -bool ok -uint8 error_code -float32 actual_sample_period_us # period actually used by hardware, may differ from requested diff --git a/dsdl/cymon/TriggerEvent.0.1.dsdl b/dsdl/cymon/TriggerEvent.0.1.dsdl new file mode 100644 index 0000000..cca4838 --- /dev/null +++ b/dsdl/cymon/TriggerEvent.0.1.dsdl @@ -0,0 +1,9 @@ + +# Published by a node when its capture triggers. Other nodes can subscribe +# to it to trigger their captures at the same moment (cymon::Device::RequestTrigger). +# Port: registers uavcan.pub.cymon_trigger.id / uavcan.sub.cymon_trigger.id. + +uint8 source_variable_id +cymon.TriggerMode.0.1 mode +float32 trigger_value +@extent 32 * 8 diff --git a/dsdl/cymon/TriggerEvent.1.0.dsdl b/dsdl/cymon/TriggerEvent.1.0.dsdl deleted file mode 100644 index a51ac82..0000000 --- a/dsdl/cymon/TriggerEvent.1.0.dsdl +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2026 Tecnologic -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Broadcast message (subject) emitted when a trigger fires. - -uint8 source_node_id -cymon.TriggerMode.1.0 mode -float32 trigger_value -float32 timestamp_us diff --git a/dsdl/cymon/TriggerMode.0.1.dsdl b/dsdl/cymon/TriggerMode.0.1.dsdl new file mode 100644 index 0000000..cb341b4 --- /dev/null +++ b/dsdl/cymon/TriggerMode.0.1.dsdl @@ -0,0 +1,10 @@ + +# Trigger condition of a capture (cymon::TriggerMode). + +uint8 RISING = 0 # The source crosses the level upwards. +uint8 FALLING = 1 # The source crosses the level downwards. +uint8 HYSTERESIS = 2 # Rising edge; fires again only after the source fell below level - hysteresis_band. +uint8 IMMEDIATE = 3 # Fires on the first sample after the pre-trigger part is recorded. + +uint8 value +@sealed diff --git a/dsdl/cymon/TriggerMode.1.0.dsdl b/dsdl/cymon/TriggerMode.1.0.dsdl deleted file mode 100644 index 1aa6787..0000000 --- a/dsdl/cymon/TriggerMode.1.0.dsdl +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright 2026 Tecnologic -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -uint8 RISING = 0 -uint8 FALLING = 1 -uint8 HYSTERESIS = 2 - -uint8 value diff --git a/include/cymon/config.hpp b/include/cymon/config.hpp index a99cd6f..e173e3f 100644 --- a/include/cymon/config.hpp +++ b/include/cymon/config.hpp @@ -1,18 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/// @file config.hpp +/// @brief Compile-time limits of cymon-lib. +/// +/// Every limit can be overridden with a preprocessor define (CMake cache +/// variables of the same name, see the README), before any cymon header is +/// included. All storage is static: the limits size the Device object. #pragma once + #include #ifndef CYMON_MAX_BUFFER_BYTES @@ -27,25 +24,33 @@ namespace cymon { -/// Total size of the capture buffer in bytes. The buffer holds -/// kMaxBufferBytes / sizeof(float) float32 slots split across all active -/// channels. Override with -DCYMON_MAX_BUFFER_BYTES=. +/// Size of the capture buffer in bytes, split across the channels of one +/// capture (kMaxBufferBytes / sizeof(float) samples in total). inline constexpr std::size_t kMaxBufferBytes = CYMON_MAX_BUFFER_BYTES; -/// Maximum number of named scalar variables that can be registered on the -/// device (the full variable registry, e.g. every signal the firmware exposes). -/// Hard upper bound is 255 (IDs are uint8). Override with -DCYMON_MAX_VARS=. +/// Number of variables the registry holds (every signal the firmware offers). inline constexpr std::size_t kMaxVars = CYMON_MAX_VARS; -static_assert(kMaxVars <= 255U, "CYMON_MAX_VARS must not exceed 255 (variable IDs are uint8)"); -/// Maximum number of variables that can be captured *simultaneously* in a -/// single capture session. Always <= kMaxVars. A monitor selects up to -/// kMaxChannels variables from the registry for one capture run. -/// Hard upper bound is 255. Override with -DCYMON_MAX_CHANNELS=. +/// Number of variables one capture records at the same time (<= kMaxVars). inline constexpr std::size_t kMaxChannels = CYMON_MAX_CHANNELS; -static_assert(kMaxChannels <= 255U, "CYMON_MAX_CHANNELS must not exceed 255 (channel IDs are uint8)"); -/// Maximum number of sample frames returned in a single ReadSamples response. -inline constexpr std::size_t kMaxReadFrames = 4U; +/// Longest variable name in bytes (cymon.GetVariableList.0.1). +inline constexpr std::size_t kMaxNameLength = 32U; + +/// Longest unit string in bytes (cymon.GetVariableList.0.1). +inline constexpr std::size_t kMaxUnitLength = 8U; + +/// Samples (not frames) one ReadSamples response carries (cymon.ReadSamples.0.1). +inline constexpr std::size_t kMaxReadSamples = 64U; + +/// Channel IDs one SetupCapture request can carry (cymon.SetupCapture.0.1). +inline constexpr std::size_t kMaxRequestChannels = 16U; + +static_assert(kMaxVars > 0U && kMaxVars <= 255U, "CYMON_MAX_VARS must be 1..255 (variable IDs are uint8)"); +static_assert(kMaxChannels > 0U && kMaxChannels <= kMaxVars, "CYMON_MAX_CHANNELS must be 1..CYMON_MAX_VARS"); +static_assert(kMaxChannels <= kMaxRequestChannels, "CYMON_MAX_CHANNELS exceeds cymon.SetupCapture.0.1"); +static_assert(kMaxChannels <= kMaxReadSamples, "one frame must fit into a ReadSamples response"); +static_assert(kMaxBufferBytes >= sizeof(float) * kMaxChannels, "CYMON_MAX_BUFFER_BYTES too small"); +static_assert(kMaxBufferBytes / sizeof(float) <= 65535U, "frame indices are uint16"); } // namespace cymon diff --git a/include/cymon/device.hpp b/include/cymon/device.hpp index 847101c..31a572a 100644 --- a/include/cymon/device.hpp +++ b/include/cymon/device.hpp @@ -1,138 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/// @file device.hpp +/// @brief Device side of the cymon trace protocol: variable registry, capture and trigger. #pragma once + #include +#include #include -#include +#include #include - #include "cymon/config.hpp" #include "cymon/sample_buffer.hpp" #include "cymon/trigger.hpp" namespace cymon { +/// Result of a request; the values are those of cymon.Error.0.1. +enum class ErrorCode : uint8_t { + kNone = 0, + kBadChannelId = 1, ///< A channel or the trigger source is no registered variable. + kBufferTooSmall = 2, ///< num_samples does not fit the buffer with this channel count. + kNotConfigured = 3, ///< ArmTrigger before a successful SetupCapture. + kAlreadyCapturing = 5, ///< SetupCapture while armed; disarm first. + kBadConfig = 9, ///< No or too many channels, pretrigger >= num_samples, bad trigger mode. +}; + +/// Capture state; the values are those of cymon.CaptureState.0.1. +enum class CaptureState : uint8_t { + kIdle = 0, ///< No capture configured. + kConfigured = 1, ///< Configured, not armed (also after a disarm). + kArmed = 2, ///< Recording the pre-trigger ring, waiting for the trigger. + kTriggered = 3, ///< Triggered, recording the post-trigger part. + kComplete = 4, ///< Capture complete; the samples can be read. +}; + +/** + * @brief Variable registry and oscilloscope-style capture of one node. + * + * The library has no Cyphal dependency: the integrating firmware deserializes + * the cymon service requests (dsdl/cymon), calls the Handle*() methods and + * serializes their results. + * + * Execution contexts: + * - Tick() runs in the sampling context, typically an interrupt at a fixed + * rate. It is allocation-free, lock-free and calls no function pointers. + * - Everything else runs in one thread (the Cyphal thread). Tick() may + * preempt it at any point: the capture state is an atomic that the thread + * publishes last, and the thread changes the capture setup only while Tick() + * ignores it (not armed). Tick() and the thread must not run in parallel on + * two cores. + */ class Device { public: - enum class ErrorCode : uint8_t { - kNone = 0, - kBadChannelId = 1, - kBufferTooSmall = 2, - kNotConfigured = 3, - kNotArmed = 4, - kAlreadyCapturing = 5, - kTooManyVars = 6, - kNameTooLong = 7, - kUnitTooLong = 8, - kBadConfig = 9, ///< num_channels is 0 or exceeds kMaxChannels - }; - struct VariableInfo { - uint8_t id = 0; - char name[33] = {}; - char unit[9] = {}; + std::string_view name; + std::string_view unit; }; struct CaptureConfig { - std::array channel_ids = {}; - uint8_t num_channels = 0; - float sample_period_us = 0.0F; - uint16_t num_samples = 0; - uint16_t pretrigger_samples = 0; + std::array channel_ids{}; + uint8_t num_channels{0U}; + /// Requested sample period [us]; rounded to a whole number of ticks. + float sample_period_us{0.0F}; + uint16_t num_samples{0U}; + uint16_t pretrigger_samples{0U}; }; - struct GetBufferInfoResponse { - uint32_t buffer_bytes = static_cast(kMaxBufferBytes); - uint8_t max_channels = static_cast(kMaxChannels); - /// Total frames storable with 1 channel. Divide by actual channel count to - /// get per-configuration depth: max_frames_for_N = max_frames / N. - uint16_t max_frames = 0; + struct BufferInfo { + uint32_t buffer_bytes{0U}; + uint8_t max_channels{0U}; + /// Frames the buffer holds with one channel; with N channels it holds max_frames / N. + uint16_t max_frames{0U}; + /// Samples one ReadSamples response carries at most. + uint8_t max_read_samples{0U}; + /// Period of Tick() [us], 0 if unknown. + float tick_period_us{0.0F}; }; - struct SetupCaptureResponse { - bool ok = false; - ErrorCode error_code = ErrorCode::kNone; - float actual_sample_period_us = 0.0F; + struct SetupCaptureResult { + ErrorCode error{ErrorCode::kNone}; + /// Sample period used: decimation * tick period [us] (the request if the tick period is unknown). + float actual_sample_period_us{0.0F}; }; struct ArmTriggerConfig { - bool arm = false; - uint8_t source_variable_id = 0; - TriggerMode trigger_mode = TriggerMode::kRising; - float trigger_level = 0.0F; - float hysteresis_band = 0.0F; + bool arm{false}; + uint8_t source_variable_id{0U}; + TriggerMode mode{TriggerMode::kRising}; + float level{0.0F}; + float hysteresis_band{0.0F}; }; - struct ArmTriggerResponse { - bool ok = false; - ErrorCode error_code = ErrorCode::kNone; + struct ReadSamplesResult { + CaptureState state{CaptureState::kIdle}; + uint8_t num_channels{0U}; + uint16_t num_samples{0U}; + uint16_t pretrigger_samples{0U}; + float sample_period_us{0.0F}; + bool end_of_data{false}; + /// Interleaved frames; the first sample_count entries are valid. + std::array samples{}; + uint8_t sample_count{0U}; + + [[nodiscard]] std::span Samples() const noexcept { return {samples.data(), sample_count}; } }; - struct ReadSamplesResponse { - bool triggered = false; - bool end_of_data = false; - uint8_t num_channels = 0; - std::array samples = {}; - uint8_t num_samples = 0; // float count in samples + struct TriggerEvent { + uint8_t source_variable_id{0U}; + TriggerMode mode{TriggerMode::kRising}; + float trigger_value{0.0F}; }; - Device(); + /** + * @brief Registers a variable that Tick() samples by reading @p source. + * + * Call during start-up, before the first Tick(). The variable ID is the + * registration index. The name and unit views and the source must outlive + * the device (string literals and static variables). A float written by + * another context is read with one 32-bit load, which is atomic on the + * supported targets. + * + * @return false if the registry is full, a string is too long or the name is empty. + */ + bool RegisterVariable(std::string_view name, std::string_view unit, const float* source) noexcept; - /// Set actual sample period achieved by hardware timer. - /// Call after hardware timer setup; returned in SetupCaptureResponse. - void set_actual_sample_period_us(float us); + /// Period of Tick() [us]; used to round the requested sample period to a decimation. + void SetTickPeriod(float tick_period_us) noexcept; - /// Register a named variable with a getter. Returns false if at capacity or - /// name/unit too long. - bool RegisterVariable(std::string_view name, std::string_view unit, std::function getter); + [[nodiscard]] uint8_t VariableCount() const noexcept; - uint16_t variable_count() const; + /// Variable @p index; an empty name past the end (cymon.GetVariableList.0.1). + [[nodiscard]] VariableInfo GetVariable(uint16_t index) const noexcept; - /// Iterator-style: returns false when index is out of range (end of list). - bool GetVariable(uint16_t index, VariableInfo* info) const; + [[nodiscard]] BufferInfo HandleGetBufferInfo() const noexcept; + SetupCaptureResult HandleSetupCapture(const CaptureConfig& config) noexcept; + ErrorCode HandleArmTrigger(const ArmTriggerConfig& config) noexcept; + /// Status and, once complete, up to @p max_frames frames from @p frame_offset (0 frames = status only). + [[nodiscard]] ReadSamplesResult HandleReadSamples(uint16_t frame_offset, uint8_t max_frames) const noexcept; - GetBufferInfoResponse HandleGetBufferInfo() const; - SetupCaptureResponse HandleSetupCapture(const CaptureConfig& config); - ArmTriggerResponse HandleArmTrigger(const ArmTriggerConfig& config); - ReadSamplesResponse HandleReadSamples(uint16_t frame_offset, uint8_t max_frames) const; + /// Triggers an armed capture at the next sample (e.g. on a cymon.TriggerEvent of another node). + void RequestTrigger() noexcept; - /// Call from timer task at configured rate. Returns true when capture is - /// complete. - bool Tick(); + /// Takes the event of the last trigger, once; publish it as cymon.TriggerEvent.0.1. + bool TakeTriggerEvent(TriggerEvent* event) noexcept; - /// Force trigger (e.g., from received TriggerEvent message from another - /// node). - void ForceTrigger(); + [[nodiscard]] CaptureState State() const noexcept; + + /** + * @brief Advances the capture by one tick; call at the period given to SetTickPeriod(). + * @return true once the capture is complete. + */ + bool Tick() noexcept; private: - struct VarEntry { - char name[33] = {}; - char unit[9] = {}; - std::function getter; - bool active = false; + struct Variable { + std::string_view name; + std::string_view unit; + const float* source{nullptr}; }; - std::array vars_; - uint8_t var_count_ = 0; - CaptureConfig capture_config_; - ArmTriggerConfig trigger_config_; - float actual_sample_period_us_ = 0.0F; - bool configured_ = false; - float prev_trigger_value_ = 0.0F; + [[nodiscard]] float Read(uint8_t variable_id) const noexcept; + + // Registry, written during start-up only. + std::array vars_{}; + uint8_t var_count_{0U}; + float tick_period_us_{0.0F}; + + // Capture setup: written by the thread while not armed, read by Tick(). + CaptureConfig capture_config_{}; + float actual_sample_period_us_{0.0F}; + uint16_t decimation_{1U}; + uint8_t trigger_source_{0U}; + TriggerMode trigger_mode_{TriggerMode::kRising}; + + // Owned by Tick() while armed. + uint16_t decimation_counter_{0U}; + float prev_trigger_value_{0.0F}; SampleBuffer buffer_; Trigger trigger_; + float trigger_value_{0.0F}; + + std::atomic state_{CaptureState::kIdle}; + std::atomic trigger_requested_{false}; + std::atomic trigger_event_pending_{false}; + + static_assert(std::atomic::is_always_lock_free, "Tick() must be lock-free"); + static_assert(std::atomic::is_always_lock_free, "Tick() must be lock-free"); }; } // namespace cymon diff --git a/include/cymon/sample_buffer.hpp b/include/cymon/sample_buffer.hpp index 334ae14..48440b9 100644 --- a/include/cymon/sample_buffer.hpp +++ b/include/cymon/sample_buffer.hpp @@ -1,75 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/// @file sample_buffer.hpp +/// @brief Ring buffer of sample frames with pre- and post-trigger depth. #pragma once + +#include #include #include - +#include #include "cymon/config.hpp" namespace cymon { -/// Fixed-size circular sample buffer for pre/post-trigger capture. -/// Total storage is kMaxBufferBytes bytes = kMaxBufferBytes/4 float32 slots. +/** + * @brief Fixed-size ring of frames (one float per channel) for pre/post-trigger capture. + * + * Before the trigger the ring runs continuously; after Trigger() it records + * num_samples - pretrigger_samples further frames and is complete. Frames are + * read back in time order, frame pretrigger_samples being the trigger sample. + * Allocation-free; WriteFrame() and Trigger() are safe in an interrupt. + */ class SampleBuffer { public: - static constexpr std::size_t kCapacityBytes = kMaxBufferBytes; - static constexpr std::size_t kCapacityFloats = kCapacityBytes / sizeof(float); + static constexpr std::size_t kCapacityFloats = kMaxBufferBytes / sizeof(float); + + /** + * @brief Sets the frame layout and disarms the buffer. + * @return false if the layout is empty or does not fit (the old one stays). + */ + [[nodiscard]] bool Setup(uint8_t num_channels, uint16_t num_samples, uint16_t pretrigger_samples) noexcept; - /// Configure channels, samples, pretrigger depth. - /// Returns false if configuration exceeds buffer capacity. - bool Setup(uint8_t num_channels, uint16_t num_samples, uint16_t pretrigger_samples); + /// Clears the ring and starts recording (requires a successful Setup()). + void Arm() noexcept; - void Arm(); - void Disarm(); + void Disarm() noexcept; - /// Write one frame (num_channels values). Returns false if not armed or - /// complete. - bool WriteFrame(const float* values); + /// Appends one frame of NumChannels() values. Returns false if not armed or complete. + bool WriteFrame(std::span values) noexcept; - /// Signal trigger. trigger_pos_ = current write_pos_. Returns false if not - /// armed. - bool Trigger(); + /// Marks the next written frame as the trigger sample. Returns false if not armed or already triggered. + bool Trigger() noexcept; - /// Read frames starting at frame_offset (0 = first pre-trigger frame). - /// Returns number of frames actually placed in out[]. - uint16_t ReadFrames(uint16_t frame_offset, uint8_t max_frames, float* out) const; + /** + * @brief Copies frames in time order into @p out (interleaved, NumChannels() floats per frame). + * @param frame_offset First frame (0 = oldest pre-trigger frame). + * @param max_frames Frames to copy at most; also limited by the size of @p out. + * @return Number of frames copied. Pre-trigger frames that were never written read as 0. + */ + [[nodiscard]] uint16_t ReadFrames(uint16_t frame_offset, + uint16_t max_frames, + std::span out) const noexcept; - bool IsComplete() const; - bool IsArmed() const; - bool WasTriggered() const; - uint8_t num_channels() const; - uint16_t num_samples() const; - uint16_t capacity_frames() const; + [[nodiscard]] bool IsComplete() const noexcept; + [[nodiscard]] bool IsArmed() const noexcept; + [[nodiscard]] bool WasTriggered() const noexcept; + /// True once the pre-trigger part of the ring holds recorded frames only. + [[nodiscard]] bool PretriggerFilled() const noexcept; + [[nodiscard]] uint8_t NumChannels() const noexcept; + [[nodiscard]] uint16_t NumSamples() const noexcept; + [[nodiscard]] uint16_t PretriggerSamples() const noexcept; + /// Frames the ring holds with the current channel count. + [[nodiscard]] uint16_t CapacityFrames() const noexcept; private: - float data_[kCapacityFloats] = {}; - uint8_t num_channels_ = 0; - uint16_t num_samples_ = 0; - uint16_t pretrigger_samples_ = 0; - uint16_t capacity_ = 0; - uint16_t write_pos_ = 0; - uint16_t trigger_pos_ = 0; - uint16_t post_trigger_count_ = 0; - uint16_t total_written_ = 0; - /// Snapshot of total_written_ taken the moment Trigger() fires; used by - /// ReadFrames() to determine how many pre-trigger frames are truly available. - uint16_t written_before_trigger_ = 0; - bool configured_ = false; - bool armed_ = false; - bool triggered_ = false; - bool complete_ = false; + void CopyFrame(uint16_t ring_pos, std::span dest) const noexcept; + + std::array data_{}; + uint8_t num_channels_{0U}; + uint16_t num_samples_{0U}; + uint16_t pretrigger_samples_{0U}; + uint16_t capacity_{0U}; + uint16_t write_pos_{0U}; + uint16_t trigger_pos_{0U}; + uint16_t post_trigger_count_{0U}; + /// Frames written since Arm(), saturating at the pre-trigger depth. + uint16_t written_before_trigger_{0U}; + bool configured_{false}; + bool armed_{false}; + bool triggered_{false}; + bool complete_{false}; }; } // namespace cymon diff --git a/include/cymon/trigger.hpp b/include/cymon/trigger.hpp index 3ad3bc4..5ef1584 100644 --- a/include/cymon/trigger.hpp +++ b/include/cymon/trigger.hpp @@ -1,45 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/// @file trigger.hpp +/// @brief Edge and hysteresis trigger of a capture. #pragma once + #include namespace cymon { -enum class TriggerMode : uint8_t { kRising = 0, kFalling = 1, kHysteresis = 2 }; +/// Trigger condition; the values are those of cymon.TriggerMode.0.1. +enum class TriggerMode : uint8_t { + kRising = 0, ///< Fires when the value crosses the level upwards. + kFalling = 1, ///< Fires when the value crosses the level downwards. + kHysteresis = 2, ///< Rising edge, re-armed only after the value fell below level - band. + kImmediate = 3, ///< Fires on the first sample (single shot without condition). +}; + +/// Returns true if @p raw is a valid TriggerMode value. +[[nodiscard]] constexpr bool IsValidTriggerMode(uint8_t raw) noexcept { + return raw <= static_cast(TriggerMode::kImmediate); +} struct TriggerConfig { - TriggerMode mode = TriggerMode::kRising; - float level = 0.0F; - float hysteresis_band = 0.0F; + TriggerMode mode{TriggerMode::kRising}; + float level{0.0F}; + float hysteresis_band{0.0F}; }; +/** + * @brief Evaluates the trigger condition on consecutive samples of one variable. + * + * Fires at most once per Arm(). Allocation-free and lock-free; safe to call + * from an interrupt. + */ class Trigger { public: - void Configure(const TriggerConfig& config); - // Arm the trigger. initial_value is used to initialize hysteresis state. - void Arm(float initial_value = 0.0F); - void Disarm(); - // Returns true if trigger condition fires. Only fires once per arm. - bool Evaluate(float prev_value, float current_value); - bool IsArmed() const; + void Configure(const TriggerConfig& config) noexcept; + + /// Arms the trigger. @p initial_value initializes the hysteresis state. + void Arm(float initial_value = 0.0F) noexcept; + + void Disarm() noexcept; + + /// Returns true when the condition fires; the trigger disarms itself then. + [[nodiscard]] bool Evaluate(float prev_value, float current_value) noexcept; + + [[nodiscard]] bool IsArmed() const noexcept; private: TriggerConfig config_; - bool armed_ = false; - // hysteresis: waiting for value to drop below lower threshold - bool awaiting_low_ = false; + bool armed_{false}; + /// Hysteresis: waiting for the value to drop below level - band. + bool awaiting_low_{false}; }; } // namespace cymon diff --git a/scripts/check_dsdl.sh b/scripts/check_dsdl.sh new file mode 100755 index 0000000..5e3a526 --- /dev/null +++ b/scripts/check_dsdl.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# Validates dsdl/cymon, checks that the C serialization Nunavut generates from +# it (the way UNIMOC generates it) compiles, and compiles the service handler +# example of docs/integration.md against it. Needs nunavut +# (pip install nunavut), a C compiler and a C++20 compiler. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +out="$(mktemp -d)" +trap 'rm -rf "${out}"' EXIT + +python3 -m nunavut -O "${out}" -l c --target-endianness little "${root}/dsdl/cymon" + +for header in "${out}"/cymon/*.h; do + printf '#include "%s"\n' "${header#"${out}/"}" | + "${CC:-cc}" -std=c11 -Wall -Wextra -Werror -I"${out}" -x c -fsyntax-only - +done +# The first C++ block of the integration guide uses the generated types. +python3 - "${root}/docs/integration.md" > "${out}/example.cpp" <<'PY' +import re, sys +text = open(sys.argv[1]).read() +print("#include \n#include \n#include ") +print(re.findall(r"```cpp\n(.*?)```", text, re.S)[0]) +PY +"${CXX:-c++}" -std=c++20 -Wall -Wextra -Werror -I"${root}/include" -I"${out}" -fsyntax-only "${out}/example.cpp" + +echo "DSDL ok: $(find "${out}/cymon" -name '*.h' | wc -l) types" diff --git a/src/cymon/device.cpp b/src/cymon/device.cpp index d671e62..e81a95f 100644 --- a/src/cymon/device.cpp +++ b/src/cymon/device.cpp @@ -1,202 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/// @file device.cpp +/// @brief Device side of the cymon trace protocol: variable registry, capture and trigger. #include "cymon/device.hpp" - #include -#include +#include namespace cymon { -Device::Device() = default; +namespace { -void Device::set_actual_sample_period_us(float us) { actual_sample_period_us_ = us; } +constexpr uint32_t kMaxDecimation = std::numeric_limits::max(); -bool Device::RegisterVariable(std::string_view name, std::string_view unit, std::function getter) { - if (var_count_ >= kMaxVars) { - return false; +/// Ticks per sample for @p requested_us, rounded to nearest, at least 1. +uint16_t Decimation(float requested_us, float tick_us) noexcept { + if (!(tick_us > 0.0F) || !(requested_us > tick_us)) { + return 1U; } - if (name.size() > 32U) { - return false; + const float kTicks = (requested_us / tick_us) + 0.5F; + if (kTicks >= static_cast(kMaxDecimation)) { + return static_cast(kMaxDecimation); } - if (unit.size() > 8U) { + return static_cast(kTicks); +} + +bool IsCapturing(CaptureState state) noexcept { + return state == CaptureState::kArmed || state == CaptureState::kTriggered; +} + +} // namespace + +bool Device::RegisterVariable(std::string_view name, std::string_view unit, const float* source) noexcept { + if (var_count_ >= kMaxVars || name.empty() || name.size() > kMaxNameLength || + unit.size() > kMaxUnitLength || source == nullptr) { return false; } - - VarEntry& entry = vars_[var_count_]; - std::memset(entry.name, 0, sizeof(entry.name)); - std::memset(entry.unit, 0, sizeof(entry.unit)); - std::memcpy(entry.name, name.data(), name.size()); - std::memcpy(entry.unit, unit.data(), unit.size()); - entry.getter = std::move(getter); - entry.active = true; + vars_[var_count_] = Variable{.name = name, .unit = unit, .source = source}; ++var_count_; return true; } -uint16_t Device::variable_count() const { return static_cast(var_count_); } +void Device::SetTickPeriod(float tick_period_us) noexcept { tick_period_us_ = tick_period_us; } -bool Device::GetVariable(uint16_t index, VariableInfo* info) const { +uint8_t Device::VariableCount() const noexcept { return var_count_; } + +Device::VariableInfo Device::GetVariable(uint16_t index) const noexcept { if (index >= var_count_) { - return false; + return {}; } - info->id = static_cast(index); - std::memcpy(info->name, vars_[index].name, sizeof(info->name)); - std::memcpy(info->unit, vars_[index].unit, sizeof(info->unit)); - return true; + return {.name = vars_[index].name, .unit = vars_[index].unit}; } -Device::GetBufferInfoResponse Device::HandleGetBufferInfo() const { - GetBufferInfoResponse resp; - resp.buffer_bytes = static_cast(kMaxBufferBytes); - resp.max_channels = static_cast(kMaxChannels); - // max_frames is the buffer depth with 1 channel; the monitor divides by its - // planned channel count: max_frames_for_N = max_frames / N. - resp.max_frames = static_cast(kMaxBufferBytes / sizeof(float)); - return resp; +Device::BufferInfo Device::HandleGetBufferInfo() const noexcept { + return {.buffer_bytes = static_cast(kMaxBufferBytes), + .max_channels = static_cast(kMaxChannels), + .max_frames = static_cast(SampleBuffer::kCapacityFloats), + .max_read_samples = static_cast(kMaxReadSamples), + .tick_period_us = tick_period_us_}; } -Device::SetupCaptureResponse Device::HandleSetupCapture(const CaptureConfig& config) { - SetupCaptureResponse resp; - - if (config.num_channels == 0 || config.num_channels > static_cast(kMaxChannels)) { - resp.ok = false; - resp.error_code = ErrorCode::kBadConfig; - return resp; +Device::SetupCaptureResult Device::HandleSetupCapture(const CaptureConfig& config) noexcept { + if (IsCapturing(state_.load(std::memory_order_acquire))) { + return {.error = ErrorCode::kAlreadyCapturing}; } - - for (uint8_t i = 0; i < config.num_channels; ++i) { - if (config.channel_ids[i] >= var_count_) { - resp.ok = false; - resp.error_code = ErrorCode::kBadChannelId; - return resp; - } + if (config.num_channels == 0U || config.num_channels > kMaxChannels) { + return {.error = ErrorCode::kBadConfig}; + } + const auto kChannels = std::span(config.channel_ids).first(config.num_channels); + if (std::any_of(kChannels.begin(), kChannels.end(), [this](uint8_t variable_id) { + return variable_id >= var_count_; + })) { + return {.error = ErrorCode::kBadChannelId}; + } + if (config.num_samples == 0U || config.pretrigger_samples >= config.num_samples) { + return {.error = ErrorCode::kBadConfig}; } - if (!buffer_.Setup(config.num_channels, config.num_samples, config.pretrigger_samples)) { - resp.ok = false; - resp.error_code = ErrorCode::kBufferTooSmall; - return resp; + return {.error = ErrorCode::kBufferTooSmall}; } capture_config_ = config; - configured_ = true; - - resp.ok = true; - resp.error_code = ErrorCode::kNone; - resp.actual_sample_period_us = (actual_sample_period_us_ > 0.0F) ? actual_sample_period_us_ : config.sample_period_us; - return resp; + decimation_ = Decimation(config.sample_period_us, tick_period_us_); + actual_sample_period_us_ = + (tick_period_us_ > 0.0F) ? static_cast(decimation_) * tick_period_us_ : config.sample_period_us; + state_.store(CaptureState::kConfigured, std::memory_order_release); + return {.error = ErrorCode::kNone, .actual_sample_period_us = actual_sample_period_us_}; } -Device::ArmTriggerResponse Device::HandleArmTrigger(const ArmTriggerConfig& config) { - ArmTriggerResponse resp; - +ErrorCode Device::HandleArmTrigger(const ArmTriggerConfig& config) noexcept { + const CaptureState kState = state_.load(std::memory_order_acquire); + if (kState == CaptureState::kIdle) { + return config.arm ? ErrorCode::kNotConfigured : ErrorCode::kNone; + } + // Stop Tick() first: from here on the thread owns the capture setup. + state_.store(CaptureState::kConfigured, std::memory_order_release); if (!config.arm) { - buffer_.Disarm(); - trigger_.Disarm(); - resp.ok = true; - return resp; + return ErrorCode::kNone; } - - if (!configured_) { - resp.ok = false; - resp.error_code = ErrorCode::kNotConfigured; - return resp; + if (!IsValidTriggerMode(static_cast(config.mode))) { + return ErrorCode::kBadConfig; } - - if (config.source_variable_id >= var_count_) { - resp.ok = false; - resp.error_code = ErrorCode::kBadChannelId; - return resp; + if (config.mode != TriggerMode::kImmediate && config.source_variable_id >= var_count_) { + return ErrorCode::kBadChannelId; } - trigger_config_ = config; - - TriggerConfig tc; - tc.mode = config.trigger_mode; - tc.level = config.trigger_level; - tc.hysteresis_band = config.hysteresis_band; - trigger_.Configure(tc); - - const float current_val = vars_[config.source_variable_id].getter ? vars_[config.source_variable_id].getter() : 0.0F; - prev_trigger_value_ = current_val; - trigger_.Arm(current_val); + trigger_source_ = config.source_variable_id; + trigger_mode_ = config.mode; + trigger_.Configure({.mode = config.mode, .level = config.level, .hysteresis_band = config.hysteresis_band}); + prev_trigger_value_ = Read(trigger_source_); + trigger_.Arm(prev_trigger_value_); + decimation_counter_ = 0U; buffer_.Arm(); - - resp.ok = true; - return resp; + trigger_requested_.store(false, std::memory_order_relaxed); + state_.store(CaptureState::kArmed, std::memory_order_release); + return ErrorCode::kNone; } -Device::ReadSamplesResponse Device::HandleReadSamples(uint16_t frame_offset, uint8_t max_frames) const { - ReadSamplesResponse resp; - - const uint8_t clamped = static_cast(std::min(static_cast(max_frames), kMaxReadFrames)); +Device::ReadSamplesResult Device::HandleReadSamples(uint16_t frame_offset, + uint8_t max_frames) const noexcept { + ReadSamplesResult result; + result.state = state_.load(std::memory_order_acquire); + if (result.state == CaptureState::kIdle) { + return result; + } + result.num_channels = capture_config_.num_channels; + result.num_samples = capture_config_.num_samples; + result.pretrigger_samples = capture_config_.pretrigger_samples; + result.sample_period_us = actual_sample_period_us_; + if (result.state != CaptureState::kComplete) { + return result; + } - const uint8_t ch = capture_config_.num_channels; - resp.num_channels = ch; + const uint16_t kFrames = buffer_.ReadFrames(frame_offset, max_frames, result.samples); + result.sample_count = static_cast(kFrames * result.num_channels); + result.end_of_data = static_cast(frame_offset) + kFrames >= result.num_samples; + return result; +} - resp.triggered = buffer_.WasTriggered(); +void Device::RequestTrigger() noexcept { trigger_requested_.store(true, std::memory_order_release); } - if (!resp.triggered) { - resp.end_of_data = false; - resp.num_samples = 0; - return resp; +bool Device::TakeTriggerEvent(TriggerEvent* event) noexcept { + if (event == nullptr || !trigger_event_pending_.exchange(false, std::memory_order_acquire)) { + return false; } + *event = {.source_variable_id = trigger_source_, .mode = trigger_mode_, .trigger_value = trigger_value_}; + return true; +} - const uint16_t frames_read = buffer_.ReadFrames(frame_offset, clamped, resp.samples.data()); - - resp.num_samples = static_cast(static_cast(frames_read) * ch); - - const uint16_t total = buffer_.num_samples(); - resp.end_of_data = static_cast(frame_offset) + frames_read >= total; +CaptureState Device::State() const noexcept { return state_.load(std::memory_order_acquire); } - return resp; +float Device::Read(uint8_t variable_id) const noexcept { + return (variable_id < var_count_) ? *vars_[variable_id].source : 0.0F; } -bool Device::Tick() { - if (!configured_ || !buffer_.IsArmed()) { - return buffer_.IsComplete(); +bool Device::Tick() noexcept { + const CaptureState kState = state_.load(std::memory_order_acquire); + if (!IsCapturing(kState)) { + return kState == CaptureState::kComplete; } - - // Evaluate trigger if armed. - if (trigger_.IsArmed()) { - const uint8_t src_id = trigger_config_.source_variable_id; - const float current_val = (src_id < var_count_ && vars_[src_id].getter) ? vars_[src_id].getter() : 0.0F; - - if (trigger_.Evaluate(prev_trigger_value_, current_val)) { + if (++decimation_counter_ < decimation_) { + return false; + } + decimation_counter_ = 0U; + + // The trigger is evaluated once the pre-trigger part is recorded, so a + // complete capture never contains unrecorded frames. + if (kState == CaptureState::kArmed) { + const float kValue = Read(trigger_source_); + if (buffer_.PretriggerFilled() && (trigger_.Evaluate(prev_trigger_value_, kValue) || + trigger_requested_.exchange(false, std::memory_order_acquire))) { + trigger_.Disarm(); buffer_.Trigger(); + trigger_value_ = kValue; + trigger_event_pending_.store(true, std::memory_order_release); + state_.store(CaptureState::kTriggered, std::memory_order_release); } - prev_trigger_value_ = current_val; + prev_trigger_value_ = kValue; } - // Sample all channels. - float frame[kMaxChannels] = {}; - const uint8_t ch = capture_config_.num_channels; - for (uint8_t i = 0; i < ch; ++i) { - const uint8_t id = capture_config_.channel_ids[i]; - frame[i] = (id < var_count_ && vars_[id].getter) ? vars_[id].getter() : 0.0F; + std::array frame{}; + for (uint8_t i = 0U; i < capture_config_.num_channels; ++i) { + frame[i] = Read(capture_config_.channel_ids[i]); } buffer_.WriteFrame(frame); - return buffer_.IsComplete(); -} - -void Device::ForceTrigger() { - if (buffer_.IsArmed()) { - buffer_.Trigger(); - trigger_.Disarm(); + if (buffer_.IsComplete()) { + state_.store(CaptureState::kComplete, std::memory_order_release); + return true; } + return false; } } // namespace cymon diff --git a/src/cymon/sample_buffer.cpp b/src/cymon/sample_buffer.cpp index 3068b29..4fb911f 100644 --- a/src/cymon/sample_buffer.cpp +++ b/src/cymon/sample_buffer.cpp @@ -1,173 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/// @file sample_buffer.cpp +/// @brief Ring buffer of sample frames with pre- and post-trigger depth. #include "cymon/sample_buffer.hpp" - #include -#include -#include namespace cymon { -bool SampleBuffer::Setup(uint8_t num_channels, uint16_t num_samples, uint16_t pretrigger_samples) { - if (num_channels == 0) { - return false; - } - if (num_channels > kMaxChannels) { - return false; - } - if (pretrigger_samples > num_samples) { - return false; - } - - const uint16_t cap = static_cast(kCapacityFloats / num_channels); - if (num_samples > cap) { +bool SampleBuffer::Setup(uint8_t num_channels, uint16_t num_samples, uint16_t pretrigger_samples) noexcept { + if (num_channels == 0U || num_channels > kMaxChannels) { return false; } - if (num_samples == 0) { + const auto kCapacity = static_cast(kCapacityFloats / num_channels); + if (num_samples == 0U || num_samples > kCapacity || pretrigger_samples >= num_samples) { return false; } num_channels_ = num_channels; num_samples_ = num_samples; pretrigger_samples_ = pretrigger_samples; - capacity_ = cap; + capacity_ = kCapacity; configured_ = true; - armed_ = false; - triggered_ = false; - complete_ = false; - write_pos_ = 0; - trigger_pos_ = 0; - post_trigger_count_ = 0; - total_written_ = 0; + Disarm(); return true; } -void SampleBuffer::Arm() { - write_pos_ = 0; - trigger_pos_ = 0; - post_trigger_count_ = 0; - total_written_ = 0; - written_before_trigger_ = 0; - armed_ = true; +void SampleBuffer::Arm() noexcept { + if (!configured_) { + return; + } + write_pos_ = 0U; + trigger_pos_ = 0U; + post_trigger_count_ = 0U; + written_before_trigger_ = 0U; triggered_ = false; complete_ = false; + armed_ = true; } -void SampleBuffer::Disarm() { +void SampleBuffer::Disarm() noexcept { armed_ = false; triggered_ = false; complete_ = false; } -bool SampleBuffer::WriteFrame(const float* values) { - if (!armed_ || complete_) { +bool SampleBuffer::WriteFrame(std::span values) noexcept { + if (!armed_ || complete_ || values.size() < num_channels_) { return false; } - const std::size_t offset = static_cast(write_pos_) * num_channels_; - for (uint8_t i = 0; i < num_channels_; ++i) { - data_[offset + i] = values[i]; - } - + const std::size_t kOffset = static_cast(write_pos_) * num_channels_; + std::copy_n(values.begin(), num_channels_, data_.begin() + static_cast(kOffset)); write_pos_ = static_cast((write_pos_ + 1U) % capacity_); - if (total_written_ < std::numeric_limits::max()) { - ++total_written_; - } - if (triggered_) { ++post_trigger_count_; - const uint16_t post_needed = static_cast(num_samples_ - pretrigger_samples_); - if (post_trigger_count_ >= post_needed) { + if (post_trigger_count_ >= num_samples_ - pretrigger_samples_) { complete_ = true; } + } else if (written_before_trigger_ < pretrigger_samples_) { + ++written_before_trigger_; } - return true; } -bool SampleBuffer::Trigger() { +bool SampleBuffer::Trigger() noexcept { if (!armed_ || triggered_) { return false; } - triggered_ = true; trigger_pos_ = write_pos_; - written_before_trigger_ = total_written_; - post_trigger_count_ = 0; - - if (pretrigger_samples_ >= num_samples_) { - complete_ = true; - } - + post_trigger_count_ = 0U; return true; } -uint16_t SampleBuffer::ReadFrames(uint16_t frame_offset, uint8_t max_frames, float* out) const { - if (!triggered_) { - return 0; +void SampleBuffer::CopyFrame(uint16_t ring_pos, std::span dest) const noexcept { + const std::size_t kOffset = static_cast(ring_pos) * num_channels_; + std::copy_n(data_.begin() + static_cast(kOffset), num_channels_, dest.begin()); +} + +uint16_t SampleBuffer::ReadFrames(uint16_t frame_offset, + uint16_t max_frames, + std::span out) const noexcept { + if (!triggered_ || num_channels_ == 0U) { + return 0U; } + const std::size_t kFit = out.size() / num_channels_; + const auto kLimit = static_cast(std::min(max_frames, kFit)); - uint16_t n = 0; - for (uint16_t i = 0; i < max_frames; ++i) { - const uint16_t abs_k = static_cast(frame_offset + i); - if (abs_k >= num_samples_) { + uint16_t copied = 0U; + for (; copied < kLimit; ++copied) { + const auto kFrame = static_cast(frame_offset) + copied; + if (kFrame >= num_samples_) { break; } - - float* dest = out + static_cast(n) * num_channels_; - - if (abs_k < pretrigger_samples_) { - // Pre-trigger frame: check if enough data was written before trigger. - const uint16_t frames_before = static_cast(pretrigger_samples_ - abs_k); - if (written_before_trigger_ < frames_before) { - // Not enough pre-trigger data; fill zeros. - for (uint8_t c = 0; c < num_channels_; ++c) { - dest[c] = 0.0F; - } + const std::span kDest = + out.subspan(static_cast(copied) * num_channels_, num_channels_); + + if (kFrame < pretrigger_samples_) { + // The ring holds written_before_trigger_ frames directly before the trigger sample. + const uint32_t kAge = pretrigger_samples_ - kFrame; + if (kAge > written_before_trigger_) { + std::fill(kDest.begin(), kDest.end(), 0.0F); } else { - const uint16_t ring_pos = - static_cast((static_cast(trigger_pos_) + capacity_ - pretrigger_samples_ + abs_k) % capacity_); - const std::size_t src_offset = static_cast(ring_pos) * num_channels_; - for (uint8_t c = 0; c < num_channels_; ++c) { - dest[c] = data_[src_offset + c]; - } + CopyFrame(static_cast((trigger_pos_ + capacity_ - kAge) % capacity_), kDest); } } else { - // Post-trigger frame. - const uint16_t post_idx = static_cast(abs_k - pretrigger_samples_); - if (post_idx >= post_trigger_count_) { + const uint32_t kPostIndex = kFrame - pretrigger_samples_; + if (kPostIndex >= post_trigger_count_) { break; } - - const uint16_t ring_pos = static_cast((static_cast(trigger_pos_) + post_idx) % capacity_); - const std::size_t src_offset = static_cast(ring_pos) * num_channels_; - for (uint8_t c = 0; c < num_channels_; ++c) { - dest[c] = data_[src_offset + c]; - } + CopyFrame(static_cast((trigger_pos_ + kPostIndex) % capacity_), kDest); } - ++n; } - return n; + return copied; } -bool SampleBuffer::IsComplete() const { return complete_; } -bool SampleBuffer::IsArmed() const { return armed_; } -bool SampleBuffer::WasTriggered() const { return triggered_; } -uint8_t SampleBuffer::num_channels() const { return num_channels_; } -uint16_t SampleBuffer::num_samples() const { return num_samples_; } -uint16_t SampleBuffer::capacity_frames() const { return capacity_; } +bool SampleBuffer::IsComplete() const noexcept { return complete_; } +bool SampleBuffer::IsArmed() const noexcept { return armed_; } +bool SampleBuffer::WasTriggered() const noexcept { return triggered_; } +bool SampleBuffer::PretriggerFilled() const noexcept { + return written_before_trigger_ >= pretrigger_samples_; +} +uint8_t SampleBuffer::NumChannels() const noexcept { return num_channels_; } +uint16_t SampleBuffer::NumSamples() const noexcept { return num_samples_; } +uint16_t SampleBuffer::PretriggerSamples() const noexcept { return pretrigger_samples_; } +uint16_t SampleBuffer::CapacityFrames() const noexcept { return capacity_; } } // namespace cymon diff --git a/src/cymon/trigger.cpp b/src/cymon/trigger.cpp index dae5943..448444e 100644 --- a/src/cymon/trigger.cpp +++ b/src/cymon/trigger.cpp @@ -1,38 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic // -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +/// @file trigger.cpp +/// @brief Edge and hysteresis trigger of a capture. #include "cymon/trigger.hpp" namespace cymon { -void Trigger::Configure(const TriggerConfig& config) { config_ = config; } +void Trigger::Configure(const TriggerConfig& config) noexcept { config_ = config; } -void Trigger::Arm(float initial_value) { +void Trigger::Arm(float initial_value) noexcept { armed_ = true; - if (config_.mode == TriggerMode::kHysteresis) { - awaiting_low_ = (initial_value >= config_.level); - } else { - awaiting_low_ = false; - } + awaiting_low_ = (config_.mode == TriggerMode::kHysteresis) && (initial_value >= config_.level); } -void Trigger::Disarm() { +void Trigger::Disarm() noexcept { armed_ = false; awaiting_low_ = false; } -bool Trigger::Evaluate(float prev_value, float current_value) { +bool Trigger::Evaluate(float prev_value, float current_value) noexcept { if (!armed_) { return false; } @@ -45,19 +33,18 @@ bool Trigger::Evaluate(float prev_value, float current_value) { case TriggerMode::kFalling: fired = (prev_value > config_.level) && (current_value <= config_.level); break; - case TriggerMode::kHysteresis: { - const float lower = config_.level - config_.hysteresis_band; + case TriggerMode::kHysteresis: if (awaiting_low_) { - if (current_value < lower) { + if (current_value < config_.level - config_.hysteresis_band) { awaiting_low_ = false; } } else { - if ((prev_value < config_.level) && (current_value >= config_.level)) { - fired = true; - } + fired = (prev_value < config_.level) && (current_value >= config_.level); } break; - } + case TriggerMode::kImmediate: + fired = true; + break; } if (fired) { @@ -66,6 +53,6 @@ bool Trigger::Evaluate(float prev_value, float current_value) { return fired; } -bool Trigger::IsArmed() const { return armed_; } +bool Trigger::IsArmed() const noexcept { return armed_; } } // namespace cymon diff --git a/tests/test_device.cpp b/tests/test_device.cpp index a383025..a7ada04 100644 --- a/tests/test_device.cpp +++ b/tests/test_device.cpp @@ -1,308 +1,236 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. #include - +#include +#include #include "cymon/device.hpp" namespace cymon { namespace { -TEST(DeviceTest, RegisterVariable_StoresVariable) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("speed", "rpm", [] { return 42.0F; })); - EXPECT_EQ(dev.variable_count(), 1U); - - Device::VariableInfo info; - ASSERT_TRUE(dev.GetVariable(0, &info)); - EXPECT_STREQ(info.name, "speed"); - EXPECT_STREQ(info.unit, "rpm"); - EXPECT_EQ(info.id, 0U); -} - -TEST(DeviceTest, RegisterVariable_TooManyVars_ReturnsFalse) { - Device dev; - for (std::size_t i = 0; i < kMaxVars; ++i) { - ASSERT_TRUE(dev.RegisterVariable("v", "u", [] { return 0.0F; })); +class DeviceTest : public ::testing::Test { + protected: + void SetUp() override { + ASSERT_TRUE(device_.RegisterVariable("i_q", "A", &i_q_)); + ASSERT_TRUE(device_.RegisterVariable("speed", "rad/s", &speed_)); } - EXPECT_FALSE(dev.RegisterVariable("overflow", "u", [] { return 0.0F; })); -} -TEST(DeviceTest, GetVariable_OutOfRange_ReturnsFalse) { - Device dev; - Device::VariableInfo info; - EXPECT_FALSE(dev.GetVariable(0, &info)); -} - -TEST(DeviceTest, GetVariable_WalksFullList) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("a", "u", [] { return 0.0F; })); - ASSERT_TRUE(dev.RegisterVariable("b", "u", [] { return 1.0F; })); - ASSERT_TRUE(dev.RegisterVariable("c", "u", [] { return 2.0F; })); - - Device::VariableInfo info; - EXPECT_TRUE(dev.GetVariable(0, &info)); - EXPECT_TRUE(dev.GetVariable(1, &info)); - EXPECT_TRUE(dev.GetVariable(2, &info)); - EXPECT_FALSE(dev.GetVariable(3, &info)); -} + Device::SetupCaptureResult Setup(uint8_t num_channels, + uint16_t num_samples, + uint16_t pretrigger, + float period_us = 100.0F) { + Device::CaptureConfig config; + for (uint8_t i = 0; i < num_channels; ++i) { + config.channel_ids[i] = i; + } + config.num_channels = num_channels; + config.num_samples = num_samples; + config.pretrigger_samples = pretrigger; + config.sample_period_us = period_us; + return device_.HandleSetupCapture(config); + } -TEST(DeviceTest, HandleGetBufferInfo_ReturnsCapacity) { - Device dev; - const auto resp = dev.HandleGetBufferInfo(); - EXPECT_EQ(resp.buffer_bytes, static_cast(kMaxBufferBytes)); - EXPECT_EQ(resp.max_channels, static_cast(kMaxChannels)); - EXPECT_GT(resp.max_frames, 0U); -} + ErrorCode Arm(TriggerMode mode, float level = 0.0F, uint8_t source = 0U) { + return device_.HandleArmTrigger( + {.arm = true, .source_variable_id = source, .mode = mode, .level = level, .hysteresis_band = 0.0F}); + } -TEST(DeviceTest, HandleSetupCapture_ValidConfig_ReturnsOk) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [] { return 0.0F; })); - ASSERT_TRUE(dev.RegisterVariable("v1", "u", [] { return 1.0F; })); + std::vector ReadAll() { + std::vector samples; + uint16_t offset = 0U; + for (;;) { + const auto result = device_.HandleReadSamples(offset, 255U); + EXPECT_EQ(result.state, CaptureState::kComplete); + samples.insert(samples.end(), result.Samples().begin(), result.Samples().end()); + offset = static_cast(offset + result.sample_count / result.num_channels); + if (result.end_of_data || result.sample_count == 0U) { + return samples; + } + } + } - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 0; - cfg.channel_ids[1] = 1; - cfg.num_channels = 2; - cfg.num_samples = 10; - cfg.pretrigger_samples = 2; - cfg.sample_period_us = 100.0F; + float i_q_{0.0F}; + float speed_{0.0F}; + Device device_; +}; - const auto resp = dev.HandleSetupCapture(cfg); - EXPECT_TRUE(resp.ok); - EXPECT_EQ(resp.error_code, Device::ErrorCode::kNone); +TEST_F(DeviceTest, RegistryListsVariablesInOrder) { + EXPECT_EQ(device_.VariableCount(), 2U); + EXPECT_EQ(device_.GetVariable(1).name, "speed"); + EXPECT_EQ(device_.GetVariable(1).unit, "rad/s"); + EXPECT_TRUE(device_.GetVariable(2).name.empty()); } -TEST(DeviceTest, HandleSetupCapture_BadChannelId_ReturnsError) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [] { return 0.0F; })); - - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 5; // doesn't exist - cfg.num_channels = 1; - cfg.num_samples = 10; - cfg.pretrigger_samples = 0; - cfg.sample_period_us = 100.0F; - - const auto resp = dev.HandleSetupCapture(cfg); - EXPECT_FALSE(resp.ok); - EXPECT_EQ(resp.error_code, Device::ErrorCode::kBadChannelId); +TEST_F(DeviceTest, RegistryRejectsBadVariables) { + const float source = 0.0F; + EXPECT_FALSE(device_.RegisterVariable("", "A", &source)); + EXPECT_FALSE(device_.RegisterVariable("x", "A", nullptr)); + EXPECT_FALSE(device_.RegisterVariable("abcdefghijklmnopqrstuvwxyz0123456", "A", &source)); + EXPECT_FALSE(device_.RegisterVariable("x", "123456789", &source)); } -TEST(DeviceTest, HandleSetupCapture_ZeroChannels_ReturnsBadConfig) { - Device dev; - Device::CaptureConfig cfg; - cfg.num_channels = 0; - cfg.num_samples = 10; - - const auto resp = dev.HandleSetupCapture(cfg); - EXPECT_FALSE(resp.ok); - EXPECT_EQ(resp.error_code, Device::ErrorCode::kBadConfig); +TEST(DeviceRegistryTest, RejectsWhenFull) { + Device device; + const float source = 0.0F; + for (std::size_t i = 0; i < kMaxVars; ++i) { + ASSERT_TRUE(device.RegisterVariable("v", "", &source)); + } + EXPECT_FALSE(device.RegisterVariable("overflow", "", &source)); } -TEST(DeviceTest, HandleSetupCapture_TooManyChannels_ReturnsBadConfig) { - Device dev; - Device::CaptureConfig cfg; - cfg.num_channels = static_cast(kMaxChannels) + 1U; - cfg.num_samples = 10; - - const auto resp = dev.HandleSetupCapture(cfg); - EXPECT_FALSE(resp.ok); - EXPECT_EQ(resp.error_code, Device::ErrorCode::kBadConfig); +TEST_F(DeviceTest, BufferInfoReportsLimits) { + device_.SetTickPeriod(62.5F); + const auto info = device_.HandleGetBufferInfo(); + EXPECT_EQ(info.buffer_bytes, kMaxBufferBytes); + EXPECT_EQ(info.max_channels, kMaxChannels); + EXPECT_EQ(info.max_frames, kMaxBufferBytes / sizeof(float)); + EXPECT_EQ(info.max_read_samples, kMaxReadSamples); + EXPECT_FLOAT_EQ(info.tick_period_us, 62.5F); } -TEST(DeviceTest, HandleSetupCapture_ActualPeriodReturned) { - Device dev; - dev.set_actual_sample_period_us(100.0F); - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [] { return 0.0F; })); - - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 0; - cfg.num_channels = 1; - cfg.num_samples = 10; - cfg.pretrigger_samples = 0; - cfg.sample_period_us = 50.0F; - - const auto resp = dev.HandleSetupCapture(cfg); - EXPECT_TRUE(resp.ok); - EXPECT_FLOAT_EQ(resp.actual_sample_period_us, 100.0F); +TEST_F(DeviceTest, SetupValidatesRequest) { + EXPECT_EQ(Setup(0, 10, 0).error, ErrorCode::kBadConfig); + EXPECT_EQ(Setup(static_cast(kMaxChannels + 1U), 10, 0).error, ErrorCode::kBadConfig); + EXPECT_EQ(Setup(1, 10, 10).error, ErrorCode::kBadConfig); + EXPECT_EQ(Setup(3, 10, 0).error, ErrorCode::kBadChannelId); // only two variables + EXPECT_EQ(Setup(1, 60000, 0).error, ErrorCode::kBufferTooSmall); + EXPECT_EQ(device_.State(), CaptureState::kIdle); + EXPECT_EQ(Setup(2, 10, 2).error, ErrorCode::kNone); + EXPECT_EQ(device_.State(), CaptureState::kConfigured); } -TEST(DeviceTest, HandleSetupCapture_DefaultsToRequestedPeriod) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [] { return 0.0F; })); - - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 0; - cfg.num_channels = 1; - cfg.num_samples = 10; - cfg.pretrigger_samples = 0; - cfg.sample_period_us = 75.0F; - - const auto resp = dev.HandleSetupCapture(cfg); - EXPECT_TRUE(resp.ok); - EXPECT_FLOAT_EQ(resp.actual_sample_period_us, 75.0F); +TEST_F(DeviceTest, SamplePeriodRoundsToTicks) { + device_.SetTickPeriod(62.5F); + EXPECT_FLOAT_EQ(Setup(1, 10, 0, 200.0F).actual_sample_period_us, 187.5F); // 3.2 ticks -> 3 + EXPECT_FLOAT_EQ(Setup(1, 10, 0, 10.0F).actual_sample_period_us, 62.5F); // never faster than a tick } -TEST(DeviceTest, HandleArmTrigger_ValidConfig_ReturnsOk) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [] { return 0.0F; })); - - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 0; - cfg.num_channels = 1; - cfg.num_samples = 10; - cfg.pretrigger_samples = 0; - cfg.sample_period_us = 100.0F; - ASSERT_TRUE(dev.HandleSetupCapture(cfg).ok); - - Device::ArmTriggerConfig atcfg; - atcfg.arm = true; - atcfg.source_variable_id = 0; - atcfg.trigger_mode = TriggerMode::kRising; - atcfg.trigger_level = 1.0F; - - const auto resp = dev.HandleArmTrigger(atcfg); - EXPECT_TRUE(resp.ok); +TEST_F(DeviceTest, SamplePeriodWithoutTickPeriodEchoesRequest) { + EXPECT_FLOAT_EQ(Setup(1, 10, 0, 75.0F).actual_sample_period_us, 75.0F); } -TEST(DeviceTest, HandleArmTrigger_BadSourceVar_ReturnsError) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [] { return 0.0F; })); - - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 0; - cfg.num_channels = 1; - cfg.num_samples = 10; - cfg.pretrigger_samples = 0; - cfg.sample_period_us = 100.0F; - ASSERT_TRUE(dev.HandleSetupCapture(cfg).ok); - - Device::ArmTriggerConfig atcfg; - atcfg.arm = true; - atcfg.source_variable_id = 99; // invalid - const auto resp = dev.HandleArmTrigger(atcfg); - EXPECT_FALSE(resp.ok); - EXPECT_EQ(resp.error_code, Device::ErrorCode::kBadChannelId); +TEST_F(DeviceTest, ArmValidatesRequest) { + EXPECT_EQ(Arm(TriggerMode::kRising), ErrorCode::kNotConfigured); + ASSERT_EQ(Setup(1, 10, 0).error, ErrorCode::kNone); + EXPECT_EQ(Arm(TriggerMode::kRising, 0.0F, 99U), ErrorCode::kBadChannelId); + EXPECT_EQ(Arm(static_cast(7)), ErrorCode::kBadConfig); + EXPECT_EQ(Arm(TriggerMode::kImmediate, 0.0F, 99U), ErrorCode::kNone); // no source needed + EXPECT_EQ(device_.State(), CaptureState::kArmed); } -TEST(DeviceTest, HandleArmTrigger_Disarm_ReturnsOk) { - Device dev; - Device::ArmTriggerConfig atcfg; - atcfg.arm = false; - const auto resp = dev.HandleArmTrigger(atcfg); - EXPECT_TRUE(resp.ok); +TEST_F(DeviceTest, SetupWhileArmedIsRefused) { + ASSERT_EQ(Setup(1, 10, 0).error, ErrorCode::kNone); + ASSERT_EQ(Arm(TriggerMode::kRising, 1.0F), ErrorCode::kNone); + EXPECT_EQ(Setup(1, 10, 0).error, ErrorCode::kAlreadyCapturing); + EXPECT_EQ(device_.HandleArmTrigger({.arm = false}), ErrorCode::kNone); + EXPECT_EQ(device_.State(), CaptureState::kConfigured); + EXPECT_EQ(Setup(1, 10, 0).error, ErrorCode::kNone); } -TEST(DeviceTest, Tick_CapturesFrames) { - float val0 = 1.0F; - float val1 = 2.0F; - - Device dev; - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [&val0] { return val0; })); - ASSERT_TRUE(dev.RegisterVariable("v1", "u", [&val1] { return val1; })); - - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 0; - cfg.channel_ids[1] = 1; - cfg.num_channels = 2; - cfg.num_samples = 4; - cfg.pretrigger_samples = 0; - cfg.sample_period_us = 100.0F; - ASSERT_TRUE(dev.HandleSetupCapture(cfg).ok); - - // Arm with immediate force trigger (level very low, rising). - Device::ArmTriggerConfig atcfg; - atcfg.arm = true; - atcfg.source_variable_id = 0; - atcfg.trigger_mode = TriggerMode::kRising; - atcfg.trigger_level = 0.5F; - ASSERT_TRUE(dev.HandleArmTrigger(atcfg).ok); - - // Force trigger since value is already above level. - dev.ForceTrigger(); - - // Tick 4 times to fill post-trigger frames. +TEST_F(DeviceTest, ImmediateCaptureRecordsInterleavedFrames) { + ASSERT_EQ(Setup(2, 4, 0).error, ErrorCode::kNone); + ASSERT_EQ(Arm(TriggerMode::kImmediate), ErrorCode::kNone); for (int i = 0; i < 4; ++i) { - dev.Tick(); + i_q_ = static_cast(i); + speed_ = static_cast(10 * i); + EXPECT_EQ(device_.Tick(), i == 3); } - - const auto rs = dev.HandleReadSamples(0, 4); - EXPECT_TRUE(rs.triggered); - EXPECT_EQ(rs.num_channels, 2U); - // Samples should have val0=1.0, val1=2.0 interleaved. - EXPECT_FLOAT_EQ(rs.samples[0], 1.0F); - EXPECT_FLOAT_EQ(rs.samples[1], 2.0F); + EXPECT_EQ(device_.State(), CaptureState::kComplete); + const std::vector expected{0.0F, 0.0F, 1.0F, 10.0F, 2.0F, 20.0F, 3.0F, 30.0F}; + EXPECT_EQ(ReadAll(), expected); } -TEST(DeviceTest, ForceTrigger_TriggersCapture) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [] { return 0.0F; })); - - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 0; - cfg.num_channels = 1; - cfg.num_samples = 4; - cfg.pretrigger_samples = 0; - cfg.sample_period_us = 100.0F; - ASSERT_TRUE(dev.HandleSetupCapture(cfg).ok); - - Device::ArmTriggerConfig atcfg; - atcfg.arm = true; - atcfg.source_variable_id = 0; - ASSERT_TRUE(dev.HandleArmTrigger(atcfg).ok); - - dev.ForceTrigger(); - - // Fill post-trigger samples. - for (int i = 0; i < 4; ++i) dev.Tick(); - - const auto rs = dev.HandleReadSamples(0, 4); - EXPECT_TRUE(rs.triggered); -} - -TEST(DeviceTest, HandleReadSamples_EndOfData_PagingIsCorrect) { - Device dev; - ASSERT_TRUE(dev.RegisterVariable("v0", "u", [] { return 1.0F; })); - - Device::CaptureConfig cfg; - cfg.channel_ids[0] = 0; - cfg.num_channels = 1; - cfg.num_samples = 6; - cfg.pretrigger_samples = 0; - cfg.sample_period_us = 100.0F; - ASSERT_TRUE(dev.HandleSetupCapture(cfg).ok); - - Device::ArmTriggerConfig atcfg; - atcfg.arm = true; - atcfg.source_variable_id = 0; - ASSERT_TRUE(dev.HandleArmTrigger(atcfg).ok); - dev.ForceTrigger(); - - // Fill all 6 post-trigger frames so capture is complete. - for (int i = 0; i < 6; ++i) dev.Tick(); - - // First page (frames 0-2): NOT end of data. - auto rs0 = dev.HandleReadSamples(0, 3); - EXPECT_TRUE(rs0.triggered); - EXPECT_EQ(rs0.num_samples, 3U); - EXPECT_FALSE(rs0.end_of_data); - - // Second page (frames 3-5): IS end of data. - auto rs1 = dev.HandleReadSamples(3, 3); - EXPECT_EQ(rs1.num_samples, 3U); - EXPECT_TRUE(rs1.end_of_data); +TEST_F(DeviceTest, RisingEdgeCaptureWithPretrigger) { + ASSERT_EQ(Setup(1, 6, 2).error, ErrorCode::kNone); + ASSERT_EQ(Arm(TriggerMode::kRising, 4.5F), ErrorCode::kNone); + for (int i = 0; i < 20 && device_.State() != CaptureState::kComplete; ++i) { + i_q_ = static_cast(i); + device_.Tick(); + } + const std::vector expected{3.0F, 4.0F, 5.0F, 6.0F, 7.0F, 8.0F}; + EXPECT_EQ(ReadAll(), expected); + + Device::TriggerEvent event; + ASSERT_TRUE(device_.TakeTriggerEvent(&event)); + EXPECT_EQ(event.source_variable_id, 0U); + EXPECT_EQ(event.mode, TriggerMode::kRising); + EXPECT_FLOAT_EQ(event.trigger_value, 5.0F); + EXPECT_FALSE(device_.TakeTriggerEvent(&event)); +} + +TEST_F(DeviceTest, TriggerWaitsForPretriggerToFill) { + ASSERT_EQ(Setup(1, 4, 3).error, ErrorCode::kNone); + i_q_ = 1.0F; // condition true from the first sample on + ASSERT_EQ(Arm(TriggerMode::kImmediate), ErrorCode::kNone); + device_.Tick(); + device_.Tick(); + device_.Tick(); + EXPECT_EQ(device_.State(), CaptureState::kArmed); + device_.Tick(); + EXPECT_EQ(device_.State(), CaptureState::kComplete); +} + +TEST_F(DeviceTest, EdgeDuringPretriggerFillIsNotSeenLater) { + ASSERT_EQ(Setup(1, 4, 2).error, ErrorCode::kNone); + ASSERT_EQ(Arm(TriggerMode::kRising, 5.0F), ErrorCode::kNone); // armed at 0 + i_q_ = 10.0F; // crosses the level while the pre-trigger part fills + device_.Tick(); + device_.Tick(); + device_.Tick(); + EXPECT_EQ(device_.State(), CaptureState::kArmed); +} + +TEST_F(DeviceTest, RequestedTriggerFiresWithoutCondition) { + ASSERT_EQ(Setup(1, 2, 0).error, ErrorCode::kNone); + ASSERT_EQ(Arm(TriggerMode::kRising, 100.0F), ErrorCode::kNone); + device_.Tick(); + EXPECT_EQ(device_.State(), CaptureState::kArmed); + device_.RequestTrigger(); + device_.Tick(); + EXPECT_EQ(device_.State(), CaptureState::kTriggered); + device_.Tick(); + EXPECT_EQ(device_.State(), CaptureState::kComplete); +} + +TEST_F(DeviceTest, DecimationSkipsTicks) { + device_.SetTickPeriod(50.0F); + ASSERT_FLOAT_EQ(Setup(1, 3, 0, 150.0F).actual_sample_period_us, 150.0F); + ASSERT_EQ(Arm(TriggerMode::kImmediate), ErrorCode::kNone); + for (int i = 1; i <= 9; ++i) { + i_q_ = static_cast(i); + device_.Tick(); + } + const std::vector expected{3.0F, 6.0F, 9.0F}; + EXPECT_EQ(ReadAll(), expected); +} + +TEST_F(DeviceTest, ReadSamplesReportsStatusBeforeCompletion) { + EXPECT_EQ(device_.HandleReadSamples(0, 4).state, CaptureState::kIdle); + ASSERT_EQ(Setup(2, 4, 1).error, ErrorCode::kNone); + ASSERT_EQ(Arm(TriggerMode::kRising, 100.0F), ErrorCode::kNone); + device_.Tick(); + const auto result = device_.HandleReadSamples(0, 4); + EXPECT_EQ(result.state, CaptureState::kArmed); + EXPECT_EQ(result.num_channels, 2U); + EXPECT_EQ(result.num_samples, 4U); + EXPECT_EQ(result.pretrigger_samples, 1U); + EXPECT_EQ(result.sample_count, 0U); + EXPECT_FALSE(result.end_of_data); +} + +TEST_F(DeviceTest, ReadSamplesPagesUpToResponseLimit) { + ASSERT_EQ(Setup(2, 100, 0).error, ErrorCode::kNone); + ASSERT_EQ(Arm(TriggerMode::kImmediate), ErrorCode::kNone); + while (!device_.Tick()) { + } + const auto first = device_.HandleReadSamples(0, 255U); + EXPECT_EQ(first.sample_count, kMaxReadSamples); + EXPECT_FALSE(first.end_of_data); + const auto last = device_.HandleReadSamples(96, 255U); + EXPECT_EQ(last.sample_count, 8U); + EXPECT_TRUE(last.end_of_data); + EXPECT_EQ(device_.HandleReadSamples(0, 0U).sample_count, 0U); // status only } } // namespace diff --git a/tests/test_sample_buffer.cpp b/tests/test_sample_buffer.cpp index ed1b398..298efed 100644 --- a/tests/test_sample_buffer.cpp +++ b/tests/test_sample_buffer.cpp @@ -1,183 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. #include - +#include #include "cymon/sample_buffer.hpp" namespace cymon { namespace { -TEST(SampleBufferTest, Setup_ValidConfig_ReturnsTrue) { - SampleBuffer buf; - EXPECT_TRUE(buf.Setup(2, 100, 10)); -} - -TEST(SampleBufferTest, Setup_TooManyChannels_ReturnsFalse) { - SampleBuffer buf; - // kMaxChannels + 1 is used to exceed the limit; requires kMaxChannels < 255. - static_assert(kMaxChannels < 255U, "test requires kMaxChannels < 255"); - EXPECT_FALSE(buf.Setup(static_cast(kMaxChannels + 1U), 10, 0)); +void Write(SampleBuffer& buffer, float value) { + const std::array frame{value}; + buffer.WriteFrame(frame); } -TEST(SampleBufferTest, Setup_TooManySamples_ReturnsFalse) { - SampleBuffer buf; - // kCapacityFloats / 2 = 512; 513 should fail. - const uint16_t too_many = static_cast(SampleBuffer::kCapacityFloats / 2 + 1); - EXPECT_FALSE(buf.Setup(2, too_many, 0)); +TEST(SampleBufferTest, SetupValidatesLayout) { + SampleBuffer buffer; + EXPECT_TRUE(buffer.Setup(2, 100, 10)); + EXPECT_FALSE(buffer.Setup(0, 10, 0)); + EXPECT_FALSE(buffer.Setup(static_cast(kMaxChannels + 1U), 10, 0)); + EXPECT_FALSE(buffer.Setup(2, static_cast(SampleBuffer::kCapacityFloats / 2U + 1U), 0)); + EXPECT_FALSE(buffer.Setup(1, 10, 10)); // no post-trigger frame left + EXPECT_FALSE(buffer.Setup(1, 0, 0)); + EXPECT_EQ(buffer.CapacityFrames(), SampleBuffer::kCapacityFloats / 2U); // the valid layout stays } -TEST(SampleBufferTest, Setup_PretriggerExceedsSamples_ReturnsFalse) { - SampleBuffer buf; - EXPECT_FALSE(buf.Setup(1, 10, 11)); +TEST(SampleBufferTest, ArmRequiresSetup) { + SampleBuffer buffer; + buffer.Arm(); + EXPECT_FALSE(buffer.IsArmed()); + ASSERT_TRUE(buffer.Setup(1, 10, 0)); + buffer.Arm(); + EXPECT_TRUE(buffer.IsArmed()); } -TEST(SampleBufferTest, Arm_SetsArmedState) { - SampleBuffer buf; - ASSERT_TRUE(buf.Setup(1, 10, 0)); - buf.Arm(); - EXPECT_TRUE(buf.IsArmed()); +TEST(SampleBufferTest, CompletesAfterPostTriggerFrames) { + SampleBuffer buffer; + ASSERT_TRUE(buffer.Setup(1, 2, 0)); + buffer.Arm(); + EXPECT_TRUE(buffer.Trigger()); + EXPECT_FALSE(buffer.Trigger()); + Write(buffer, 1.0F); + EXPECT_FALSE(buffer.IsComplete()); + Write(buffer, 2.0F); + EXPECT_TRUE(buffer.IsComplete()); + const std::array frame{3.0F}; + EXPECT_FALSE(buffer.WriteFrame(frame)); } -TEST(SampleBufferTest, WriteFrame_StoresData) { - SampleBuffer buf; - ASSERT_TRUE(buf.Setup(1, 10, 0)); - buf.Arm(); - float v = 3.14F; - EXPECT_TRUE(buf.WriteFrame(&v)); -} - -TEST(SampleBufferTest, Trigger_TransitionsToTriggered) { - SampleBuffer buf; - ASSERT_TRUE(buf.Setup(1, 2, 0)); - buf.Arm(); - EXPECT_TRUE(buf.Trigger()); - EXPECT_TRUE(buf.WasTriggered()); -} - -TEST(SampleBufferTest, WriteFrame_AfterComplete_ReturnsFalse) { - SampleBuffer buf; - ASSERT_TRUE(buf.Setup(1, 2, 0)); - buf.Arm(); - buf.Trigger(); - float v = 1.0F; - buf.WriteFrame(&v); - buf.WriteFrame(&v); - EXPECT_TRUE(buf.IsComplete()); - EXPECT_FALSE(buf.WriteFrame(&v)); -} - -TEST(SampleBufferTest, ReadFrames_PostTriggerData_CorrectValues) { - SampleBuffer buf; - // 2 pretrigger + 5 post-trigger = 7 total, 1 channel - ASSERT_TRUE(buf.Setup(1, 7, 2)); - buf.Arm(); - - // Write 5 pre-trigger frames (values 10..14) +TEST(SampleBufferTest, ReadsPreAndPostTriggerInTimeOrder) { + SampleBuffer buffer; + ASSERT_TRUE(buffer.Setup(1, 7, 2)); + buffer.Arm(); for (int i = 0; i < 5; ++i) { - float v = static_cast(10 + i); - buf.WriteFrame(&v); + Write(buffer, static_cast(10 + i)); } - - buf.Trigger(); - - // Write 5 post-trigger frames (values 20..24) + EXPECT_TRUE(buffer.PretriggerFilled()); + buffer.Trigger(); for (int i = 0; i < 5; ++i) { - float v = static_cast(20 + i); - buf.WriteFrame(&v); + Write(buffer, static_cast(20 + i)); } + ASSERT_TRUE(buffer.IsComplete()); - EXPECT_TRUE(buf.IsComplete()); - - // Read all 7 frames starting at offset 0. - float out[7] = {}; - const uint16_t n = buf.ReadFrames(0, 7, out); - EXPECT_EQ(n, 7U); + std::array out{}; + ASSERT_EQ(buffer.ReadFrames(0, 7, out), 7U); + const std::array expected{13.0F, 14.0F, 20.0F, 21.0F, 22.0F, 23.0F, 24.0F}; + EXPECT_EQ(out, expected); +} - // Pre-trigger frames: the 2 frames just before trigger = values 13, 14 - EXPECT_FLOAT_EQ(out[0], 13.0F); - EXPECT_FLOAT_EQ(out[1], 14.0F); +TEST(SampleBufferTest, ReadsAcrossRingWrap) { + SampleBuffer buffer; + const auto capacity = static_cast(SampleBuffer::kCapacityFloats); + ASSERT_TRUE(buffer.Setup(1, capacity, static_cast(capacity / 2U))); + buffer.Arm(); + for (uint32_t i = 0; i < capacity + 17U; ++i) { + Write(buffer, static_cast(i)); + } + buffer.Trigger(); + for (uint32_t i = 0; i < capacity - capacity / 2U; ++i) { + Write(buffer, static_cast(100000U + i)); + } + ASSERT_TRUE(buffer.IsComplete()); - // Post-trigger frames: 20, 21, 22, 23, 24 - EXPECT_FLOAT_EQ(out[2], 20.0F); - EXPECT_FLOAT_EQ(out[3], 21.0F); - EXPECT_FLOAT_EQ(out[4], 22.0F); - EXPECT_FLOAT_EQ(out[5], 23.0F); - EXPECT_FLOAT_EQ(out[6], 24.0F); + std::array out{}; + ASSERT_EQ(buffer.ReadFrames(static_cast(capacity / 2U - 1U), 2, out), 2U); + EXPECT_FLOAT_EQ(out[0], static_cast(capacity + 16U)); // last pre-trigger frame + EXPECT_FLOAT_EQ(out[1], 100000.0F); // trigger sample } -TEST(SampleBufferTest, ReadFrames_BeforeTrigger_ReturnsZero) { - SampleBuffer buf; - ASSERT_TRUE(buf.Setup(1, 10, 5)); - buf.Arm(); - float v = 1.0F; - buf.WriteFrame(&v); - - float out[10] = {}; - EXPECT_EQ(buf.ReadFrames(0, 10, out), 0U); +TEST(SampleBufferTest, InterleavesChannelsAndLimitsToOutput) { + SampleBuffer buffer; + ASSERT_TRUE(buffer.Setup(2, 3, 0)); + buffer.Arm(); + buffer.Trigger(); + for (int i = 0; i < 3; ++i) { + const std::array frame{static_cast(i), static_cast(10 * i)}; + buffer.WriteFrame(frame); + } + std::array out{}; // room for two frames only + ASSERT_EQ(buffer.ReadFrames(1, 3, out), 2U); + EXPECT_FLOAT_EQ(out[0], 1.0F); + EXPECT_FLOAT_EQ(out[1], 10.0F); + EXPECT_FLOAT_EQ(out[2], 2.0F); + EXPECT_FLOAT_EQ(out[3], 20.0F); } -TEST(SampleBufferTest, ReadFrames_BeyondWritten_ReturnsAvailable) { - SampleBuffer buf; - // 0 pretrigger, 10 post-trigger frames, 1 channel - ASSERT_TRUE(buf.Setup(1, 10, 0)); - buf.Arm(); - buf.Trigger(); +TEST(SampleBufferTest, NothingToReadBeforeTrigger) { + SampleBuffer buffer; + ASSERT_TRUE(buffer.Setup(1, 10, 5)); + buffer.Arm(); + Write(buffer, 1.0F); + std::array out{}; + EXPECT_EQ(buffer.ReadFrames(0, 10, out), 0U); + EXPECT_FALSE(buffer.PretriggerFilled()); +} - // Write only 3 post-trigger frames +TEST(SampleBufferTest, ReadsOnlyWrittenPostTriggerFrames) { + SampleBuffer buffer; + ASSERT_TRUE(buffer.Setup(1, 10, 0)); + buffer.Arm(); + buffer.Trigger(); for (int i = 0; i < 3; ++i) { - float v = static_cast(i); - buf.WriteFrame(&v); + Write(buffer, static_cast(i)); } - - float out[10] = {}; - // Request 10, but only 3 are available. - const uint16_t n = buf.ReadFrames(0, 10, out); - EXPECT_EQ(n, 3U); + std::array out{}; + EXPECT_EQ(buffer.ReadFrames(0, 10, out), 3U); } -TEST(SampleBufferTest, ReadFrames_EarlyTrigger_PreTriggerZeroFilled) { - SampleBuffer buf; - // 3 pretrigger + 2 post-trigger = 5 total, 1 channel - ASSERT_TRUE(buf.Setup(1, 5, 3)); - buf.Arm(); - - // Write only 1 frame before trigger (need 3 for full pretrigger). - float pre = 99.0F; - buf.WriteFrame(&pre); - - buf.Trigger(); - - // Write 2 post-trigger frames so capture completes. - float post = 42.0F; - buf.WriteFrame(&post); - buf.WriteFrame(&post); - EXPECT_TRUE(buf.IsComplete()); - - float out[5] = {}; - const uint16_t n = buf.ReadFrames(0, 5, out); - EXPECT_EQ(n, 5U); - - // Frames 0 and 1 need 3 and 2 frames before trigger respectively; - // only 1 was written, so both must be zero-filled. - EXPECT_FLOAT_EQ(out[0], 0.0F); // abs_k=0: frames_before=3 > written_before=1 → zero - EXPECT_FLOAT_EQ(out[1], 0.0F); // abs_k=1: frames_before=2 > written_before=1 → zero - // abs_k=2: frames_before=1 <= written_before=1 → real data (the one frame written) - EXPECT_FLOAT_EQ(out[2], 99.0F); - // Post-trigger frames - EXPECT_FLOAT_EQ(out[3], 42.0F); - EXPECT_FLOAT_EQ(out[4], 42.0F); +TEST(SampleBufferTest, EarlyTriggerZeroFillsMissingPretrigger) { + SampleBuffer buffer; + ASSERT_TRUE(buffer.Setup(1, 5, 3)); + buffer.Arm(); + Write(buffer, 99.0F); + buffer.Trigger(); + Write(buffer, 42.0F); + Write(buffer, 43.0F); + ASSERT_TRUE(buffer.IsComplete()); + + std::array out{}; + ASSERT_EQ(buffer.ReadFrames(0, 5, out), 5U); + const std::array expected{0.0F, 0.0F, 99.0F, 42.0F, 43.0F}; + EXPECT_EQ(out, expected); } } // namespace diff --git a/tests/test_trigger.cpp b/tests/test_trigger.cpp index 835d268..5d684e2 100644 --- a/tests/test_trigger.cpp +++ b/tests/test_trigger.cpp @@ -1,100 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Tecnologic -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. #include - #include "cymon/trigger.hpp" namespace cymon { namespace { -TEST(TriggerTest, RisingEdge_FiresOnCrossing) { - Trigger t; - t.Configure({TriggerMode::kRising, 1.0F, 0.0F}); - t.Arm(0.0F); - EXPECT_TRUE(t.Evaluate(0.5F, 1.5F)); +TEST(TriggerTest, RisingEdgeFiresOnCrossing) { + Trigger trigger; + trigger.Configure({.mode = TriggerMode::kRising, .level = 1.0F}); + trigger.Arm(0.0F); + EXPECT_FALSE(trigger.Evaluate(0.2F, 0.9F)); + EXPECT_TRUE(trigger.Evaluate(0.5F, 1.5F)); + EXPECT_FALSE(trigger.IsArmed()); } -TEST(TriggerTest, RisingEdge_NoFireBelowLevel) { - Trigger t; - t.Configure({TriggerMode::kRising, 1.0F, 0.0F}); - t.Arm(0.0F); - EXPECT_FALSE(t.Evaluate(0.2F, 0.9F)); +TEST(TriggerTest, FallingEdgeFiresOnCrossing) { + Trigger trigger; + trigger.Configure({.mode = TriggerMode::kFalling, .level = 1.0F}); + trigger.Arm(2.0F); + EXPECT_FALSE(trigger.Evaluate(1.5F, 1.2F)); + EXPECT_TRUE(trigger.Evaluate(1.5F, 0.5F)); } -TEST(TriggerTest, FallingEdge_FiresOnCrossing) { - Trigger t; - t.Configure({TriggerMode::kFalling, 1.0F, 0.0F}); - t.Arm(2.0F); - EXPECT_TRUE(t.Evaluate(1.5F, 0.5F)); +TEST(TriggerTest, HysteresisFiresOnlyAfterDropBelowBand) { + Trigger trigger; + trigger.Configure({.mode = TriggerMode::kHysteresis, .level = 1.0F, .hysteresis_band = 0.5F}); + trigger.Arm(2.0F); // starts above the level: waits for a drop below 0.5 + EXPECT_FALSE(trigger.Evaluate(2.0F, 0.6F)); + EXPECT_FALSE(trigger.Evaluate(0.6F, 1.2F)); // rising, but not re-armed yet + EXPECT_FALSE(trigger.Evaluate(1.2F, 0.4F)); + EXPECT_TRUE(trigger.Evaluate(0.4F, 1.1F)); + EXPECT_FALSE(trigger.IsArmed()); } -TEST(TriggerTest, FallingEdge_NoFireAboveLevel) { - Trigger t; - t.Configure({TriggerMode::kFalling, 1.0F, 0.0F}); - t.Arm(2.0F); - EXPECT_FALSE(t.Evaluate(1.5F, 1.2F)); +TEST(TriggerTest, ImmediateFiresOnFirstEvaluation) { + Trigger trigger; + trigger.Configure({.mode = TriggerMode::kImmediate}); + trigger.Arm(); + EXPECT_TRUE(trigger.Evaluate(0.0F, 0.0F)); } -TEST(TriggerTest, Hysteresis_FiresAfterDropAndRise) { - Trigger t; - // level=1.0, band=0.5 → lower=0.5 - t.Configure({TriggerMode::kHysteresis, 1.0F, 0.5F}); - // Arm with value above level → awaiting_low_=true - t.Arm(2.0F); - EXPECT_TRUE(t.IsArmed()); - - // Still above lower threshold — should not fire - EXPECT_FALSE(t.Evaluate(2.0F, 0.6F)); - EXPECT_TRUE(t.IsArmed()); - - // Drop below lower threshold (0.5) - EXPECT_FALSE(t.Evaluate(0.6F, 0.4F)); - EXPECT_TRUE(t.IsArmed()); - - // Rise above level (1.0) → fires - EXPECT_TRUE(t.Evaluate(0.4F, 1.1F)); - EXPECT_FALSE(t.IsArmed()); +TEST(TriggerTest, DisarmedNeverFires) { + Trigger trigger; + trigger.Configure({.mode = TriggerMode::kRising, .level = 1.0F}); + trigger.Arm(0.0F); + trigger.Disarm(); + EXPECT_FALSE(trigger.Evaluate(0.5F, 1.5F)); } -TEST(TriggerTest, Hysteresis_NoFireIfAlreadyHigh) { - Trigger t; - t.Configure({TriggerMode::kHysteresis, 1.0F, 0.5F}); - t.Arm(2.0F); // awaiting_low_=true - // Stays above level without dropping below lower - EXPECT_FALSE(t.Evaluate(1.5F, 1.2F)); - EXPECT_FALSE(t.Evaluate(1.2F, 1.8F)); - EXPECT_TRUE(t.IsArmed()); +TEST(TriggerTest, RearmFiresAgain) { + Trigger trigger; + trigger.Configure({.mode = TriggerMode::kRising, .level = 1.0F}); + trigger.Arm(0.0F); + EXPECT_TRUE(trigger.Evaluate(0.5F, 1.5F)); + EXPECT_FALSE(trigger.Evaluate(0.5F, 1.5F)); + trigger.Arm(0.0F); + EXPECT_TRUE(trigger.Evaluate(0.0F, 2.0F)); } -TEST(TriggerTest, Disarm_PreventsEvaluation) { - Trigger t; - t.Configure({TriggerMode::kRising, 1.0F, 0.0F}); - t.Arm(0.0F); - t.Disarm(); - EXPECT_FALSE(t.Evaluate(0.5F, 1.5F)); -} - -TEST(TriggerTest, Rearm_FiresAgain) { - Trigger t; - t.Configure({TriggerMode::kRising, 1.0F, 0.0F}); - t.Arm(0.0F); - EXPECT_TRUE(t.Evaluate(0.5F, 1.5F)); - EXPECT_FALSE(t.IsArmed()); - - t.Arm(0.0F); - EXPECT_TRUE(t.Evaluate(0.0F, 2.0F)); +TEST(TriggerTest, ValidModes) { + EXPECT_TRUE(IsValidTriggerMode(3U)); + EXPECT_FALSE(IsValidTriggerMode(4U)); } } // namespace From 53b4a6502db1604956d8f51f5f8f11f28c908de8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:57:08 +0000 Subject: [PATCH 2/2] CI: run on pull requests only, not on push Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AKjxMYUjfvU5kDumYNNQY8 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4bc686..cb05429 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,8 @@ name: CI on: - push: - branches: [main, develop, "claude/**", "copilot/**"] + # Pull requests only; a push to a branch runs nothing. pull_request: + workflow_dispatch: jobs: build-and-test: