diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..792ef9b --- /dev/null +++ b/.clang-format @@ -0,0 +1,3 @@ +--- +BasedOnStyle: Google +ColumnLimit: 140 diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..ca7d9b2 --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,29 @@ +--- +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 + +WarningsAsErrors: '' +HeaderFilterRegex: 'include/cymon/.*' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0702785 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI +on: + push: + branches: [main, "copilot/**"] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + sudo apt-get update && sudo apt-get install -y \ + cmake ninja-build clang clang-format clang-tidy + + - name: Configure + run: | + cmake -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON + + - name: Build + 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/ diff --git a/.gitignore b/.gitignore index 0ba1c63..944df72 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ vcpkg_installed/ # test output & cache Testing/ .cache/ + +# nunavut generated headers +generated/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..8929669 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,43 @@ +# 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. + +cmake_minimum_required(VERSION 3.20) +project(cymon-lib VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# Library +add_library(cymon + src/cymon/trigger.cpp + src/cymon/sample_buffer.cpp + src/cymon/device.cpp +) +target_include_directories(cymon PUBLIC include) +target_compile_options(cymon PRIVATE -Wall -Wextra -Wpedantic) + +# 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) +endif() + +# Tests +option(CYMON_BUILD_TESTS "Build tests" ON) +if(CYMON_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/README.md b/README.md index 4c64dc4..17c7268 100644 --- a/README.md +++ b/README.md @@ -1 +1,85 @@ -# cymon-lib \ No newline at end of file +# cymon-lib + +[![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. + +## 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. + +## 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 | + +## Build + +```bash +cmake -B build -DCMAKE_BUILD_TYPE=Debug +cmake --build build +ctest --test-dir build --output-on-failure +``` + +To build with a specific compiler: + +```bash +cmake -B build -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_BUILD_TYPE=Debug +cmake --build build +``` + +To disable tests: + +```bash +cmake -B build -DCYMON_BUILD_TESTS=OFF +cmake --build build +``` + +## Integration + +```cpp +#include "cymon/device.hpp" + +cymon::Device device; + +// Register variables (call once at startup). +device.RegisterVariable("speed", "rpm", [] { return read_speed(); }); +device.RegisterVariable("current", "A", [] { return read_current(); }); + +// In your Cyphal service handler: +auto resp = device.HandleSetupCapture(config); +auto resp = device.HandleArmTrigger(arm_cfg); +auto resp = device.HandleReadSamples(frame_offset, max_frames); + +// In your hardware timer ISR / task: +device.Tick(); +``` + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/cmake/nunavut.cmake b/cmake/nunavut.cmake new file mode 100644 index 0000000..cabcf85 --- /dev/null +++ b/cmake/nunavut.cmake @@ -0,0 +1,53 @@ +# 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/dsdl/cymon/ArmTrigger.1.0.dsdl b/dsdl/cymon/ArmTrigger.1.0.dsdl new file mode 100644 index 0000000..67f0a16 --- /dev/null +++ b/dsdl/cymon/ArmTrigger.1.0.dsdl @@ -0,0 +1,22 @@ +# 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/GetBufferInfo.1.0.dsdl b/dsdl/cymon/GetBufferInfo.1.0.dsdl new file mode 100644 index 0000000..3b5ba3d --- /dev/null +++ b/dsdl/cymon/GetBufferInfo.1.0.dsdl @@ -0,0 +1,20 @@ +# 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.1.0.dsdl b/dsdl/cymon/GetVariableList.1.0.dsdl new file mode 100644 index 0000000..a303979 --- /dev/null +++ b/dsdl/cymon/GetVariableList.1.0.dsdl @@ -0,0 +1,22 @@ +# 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.1.0.dsdl b/dsdl/cymon/ReadSamples.1.0.dsdl new file mode 100644 index 0000000..de1392e --- /dev/null +++ b/dsdl/cymon/ReadSamples.1.0.dsdl @@ -0,0 +1,23 @@ +# 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.1.0.dsdl b/dsdl/cymon/SetupCapture.1.0.dsdl new file mode 100644 index 0000000..e83a924 --- /dev/null +++ b/dsdl/cymon/SetupCapture.1.0.dsdl @@ -0,0 +1,22 @@ +# 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.1.0.dsdl b/dsdl/cymon/TriggerEvent.1.0.dsdl new file mode 100644 index 0000000..a51ac82 --- /dev/null +++ b/dsdl/cymon/TriggerEvent.1.0.dsdl @@ -0,0 +1,20 @@ +# 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.1.0.dsdl b/dsdl/cymon/TriggerMode.1.0.dsdl new file mode 100644 index 0000000..1aa6787 --- /dev/null +++ b/dsdl/cymon/TriggerMode.1.0.dsdl @@ -0,0 +1,19 @@ +# 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 new file mode 100644 index 0000000..a99cd6f --- /dev/null +++ b/include/cymon/config.hpp @@ -0,0 +1,51 @@ +// 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. + +#pragma once +#include + +#ifndef CYMON_MAX_BUFFER_BYTES +#define CYMON_MAX_BUFFER_BYTES 4096U +#endif +#ifndef CYMON_MAX_VARS +#define CYMON_MAX_VARS 64U +#endif +#ifndef CYMON_MAX_CHANNELS +#define CYMON_MAX_CHANNELS 8U +#endif + +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=. +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=. +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=. +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; + +} // namespace cymon diff --git a/include/cymon/device.hpp b/include/cymon/device.hpp new file mode 100644 index 0000000..847101c --- /dev/null +++ b/include/cymon/device.hpp @@ -0,0 +1,138 @@ +// 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. + +#pragma once +#include +#include +#include +#include + +#include "cymon/config.hpp" +#include "cymon/sample_buffer.hpp" +#include "cymon/trigger.hpp" + +namespace cymon { + +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] = {}; + }; + + 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; + }; + + 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 SetupCaptureResponse { + bool ok = false; + ErrorCode error_code = ErrorCode::kNone; + 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; + }; + + struct ArmTriggerResponse { + bool ok = false; + ErrorCode error_code = ErrorCode::kNone; + }; + + 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 + }; + + Device(); + + /// Set actual sample period achieved by hardware timer. + /// Call after hardware timer setup; returned in SetupCaptureResponse. + void set_actual_sample_period_us(float us); + + /// 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); + + uint16_t variable_count() const; + + /// Iterator-style: returns false when index is out of range (end of list). + bool GetVariable(uint16_t index, VariableInfo* info) const; + + GetBufferInfoResponse HandleGetBufferInfo() const; + SetupCaptureResponse HandleSetupCapture(const CaptureConfig& config); + ArmTriggerResponse HandleArmTrigger(const ArmTriggerConfig& config); + ReadSamplesResponse HandleReadSamples(uint16_t frame_offset, uint8_t max_frames) const; + + /// Call from timer task at configured rate. Returns true when capture is + /// complete. + bool Tick(); + + /// Force trigger (e.g., from received TriggerEvent message from another + /// node). + void ForceTrigger(); + + private: + struct VarEntry { + char name[33] = {}; + char unit[9] = {}; + std::function getter; + bool active = false; + }; + + 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; + SampleBuffer buffer_; + Trigger trigger_; +}; + +} // namespace cymon diff --git a/include/cymon/sample_buffer.hpp b/include/cymon/sample_buffer.hpp new file mode 100644 index 0000000..334ae14 --- /dev/null +++ b/include/cymon/sample_buffer.hpp @@ -0,0 +1,75 @@ +// 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. + +#pragma once +#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. +class SampleBuffer { + public: + static constexpr std::size_t kCapacityBytes = kMaxBufferBytes; + static constexpr std::size_t kCapacityFloats = kCapacityBytes / sizeof(float); + + /// 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); + + void Arm(); + void Disarm(); + + /// Write one frame (num_channels values). Returns false if not armed or + /// complete. + bool WriteFrame(const float* values); + + /// Signal trigger. trigger_pos_ = current write_pos_. Returns false if not + /// armed. + bool Trigger(); + + /// 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; + + bool IsComplete() const; + bool IsArmed() const; + bool WasTriggered() const; + uint8_t num_channels() const; + uint16_t num_samples() const; + uint16_t capacity_frames() const; + + 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; +}; + +} // namespace cymon diff --git a/include/cymon/trigger.hpp b/include/cymon/trigger.hpp new file mode 100644 index 0000000..3ad3bc4 --- /dev/null +++ b/include/cymon/trigger.hpp @@ -0,0 +1,45 @@ +// 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. + +#pragma once +#include + +namespace cymon { + +enum class TriggerMode : uint8_t { kRising = 0, kFalling = 1, kHysteresis = 2 }; + +struct TriggerConfig { + TriggerMode mode = TriggerMode::kRising; + float level = 0.0F; + float hysteresis_band = 0.0F; +}; + +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; + + private: + TriggerConfig config_; + bool armed_ = false; + // hysteresis: waiting for value to drop below lower threshold + bool awaiting_low_ = false; +}; + +} // namespace cymon diff --git a/src/cymon/device.cpp b/src/cymon/device.cpp new file mode 100644 index 0000000..d671e62 --- /dev/null +++ b/src/cymon/device.cpp @@ -0,0 +1,202 @@ +// 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 "cymon/device.hpp" + +#include +#include + +namespace cymon { + +Device::Device() = default; + +void Device::set_actual_sample_period_us(float us) { actual_sample_period_us_ = us; } + +bool Device::RegisterVariable(std::string_view name, std::string_view unit, std::function getter) { + if (var_count_ >= kMaxVars) { + return false; + } + if (name.size() > 32U) { + return false; + } + if (unit.size() > 8U) { + 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; + ++var_count_; + return true; +} + +uint16_t Device::variable_count() const { return static_cast(var_count_); } + +bool Device::GetVariable(uint16_t index, VariableInfo* info) const { + if (index >= var_count_) { + return false; + } + 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; +} + +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::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; + } + + 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 (!buffer_.Setup(config.num_channels, config.num_samples, config.pretrigger_samples)) { + resp.ok = false; + resp.error_code = ErrorCode::kBufferTooSmall; + return resp; + } + + 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; +} + +Device::ArmTriggerResponse Device::HandleArmTrigger(const ArmTriggerConfig& config) { + ArmTriggerResponse resp; + + if (!config.arm) { + buffer_.Disarm(); + trigger_.Disarm(); + resp.ok = true; + return resp; + } + + if (!configured_) { + resp.ok = false; + resp.error_code = ErrorCode::kNotConfigured; + return resp; + } + + if (config.source_variable_id >= var_count_) { + resp.ok = false; + resp.error_code = ErrorCode::kBadChannelId; + return resp; + } + + 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); + buffer_.Arm(); + + resp.ok = true; + return resp; +} + +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)); + + const uint8_t ch = capture_config_.num_channels; + resp.num_channels = ch; + + resp.triggered = buffer_.WasTriggered(); + + if (!resp.triggered) { + resp.end_of_data = false; + resp.num_samples = 0; + return resp; + } + + 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; + + return resp; +} + +bool Device::Tick() { + if (!configured_ || !buffer_.IsArmed()) { + return buffer_.IsComplete(); + } + + // 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)) { + buffer_.Trigger(); + } + prev_trigger_value_ = current_val; + } + + // 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; + } + buffer_.WriteFrame(frame); + + return buffer_.IsComplete(); +} + +void Device::ForceTrigger() { + if (buffer_.IsArmed()) { + buffer_.Trigger(); + trigger_.Disarm(); + } +} + +} // namespace cymon diff --git a/src/cymon/sample_buffer.cpp b/src/cymon/sample_buffer.cpp new file mode 100644 index 0000000..3068b29 --- /dev/null +++ b/src/cymon/sample_buffer.cpp @@ -0,0 +1,173 @@ +// 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 "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) { + return false; + } + if (num_samples == 0) { + return false; + } + + num_channels_ = num_channels; + num_samples_ = num_samples; + pretrigger_samples_ = pretrigger_samples; + capacity_ = cap; + configured_ = true; + armed_ = false; + triggered_ = false; + complete_ = false; + write_pos_ = 0; + trigger_pos_ = 0; + post_trigger_count_ = 0; + total_written_ = 0; + return true; +} + +void SampleBuffer::Arm() { + write_pos_ = 0; + trigger_pos_ = 0; + post_trigger_count_ = 0; + total_written_ = 0; + written_before_trigger_ = 0; + armed_ = true; + triggered_ = false; + complete_ = false; +} + +void SampleBuffer::Disarm() { + armed_ = false; + triggered_ = false; + complete_ = false; +} + +bool SampleBuffer::WriteFrame(const float* values) { + if (!armed_ || complete_) { + 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]; + } + + 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) { + complete_ = true; + } + } + + return true; +} + +bool SampleBuffer::Trigger() { + 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; + } + + return true; +} + +uint16_t SampleBuffer::ReadFrames(uint16_t frame_offset, uint8_t max_frames, float* out) const { + if (!triggered_) { + return 0; + } + + 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_) { + 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; + } + } 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]; + } + } + } else { + // Post-trigger frame. + const uint16_t post_idx = static_cast(abs_k - pretrigger_samples_); + if (post_idx >= 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]; + } + } + ++n; + } + return n; +} + +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_; } + +} // namespace cymon diff --git a/src/cymon/trigger.cpp b/src/cymon/trigger.cpp new file mode 100644 index 0000000..dae5943 --- /dev/null +++ b/src/cymon/trigger.cpp @@ -0,0 +1,71 @@ +// 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 "cymon/trigger.hpp" + +namespace cymon { + +void Trigger::Configure(const TriggerConfig& config) { config_ = config; } + +void Trigger::Arm(float initial_value) { + armed_ = true; + if (config_.mode == TriggerMode::kHysteresis) { + awaiting_low_ = (initial_value >= config_.level); + } else { + awaiting_low_ = false; + } +} + +void Trigger::Disarm() { + armed_ = false; + awaiting_low_ = false; +} + +bool Trigger::Evaluate(float prev_value, float current_value) { + if (!armed_) { + return false; + } + + bool fired = false; + switch (config_.mode) { + case TriggerMode::kRising: + fired = (prev_value < config_.level) && (current_value >= config_.level); + break; + case TriggerMode::kFalling: + fired = (prev_value > config_.level) && (current_value <= config_.level); + break; + case TriggerMode::kHysteresis: { + const float lower = config_.level - config_.hysteresis_band; + if (awaiting_low_) { + if (current_value < lower) { + awaiting_low_ = false; + } + } else { + if ((prev_value < config_.level) && (current_value >= config_.level)) { + fired = true; + } + } + break; + } + } + + if (fired) { + armed_ = false; + } + return fired; +} + +bool Trigger::IsArmed() const { return armed_; } + +} // namespace cymon diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..f8a11dc --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,38 @@ +# 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. + +cmake_minimum_required(VERSION 3.20) + +include(FetchContent) + +FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz + URL_HASH SHA256=8ad598c73ad796e0d8280b082cebd82a630d73e73cd3c70057938a6501bba5d7 +) +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +add_executable(cymon_tests + test_trigger.cpp + test_sample_buffer.cpp + test_device.cpp +) + +target_link_libraries(cymon_tests + PRIVATE cymon GTest::gtest_main +) + +include(GoogleTest) +gtest_discover_tests(cymon_tests) diff --git a/tests/test_device.cpp b/tests/test_device.cpp new file mode 100644 index 0000000..a383025 --- /dev/null +++ b/tests/test_device.cpp @@ -0,0 +1,309 @@ +// 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/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; })); + } + 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)); +} + +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); +} + +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; })); + + 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; + + const auto resp = dev.HandleSetupCapture(cfg); + EXPECT_TRUE(resp.ok); + EXPECT_EQ(resp.error_code, Device::ErrorCode::kNone); +} + +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(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(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(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(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(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(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(DeviceTest, HandleArmTrigger_Disarm_ReturnsOk) { + Device dev; + Device::ArmTriggerConfig atcfg; + atcfg.arm = false; + const auto resp = dev.HandleArmTrigger(atcfg); + EXPECT_TRUE(resp.ok); +} + +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. + for (int i = 0; i < 4; ++i) { + dev.Tick(); + } + + 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); +} + +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); +} + +} // namespace +} // namespace cymon diff --git a/tests/test_sample_buffer.cpp b/tests/test_sample_buffer.cpp new file mode 100644 index 0000000..ed1b398 --- /dev/null +++ b/tests/test_sample_buffer.cpp @@ -0,0 +1,184 @@ +// 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/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)); +} + +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, Setup_PretriggerExceedsSamples_ReturnsFalse) { + SampleBuffer buf; + EXPECT_FALSE(buf.Setup(1, 10, 11)); +} + +TEST(SampleBufferTest, Arm_SetsArmedState) { + SampleBuffer buf; + ASSERT_TRUE(buf.Setup(1, 10, 0)); + buf.Arm(); + EXPECT_TRUE(buf.IsArmed()); +} + +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) + for (int i = 0; i < 5; ++i) { + float v = static_cast(10 + i); + buf.WriteFrame(&v); + } + + buf.Trigger(); + + // Write 5 post-trigger frames (values 20..24) + for (int i = 0; i < 5; ++i) { + float v = static_cast(20 + i); + buf.WriteFrame(&v); + } + + 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); + + // 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); + + // 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); +} + +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, ReadFrames_BeyondWritten_ReturnsAvailable) { + SampleBuffer buf; + // 0 pretrigger, 10 post-trigger frames, 1 channel + ASSERT_TRUE(buf.Setup(1, 10, 0)); + buf.Arm(); + buf.Trigger(); + + // Write only 3 post-trigger frames + for (int i = 0; i < 3; ++i) { + float v = static_cast(i); + buf.WriteFrame(&v); + } + + float out[10] = {}; + // Request 10, but only 3 are available. + const uint16_t n = buf.ReadFrames(0, 10, out); + EXPECT_EQ(n, 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); +} + +} // namespace +} // namespace cymon diff --git a/tests/test_trigger.cpp b/tests/test_trigger.cpp new file mode 100644 index 0000000..835d268 --- /dev/null +++ b/tests/test_trigger.cpp @@ -0,0 +1,101 @@ +// 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, 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, 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, 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, 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, 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, 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)); +} + +} // namespace +} // namespace cymon