From 41bad3ba12e4d1ff3b32858fb55ab63d5f3589ca Mon Sep 17 00:00:00 2001 From: Anton Chernov Date: Mon, 7 Sep 2026 21:46:43 +0300 Subject: [PATCH 1/2] Added edge trigger detection Held triggered frames and a trigger reference marker Aligned the trace to a movable trigger point --- CMakeLists.txt | 16 +++ HISTORY.md | 45 ++++++++ app/main.cpp | 291 ++++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 335 insertions(+), 17 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index de345f0..28cec90 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,6 +48,7 @@ set(SOURCES_LIST capture/src/waveform_parser.cpp capture/src/waveform_ring_buffer.cpp core/src/waveform_scaling.cpp + core/src/waveform_trigger.cpp core/src/instrument_scaling_profile.cpp ${imgui_SOURCE_DIR}/imgui.cpp ${imgui_SOURCE_DIR}/imgui_draw.cpp @@ -65,6 +66,7 @@ set(HEADERS_LIST capture/inc/waveform_parser.h capture/inc/waveform_ring_buffer.h core/inc/waveform_scaling.h + core/inc/waveform_trigger.h core/inc/instrument_model.h core/inc/instrument_scaling_profile.h ) @@ -85,6 +87,10 @@ set(WAVEFORM_SCALING_TEST_SOURCES_LIST tests/waveform_scaling_test.cpp ) +set(WAVEFORM_TRIGGER_TEST_SOURCES_LIST + tests/waveform_trigger_test.cpp +) + set(INSTRUMENT_SCALING_PROFILE_TEST_SOURCES_LIST tests/instrument_scaling_profile_test.cpp ) @@ -177,6 +183,16 @@ if(BUILD_TESTING) enable_project_warnings(waveform_scaling_tests) add_test(NAME waveform_scaling COMMAND waveform_scaling_tests) add_dependencies(${APP_NAME} waveform_scaling_tests) + add_executable(waveform_trigger_tests + ${WAVEFORM_TRIGGER_TEST_SOURCES_LIST} + core/src/waveform_trigger.cpp + ) + target_include_directories(waveform_trigger_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/core/inc + ) + enable_project_warnings(waveform_trigger_tests) + add_test(NAME waveform_trigger COMMAND waveform_trigger_tests) + add_dependencies(${APP_NAME} waveform_trigger_tests) add_executable(instrument_scaling_profile_tests ${INSTRUMENT_SCALING_PROFILE_TEST_SOURCES_LIST} core/src/instrument_scaling_profile.cpp diff --git a/HISTORY.md b/HISTORY.md index 203a335..4bc945f 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -553,4 +553,49 @@ Records key decisions, structural changes, and completed development stages. headroom is deferred to a later stage. The software zero reference already yields a correct 0 V reading. +### Stage 4 - Added edge trigger detection + +- Added a new `oscilloscope::core` trigger module (`core/inc/waveform_trigger.h`, + `core/src/waveform_trigger.cpp`) with an `ETriggerSlope` enum and + `findEdgeTrigger`, which scans a sample buffer for the first rising or + falling crossing of a level starting at a given index. +- Added `tests/waveform_trigger_test.cpp` covering rising and falling edges, + the no-crossing case, the start-index skip, and the null/short-buffer guards, + and registered the `waveform_trigger` test in `CMakeLists.txt`. +- Wired the trigger into `app/main.cpp`: added a Trigger panel with a source + selector (CH1, CH2, ALT, EXT, EXT/10), a slope selector (Rising/Falling), a + level slider, and a mode selector (Auto, Normal, Single). Drew a horizontal + level line and a vertical trigger-position marker over the waveform, and used + the detected trigger sample as the readout reference. Software edge detection + runs on CH1/CH2 (ALT falls back to CH1; EXT and EXT/10 have no in-software + signal). Auto free-runs, Normal draws only when a trigger is found, and + Single freezes the first triggered frame until the Rearm button is pressed. +- Modelled the trigger controls as `ETriggerMode`, `ETriggerSource`, and + `ETriggerSlope` enumerations with option tables that keep each value next to + its label (mirroring the scaling-profile step tables), so the combo labels + cannot drift from the enumerators. + +### Stage 4 - Held triggered frames and a trigger reference marker + +- Fixed Normal mode to hold the last triggered frame until the next trigger + instead of blanking the display when a frame contained no edge. Normal and + Single now share one held-frame path: Single captures once per arm, Normal + refreshes the held frame on every new trigger, and Auto free-runs live. +- Added a "T" reference marker at the top of the display marking the sweep + trigger point, alongside the existing vertical trigger line. + +### Stage 4 - Aligned the trace to a movable trigger point + +- Reworked the trigger point so it is a fixed, user-movable horizontal + reference: added a Position control and shifted the displayed trace so the + triggering edge lands exactly under the "T" marker, with pre-trigger samples + to its left and post-trigger samples to its right. +- Added a `horizontalOffset` parameter to `drawChannelWaveform` and drew the + vertical reference and "T" marker at the fixed trigger position instead of at + the raw sample index of the detected edge. +- Armed the trigger only past a pre-trigger window sized by the Position + control, so the aligned trace keeps enough history to fill the display left + of the "T" marker instead of leaving an empty region. + + diff --git a/app/main.cpp b/app/main.cpp index b5ac1a8..f898ea1 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -87,12 +87,15 @@ #include "usb_device.h" #include "instrument_scaling_profile.h" #include "waveform_scaling.h" +#include "waveform_trigger.h" using oscilloscope::capture::SAcquisitionLoop; using oscilloscope::capture::SWaveformSamples; using oscilloscope::capture::EAcquisitionOperation; using oscilloscope::capture::EAcquisitionState; using oscilloscope::core::EInstrumentModel; +using oscilloscope::core::ETriggerSlope; +using oscilloscope::core::findEdgeTrigger; using oscilloscope::core::findInstrtScalingProfile; using oscilloscope::core::sampleIndexToSeconds; using oscilloscope::core::sampleToVolts; @@ -113,6 +116,67 @@ static const uint32_t kUsbPresenceIntervalMs = 1000U; static const SUsbDeviceInfo kEmptyDeviceInfo = { NULL, EInstrumentModel::eUnknown, 0U, 0U, 0U, 0U }; +/** @brief Trigger acquisition modes offered in the control panel */ +enum class ETriggerMode { + eAuto = 0, /**< Free-running sweep, refreshes without a trigger */ + eNormal, /**< Refreshes only when the trigger condition is met */ + eSingle /**< Captures one triggered frame, then holds it */ +}; + +/** @brief Trigger source selectable in the control panel */ +enum class ETriggerSource { + eChannelOne = 0, + eChannelTwo, + eAlternate, + eExternal, + eExternalTenth +}; + +/** @brief One trigger-mode option pairing its value with its label */ +struct STriggerModeOption { + ETriggerMode mode; /**< Enumerated mode value */ + const char *label; /**< Display text */ +}; + +/** @brief One trigger-slope option pairing its value with its label */ +struct STriggerSlopeOption { + ETriggerSlope slope; /**< Enumerated slope value */ + const char *label; /**< Display text */ +}; + +/** @brief One trigger-source option: value, label, and signal channel */ +struct STriggerSourceOption { + ETriggerSource source; /**< Enumerated source value */ + const char *label; /**< Display text */ + int signalChannel; /**< 0=CH1, 1=CH2, -1 = no in-software signal */ +}; + +static const STriggerModeOption kTriggerModeOptions[] = { + { ETriggerMode::eAuto, "Auto" }, + { ETriggerMode::eNormal, "Normal" }, + { ETriggerMode::eSingle, "Single" } +}; + +static const STriggerSlopeOption kTriggerSlopeOptions[] = { + { ETriggerSlope::eRising, "Rising" }, + { ETriggerSlope::eFalling, "Falling" } +}; + +static const STriggerSourceOption kTriggerSourceOptions[] = { + { ETriggerSource::eChannelOne, "CH1", 0 }, + { ETriggerSource::eChannelTwo, "CH2", 1 }, + { ETriggerSource::eAlternate, "ALT", 0 }, + { ETriggerSource::eExternal, "EXT", -1 }, + { ETriggerSource::eExternalTenth, "EXT/10", -1 } +}; + +static const size_t kTriggerModeOptionCount = + sizeof(kTriggerModeOptions) / sizeof(kTriggerModeOptions[0]); +static const size_t kTriggerSlopeOptionCount = + sizeof(kTriggerSlopeOptions) / sizeof(kTriggerSlopeOptions[0]); +static const size_t kTriggerSourceOptionCount = + sizeof(kTriggerSourceOptions) / sizeof(kTriggerSourceOptions[0]); + #ifdef __GNUC__ // GCC/MinGW only const char kVersionInfo[] __attribute__((section(".version"), used)) = "FileDescription: Oscilloscope application\n" @@ -151,6 +215,7 @@ static void drawOscilloscopeGrid( * @param[in] sampleCount Number of samples to read from @p samples * @param[in] profile Scaling profile giving the ADC-to-division mapping * @param[in] zeroReference Raw ADC level treated as zero volts for this channel + * @param[in] horizontalOffset Pixel shift applied so the trigger aligns to T * @param[in] color Polyline color */ static void drawChannelWaveform( @@ -161,6 +226,7 @@ static void drawChannelWaveform( size_t sampleCount, const SInstrumentScalingProfile *profile, double zeroReference, + float horizontalOffset, ImU32 color ); @@ -354,6 +420,13 @@ int main (void) { int voltsPerDivision[] = {7, 7}; float channelZeroReference[] = {128.0f, 128.0f}; float channelBaselineMean[] = {128.0f, 128.0f}; + ETriggerSource triggerSource = ETriggerSource::eChannelOne; + ETriggerSlope triggerSlope = ETriggerSlope::eRising; + int triggerLevel = 128; + float triggerPosition = 0.5f; + ETriggerMode triggerMode = ETriggerMode::eAuto; + bool singleArmed = true; + bool heldValid = false; SUsbScanResult usbScanResult = oscilloscope::usb::enumerateSupportedDevices(); bool demoMode = @@ -369,7 +442,9 @@ int main (void) { uint32_t nextUsbPresenceCheck = SDL_GetTicks() + kUsbPresenceIntervalMs; SWaveformSamples latestWaveform{}; + SWaveformSamples heldWaveform{}; uint32_t latestTriggerPoint = 0U; + uint32_t heldTriggerIndex = 0U; bool hasWaveform = false; /* Timebase/voltage-scale labels and values come from the active @@ -475,6 +550,80 @@ int main (void) { const SInstrumentScalingProfile *scalingProfile = resolveActiveScalingProfile(connectedDevice, usbScanResult); + const int triggerSignalChannel = + kTriggerSourceOptions[static_cast(triggerSource)] + .signalChannel; + const bool triggerHasSignal = (triggerSignalChannel >= 0); + bool triggerFound = false; + size_t triggerIndex = 0U; + size_t triggerStartIndex = 0U; + + if (hasWaveform && (latestWaveform.sampleCount != 0U) && + triggerHasSignal) { + const uint8_t *triggerSamples = + (triggerSignalChannel == 1) + ? latestWaveform.channelTwo.data() + : latestWaveform.channelOne.data(); + + /* Arm the trigger only past the pre-trigger window so the + * aligned trace keeps enough history to fill left of T. */ + triggerStartIndex = static_cast( + triggerPosition * + static_cast(latestWaveform.sampleCount - 1U) + ); + triggerFound = findEdgeTrigger( + triggerSamples, + latestWaveform.sampleCount, + static_cast(triggerLevel), + triggerSlope, + triggerStartIndex, + &triggerIndex + ); + } + + /* Normal and Single both hold the last triggered frame. Single + * captures once per arm; Normal refreshes on every new trigger. */ + bool captureNow = false; + if (triggerMode == ETriggerMode::eNormal) { + captureNow = triggerFound; + } + else if (triggerMode == ETriggerMode::eSingle) { + captureNow = singleArmed && triggerFound; + } + + if (captureNow && hasWaveform && (latestWaveform.sampleCount != 0U)) { + heldWaveform = latestWaveform; + heldTriggerIndex = static_cast(triggerIndex); + heldValid = true; + if (triggerMode == ETriggerMode::eSingle) { + singleArmed = false; + } + } + if (triggerMode == ETriggerMode::eAuto) { + heldValid = false; + singleArmed = true; + } + else if (triggerMode == ETriggerMode::eNormal) { + singleArmed = true; + } + + const SWaveformSamples *shownWaveform = &latestWaveform; + size_t shownTriggerIndex = triggerIndex; + bool shownTriggerFound = triggerFound; + bool shownHasWaveform = + hasWaveform && (latestWaveform.sampleCount != 0U); + bool drawTraces = false; + + if (triggerMode == ETriggerMode::eAuto) { + drawTraces = shownHasWaveform; + } + else if (heldValid) { + shownWaveform = &heldWaveform; + shownTriggerIndex = static_cast(heldTriggerIndex); + shownTriggerFound = true; + shownHasWaveform = (heldWaveform.sampleCount != 0U); + drawTraces = shownHasWaveform; + } ImGui::BeginGroup(); ImGui::TextUnformatted("Display"); @@ -491,27 +640,75 @@ int main (void) { ImDrawList *waveformDrawList = ImGui::GetWindowDrawList(); const ImVec2 waveformOrigin = ImGui::GetCursorScreenPos(); const ImVec2 waveformSize = ImGui::GetContentRegionAvail(); + const float triggerReferenceX = + waveformOrigin.x + waveformSize.x * triggerPosition; + float waveformXShift = 0.0f; + + if (shownTriggerFound && (shownWaveform->sampleCount > 1U)) { + const float rawTriggerX = waveformSize.x * + static_cast(shownTriggerIndex) / + static_cast(shownWaveform->sampleCount - 1U); + waveformXShift = + (triggerReferenceX - waveformOrigin.x) - rawTriggerX; + } drawOscilloscopeGrid(waveformDrawList, waveformOrigin, waveformSize); - if (hasWaveform && (latestWaveform.sampleCount != 0U)) { - if (channelEnabled[0]) { + if (shownHasWaveform) { + if (drawTraces && channelEnabled[0]) { drawChannelWaveform( waveformDrawList, waveformOrigin, waveformSize, - latestWaveform.channelOne.data(), - latestWaveform.sampleCount, scalingProfile, + shownWaveform->channelOne.data(), + shownWaveform->sampleCount, scalingProfile, static_cast(channelZeroReference[0]), + waveformXShift, IM_COL32(255, 214, 0, 255) ); } - if (channelEnabled[1]) { + if (drawTraces && channelEnabled[1]) { drawChannelWaveform( waveformDrawList, waveformOrigin, waveformSize, - latestWaveform.channelTwo.data(), - latestWaveform.sampleCount, scalingProfile, + shownWaveform->channelTwo.data(), + shownWaveform->sampleCount, scalingProfile, static_cast(channelZeroReference[1]), + waveformXShift, IM_COL32(64, 200, 255, 255) ); } + if ((scalingProfile != NULL) && triggerHasSignal) { + const float pixelsPerDivision = waveformSize.y / + static_cast(scalingProfile->verticalDivisions); + const float levelDivisions = + (static_cast(triggerLevel) - + channelZeroReference[triggerSignalChannel]) / + static_cast(scalingProfile->adcCountsPerDivision); + const float levelY = waveformOrigin.y + + waveformSize.y * 0.5f - levelDivisions * pixelsPerDivision; + + waveformDrawList->AddLine( + ImVec2(waveformOrigin.x, levelY), + ImVec2(waveformOrigin.x + waveformSize.x, levelY), + IM_COL32(255, 96, 96, 150), 1.0f + ); + /* Vertical reference and "T" marker sit at the fixed + * trigger position; the trace is aligned under it. */ + waveformDrawList->AddLine( + ImVec2(triggerReferenceX, waveformOrigin.y), + ImVec2( + triggerReferenceX, waveformOrigin.y + waveformSize.y), + IM_COL32(255, 96, 96, 200), 1.0f + ); + waveformDrawList->AddTriangleFilled( + ImVec2(triggerReferenceX - 5.0f, waveformOrigin.y), + ImVec2(triggerReferenceX + 5.0f, waveformOrigin.y), + ImVec2(triggerReferenceX, waveformOrigin.y + 8.0f), + IM_COL32(255, 96, 96, 220) + ); + waveformDrawList->AddText( + ImVec2(triggerReferenceX + 6.0f, waveformOrigin.y + 1.0f), + IM_COL32(255, 96, 96, 255), + "T" + ); + } } ImGui::EndChild(); statusPosition = ImGui::GetCursorScreenPos(); @@ -666,6 +863,60 @@ int main (void) { channelZeroReference[1] = channelBaselineMean[1]; } ImGui::Separator(); + ImGui::TextUnformatted("Trigger"); + { + const char *triggerSourceLabels[kTriggerSourceOptionCount]; + const char *triggerSlopeLabels[kTriggerSlopeOptionCount]; + const char *triggerModeLabels[kTriggerModeOptionCount]; + int triggerSourceIndex = static_cast(triggerSource); + int triggerSlopeIndex = static_cast(triggerSlope); + int triggerModeIndex = static_cast(triggerMode); + size_t optionIndex = 0U; + + for (optionIndex = 0U; + optionIndex < kTriggerSourceOptionCount; ++optionIndex) { + triggerSourceLabels[optionIndex] = + kTriggerSourceOptions[optionIndex].label; + } + for (optionIndex = 0U; + optionIndex < kTriggerSlopeOptionCount; ++optionIndex) { + triggerSlopeLabels[optionIndex] = + kTriggerSlopeOptions[optionIndex].label; + } + for (optionIndex = 0U; + optionIndex < kTriggerModeOptionCount; ++optionIndex) { + triggerModeLabels[optionIndex] = + kTriggerModeOptions[optionIndex].label; + } + + if (ImGui::Combo( + "Source", &triggerSourceIndex, triggerSourceLabels, + static_cast(kTriggerSourceOptionCount))) { + triggerSource = + kTriggerSourceOptions[triggerSourceIndex].source; + } + if (ImGui::Combo( + "Slope", &triggerSlopeIndex, triggerSlopeLabels, + static_cast(kTriggerSlopeOptionCount))) { + triggerSlope = kTriggerSlopeOptions[triggerSlopeIndex].slope; + } + ImGui::SliderInt("Level", &triggerLevel, 0, 255); + ImGui::SliderFloat( + "Position", &triggerPosition, 0.0f, 1.0f, "%.2f" + ); + if (ImGui::Combo( + "Mode", &triggerModeIndex, triggerModeLabels, + static_cast(kTriggerModeOptionCount))) { + triggerMode = kTriggerModeOptions[triggerModeIndex].mode; + } + if (triggerMode == ETriggerMode::eSingle) { + if (ImGui::Button("Rearm", ImVec2(-1.0f, 0.0f))) { + singleArmed = true; + heldValid = false; + } + } + } + ImGui::Separator(); if (ImGui::Checkbox("Demo mode", &demoMode)) { updateDemoMode( demoMode, @@ -682,23 +933,25 @@ int main (void) { char waveformStatus[128]; - if (hasWaveform && latestWaveform.sampleCount != 0U) { + if (shownHasWaveform) { size_t triggerSampleIndex = - static_cast(latestTriggerPoint); + shownTriggerFound + ? shownTriggerIndex + : static_cast(latestTriggerPoint); - if (triggerSampleIndex >= latestWaveform.sampleCount) { + if (triggerSampleIndex >= shownWaveform->sampleCount) { triggerSampleIndex = 0U; } const double channelOneVolts = sampleToVolts( - latestWaveform.channelOne[triggerSampleIndex], + shownWaveform->channelOne[triggerSampleIndex], scalingProfile->voltageSteps[voltsPerDivision[0]] .valuePerDivision, static_cast(channelZeroReference[0] + 0.5f), scalingProfile->adcCountsPerDivision ); const double channelTwoVolts = sampleToVolts( - latestWaveform.channelTwo[triggerSampleIndex], + shownWaveform->channelTwo[triggerSampleIndex], scalingProfile->voltageSteps[voltsPerDivision[1]] .valuePerDivision, static_cast(channelZeroReference[1] + 0.5f), @@ -706,7 +959,7 @@ int main (void) { ); const double triggerSeconds = sampleIndexToSeconds( triggerSampleIndex, - latestWaveform.sampleCount, + shownWaveform->sampleCount, scalingProfile->timebaseSteps[timebase].valuePerDivision, scalingProfile->horizontalDivisions ); @@ -714,9 +967,12 @@ int main (void) { snprintf( waveformStatus, sizeof(waveformStatus), - "Waveform %zu samples (trigger %u) CH1 %.3fV CH2 %.3fV @ %.3gs", - latestWaveform.sampleCount, - static_cast(latestTriggerPoint), + "Waveform %zu samples (%s %zu) CH1 %.3fV CH2 %.3fV @ %.3gs", + shownWaveform->sampleCount, + shownTriggerFound + ? "trig" + : (triggerMode == ETriggerMode::eAuto ? "auto" : "wait"), + triggerSampleIndex, channelOneVolts, channelTwoVolts, triggerSeconds @@ -1105,6 +1361,7 @@ static void drawChannelWaveform( size_t sampleCount, const SInstrumentScalingProfile *profile, double zeroReference, + float horizontalOffset, ImU32 color ) { std::vector points; @@ -1120,7 +1377,7 @@ static void drawChannelWaveform( const double divisions = (static_cast(samples[index]) - zeroReference) / profile->adcCountsPerDivision; - const float x = position.x + size.x * + const float x = position.x + horizontalOffset + size.x * static_cast(index) / static_cast(sampleCount - 1U); const float y = centerY - From d7c1b463590b8ae66ed018f98914f4ca39065f11 Mon Sep 17 00:00:00 2001 From: Anton Chernov Date: Mon, 7 Sep 2026 22:16:49 +0300 Subject: [PATCH 2/2] Windowed the record under a fixed trigger marker --- HISTORY.md | 21 ++++ README.md | 23 ++-- app/main.cpp | 118 ++++++++++++++----- core/inc/waveform_trigger.h | 65 ++++++++++ core/src/waveform_trigger.cpp | 63 ++++++++++ docs/mainpage.md | 10 +- tests/waveform_trigger_test.cpp | 203 ++++++++++++++++++++++++++++++++ 7 files changed, 462 insertions(+), 41 deletions(-) create mode 100755 core/inc/waveform_trigger.h create mode 100755 core/src/waveform_trigger.cpp create mode 100755 tests/waveform_trigger_test.cpp diff --git a/HISTORY.md b/HISTORY.md index 4bc945f..a5450c6 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -597,5 +597,26 @@ Records key decisions, structural changes, and completed development stages. control, so the aligned trace keeps enough history to fill the display left of the "T" marker instead of leaving an empty region. +### Stage 4 - Windowed the record under a fixed trigger marker + +- Displayed only a fraction of the captured record (a window) so the surplus + samples act as pre/post-trigger reserve, letting the trace fill the whole + display under the fixed "T" marker without an empty region on either side. +- Slid the window so the trigger sample sits under the marker, clamping the + window to the record bounds; at the reserve limit the marker drifts slightly + rather than exposing an empty edge. +- Replaced the pixel-shift parameter of `drawChannelWaveform` with a window + start and length, and added `computeDisplayWindow` to size the window. +- Verified on a physical Hantek DSO-2250 with a noise signal, the trigger + centered, and Normal sweep: the trace reaches the right edge and starts at + the left edge with no gaps. Confirmed at Position 0 and Position 90 as well. + +### Next windowing tasks + +- Phase 2: derive the display window length from the Timebase control using the + device's real sample period, so the Timebase combo drives the horizontal + span. Requires confirming the true sample period from the reference USB + captures before wiring it to the window length. + diff --git a/README.md b/README.md index a84b7db..82f77a1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ The application currently provides: - supported Hantek device discovery and connection through libusb; - FX2 firmware upload and operational-device re-enumeration; - Start/Stop-controlled endpoint polling; -- bounded USB error recovery and safe device-loss handling. +- bounded USB error recovery and safe device-loss handling; +- live two-channel waveform rendering with edge triggering. Capture-state responses and sample buffers are decoded by deterministic, hardware-independent parser functions. Each supported-device entry supplies its @@ -25,8 +26,10 @@ worker reads and queues the complete profile-defined sample buffer, then starts the next capture. A processing worker decodes complete captures and publishes the latest frame and trigger point safely for rendering. Deterministic functions convert raw capture samples into volts and elapsed capture time from -the selected voltage-scale and timebase settings. Live waveform rendering is -not yet implemented. +the selected voltage-scale and timebase settings. Both channels are plotted on +the display grid with Auto, Normal, and Single edge triggering, a movable +trigger Position with a fixed "T" reference marker, and a display window that +keeps the trace filling the full width with pre/post-trigger reserve. ## Planned Stack @@ -217,18 +220,14 @@ CI can update only the version metadata by passing `--skip-build`. ## Known Issues -- Live capture on a physical Hantek DSO-2250 never reports a waveform: - `GetCaptureState` polling stays at the empty-buffer state, so no channel - data is read. Demo mode and the full CTest suite are unaffected. See - `HISTORY.md` ("Known issue - live capture on real DSO-2250 hardware - reports no waveform") for the investigation and ruled-out causes. +- None currently tracked. ## Next Steps -1. Diagnose the live-capture-never-completes issue on real DSO-2250 - hardware. -2. Render live and demo waveforms on the display grid. -3. Implement the two-channel model, timebase, and instrument controls. +1. Drive the horizontal display window length from the Timebase control using + the device's real sample period. +2. Add a vertical Level slider and per-channel zero-position controls. +3. Implement cursor-based measurements. The full goals, constraints, and architecture are documented in `WorkingDocs/TECHNICAL_SPECIFICATION.md`. diff --git a/app/main.cpp b/app/main.cpp index f898ea1..89d79d8 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -116,6 +116,10 @@ static const uint32_t kUsbPresenceIntervalMs = 1000U; static const SUsbDeviceInfo kEmptyDeviceInfo = { NULL, EInstrumentModel::eUnknown, 0U, 0U, 0U, 0U }; +/** @brief Fraction of the captured record shown across the display, leaving + * the rest as pre/post-trigger reserve to fill under the marker */ +static const float kDisplayWindowFraction = 0.8f; + /** @brief Trigger acquisition modes offered in the control panel */ enum class ETriggerMode { eAuto = 0, /**< Free-running sweep, refreshes without a trigger */ @@ -212,10 +216,11 @@ static void drawOscilloscopeGrid( * @param[in] position Top-left corner of the plot area in screen space * @param[in] size Plot-area dimensions in pixels * @param[in] samples Raw ADC bytes to plot, one per horizontal step - * @param[in] sampleCount Number of samples to read from @p samples + * @param[in] sampleCount Total samples available in @p samples + * @param[in] windowStart First record sample mapped to the left edge + * @param[in] windowLength Number of samples spread across the plot width * @param[in] profile Scaling profile giving the ADC-to-division mapping * @param[in] zeroReference Raw ADC level treated as zero volts for this channel - * @param[in] horizontalOffset Pixel shift applied so the trigger aligns to T * @param[in] color Polyline color */ static void drawChannelWaveform( @@ -224,12 +229,21 @@ static void drawChannelWaveform( const ImVec2 &size, const uint8_t *samples, size_t sampleCount, + size_t windowStart, + size_t windowLength, const SInstrumentScalingProfile *profile, double zeroReference, - float horizontalOffset, ImU32 color ); +/** + * @brief Computes the on-screen window length in samples + * @param[in] sampleCount Total samples in the captured record + * @returns Window length shown across the display, leaving the remainder as + * pre/post-trigger reserve so the trace fills under the marker + */ +static size_t computeDisplayWindow(size_t sampleCount); + /** * @brief Checks whether a device is still present in a scan result * @param[in] scanResult Latest supported-device scan result @@ -564,12 +578,14 @@ int main (void) { (triggerSignalChannel == 1) ? latestWaveform.channelTwo.data() : latestWaveform.channelOne.data(); + const size_t triggerWindowSamples = + computeDisplayWindow(latestWaveform.sampleCount); - /* Arm the trigger only past the pre-trigger window so the - * aligned trace keeps enough history to fill left of T. */ + /* Arm the trigger past the window's pre-trigger span so the + * aligned window keeps enough history to fill left of T. */ triggerStartIndex = static_cast( triggerPosition * - static_cast(latestWaveform.sampleCount - 1U) + static_cast(triggerWindowSamples - 1U) ); triggerFound = findEdgeTrigger( triggerSamples, @@ -642,14 +658,28 @@ int main (void) { const ImVec2 waveformSize = ImGui::GetContentRegionAvail(); const float triggerReferenceX = waveformOrigin.x + waveformSize.x * triggerPosition; - float waveformXShift = 0.0f; - - if (shownTriggerFound && (shownWaveform->sampleCount > 1U)) { - const float rawTriggerX = waveformSize.x * - static_cast(shownTriggerIndex) / - static_cast(shownWaveform->sampleCount - 1U); - waveformXShift = - (triggerReferenceX - waveformOrigin.x) - rawTriggerX; + const size_t displayWindowSamples = + computeDisplayWindow(shownWaveform->sampleCount); + size_t displayWindowStart = 0U; + + if (shownTriggerFound && (displayWindowSamples > 1U)) { + const float preTriggerSamples = triggerPosition * + static_cast(displayWindowSamples - 1U); + const float windowStartFloat = + static_cast(shownTriggerIndex) - preTriggerSamples; + size_t maxWindowStart = 0U; + + if (shownWaveform->sampleCount > displayWindowSamples) { + maxWindowStart = + shownWaveform->sampleCount - displayWindowSamples; + } + if (windowStartFloat > 0.0f) { + displayWindowStart = + static_cast(windowStartFloat + 0.5f); + if (displayWindowStart > maxWindowStart) { + displayWindowStart = maxWindowStart; + } + } } drawOscilloscopeGrid(waveformDrawList, waveformOrigin, waveformSize); @@ -658,9 +688,10 @@ int main (void) { drawChannelWaveform( waveformDrawList, waveformOrigin, waveformSize, shownWaveform->channelOne.data(), - shownWaveform->sampleCount, scalingProfile, + shownWaveform->sampleCount, + displayWindowStart, displayWindowSamples, + scalingProfile, static_cast(channelZeroReference[0]), - waveformXShift, IM_COL32(255, 214, 0, 255) ); } @@ -668,9 +699,10 @@ int main (void) { drawChannelWaveform( waveformDrawList, waveformOrigin, waveformSize, shownWaveform->channelTwo.data(), - shownWaveform->sampleCount, scalingProfile, + shownWaveform->sampleCount, + displayWindowStart, displayWindowSamples, + scalingProfile, static_cast(channelZeroReference[1]), - waveformXShift, IM_COL32(64, 200, 255, 255) ); } @@ -957,9 +989,18 @@ int main (void) { static_cast(channelZeroReference[1] + 0.5f), scalingProfile->adcCountsPerDivision ); + size_t windowRelativeIndex = 0U; + + if (triggerSampleIndex > displayWindowStart) { + windowRelativeIndex = triggerSampleIndex - displayWindowStart; + } + if (windowRelativeIndex >= displayWindowSamples) { + windowRelativeIndex = displayWindowSamples - 1U; + } + const double triggerSeconds = sampleIndexToSeconds( - triggerSampleIndex, - shownWaveform->sampleCount, + windowRelativeIndex, + displayWindowSamples, scalingProfile->timebaseSteps[timebase].valuePerDivision, scalingProfile->horizontalDivisions ); @@ -1352,6 +1393,26 @@ static void drawOscilloscopeGrid( } /*----------------------------------------------------------------------------*/ +/** @fn computeDisplayWindow */ +static size_t computeDisplayWindow(size_t sampleCount) { + size_t windowSamples = sampleCount; + + if (sampleCount > 2U) { + windowSamples = static_cast( + static_cast(sampleCount) * kDisplayWindowFraction + 0.5f + ); + if (windowSamples < 2U) { + windowSamples = 2U; + } + if (windowSamples > sampleCount) { + windowSamples = sampleCount; + } + } + + return windowSamples; +} +/*----------------------------------------------------------------------------*/ + /** @fn drawChannelWaveform */ static void drawChannelWaveform( ImDrawList *drawList, @@ -1359,27 +1420,30 @@ static void drawChannelWaveform( const ImVec2 &size, const uint8_t *samples, size_t sampleCount, + size_t windowStart, + size_t windowLength, const SInstrumentScalingProfile *profile, double zeroReference, - float horizontalOffset, ImU32 color ) { std::vector points; const float centerY = position.y + size.y * 0.5f; size_t index = 0U; - if ((samples != NULL) && (profile != NULL) && (sampleCount > 1U)) { + if ((samples != NULL) && (profile != NULL) && (windowLength > 1U) && + ((windowStart + windowLength) <= sampleCount)) { const float pixelsPerDivision = static_cast(size.y / profile->verticalDivisions); - points.reserve(sampleCount); - for (index = 0U; index < sampleCount; ++index) { + points.reserve(windowLength); + for (index = 0U; index < windowLength; ++index) { + const size_t sampleIndex = windowStart + index; const double divisions = - (static_cast(samples[index]) - zeroReference) / + (static_cast(samples[sampleIndex]) - zeroReference) / profile->adcCountsPerDivision; - const float x = position.x + horizontalOffset + size.x * + const float x = position.x + size.x * static_cast(index) / - static_cast(sampleCount - 1U); + static_cast(windowLength - 1U); const float y = centerY - static_cast(divisions) * pixelsPerDivision; diff --git a/core/inc/waveform_trigger.h b/core/inc/waveform_trigger.h new file mode 100755 index 0000000..ae970ec --- /dev/null +++ b/core/inc/waveform_trigger.h @@ -0,0 +1,65 @@ +/** + * @file waveform_trigger.h + * @version 0.2.9 + * @authors Anton Chernov + * @date 2026-09-07 + * @date @showdate "%Y-%m-%d" + * @par + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef WAVEFORM_TRIGGER_H_ +#define WAVEFORM_TRIGGER_H_ + +/******************************* Included files ******************************/ +#include +#include + +/********************************* Definitions ********************************/ + +namespace oscilloscope { +namespace core { + +/** @brief Edge slope that qualifies as a trigger event */ +enum class ETriggerSlope { + eRising, /**< Trigger on a low-to-high level crossing */ + eFalling /**< Trigger on a high-to-low level crossing */ +}; + +/********************* Application Programming Interface *********************/ + +/** + * @brief Finds the first edge-trigger crossing in a sample buffer + * @param[in] samples Raw ADC samples for the trigger source channel + * @param[in] sampleCount Number of valid samples in @p samples + * @param[in] level Raw ADC threshold that defines the trigger crossing + * @param[in] slope Edge direction that qualifies as a trigger event + * @param[in] startIndex First sample index to examine + * @param[out] triggerIndex Destination for the crossing sample index + * @returns True when a qualifying edge is found at or after @p startIndex + */ +bool findEdgeTrigger( + const uint8_t *samples, + size_t sampleCount, + uint8_t level, + ETriggerSlope slope, + size_t startIndex, + size_t *triggerIndex +); + +} // namespace core +} // namespace oscilloscope +/******************************************************************************/ +#endif //! WAVEFORM_TRIGGER_H_ diff --git a/core/src/waveform_trigger.cpp b/core/src/waveform_trigger.cpp new file mode 100755 index 0000000..7baa8cb --- /dev/null +++ b/core/src/waveform_trigger.cpp @@ -0,0 +1,63 @@ +/** + * @file waveform_trigger.cpp + * @version 0.2.11 + * @authors Anton Chernov + * @date 2026-09-07 + * @date @showdate "%Y-%m-%d" + * @par + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/******************************* Included files ******************************/ +#include "waveform_trigger.h" + +/********************* Application Programming Interface *********************/ + +/** @fn oscilloscope::core::findEdgeTrigger */ +bool oscilloscope::core::findEdgeTrigger( + const uint8_t *samples, + size_t sampleCount, + uint8_t level, + ETriggerSlope slope, + size_t startIndex, + size_t *triggerIndex +) { + bool ret_val = false; + size_t index = 0U; + + if ((samples != NULL) && (triggerIndex != NULL) && (sampleCount > 1U)) { + size_t start = startIndex; + + if (start < 1U) { + start = 1U; + } + for (index = start; (index < sampleCount) && (!ret_val); ++index) { + const bool rising = + (samples[index - 1U] < level) && (samples[index] >= level); + const bool falling = + (samples[index - 1U] > level) && (samples[index] <= level); + const bool matched = + (slope == ETriggerSlope::eRising) ? rising : falling; + + if (matched) { + *triggerIndex = index; + ret_val = true; + } + } + } + + return ret_val; +} +/******************************************************************************/ diff --git a/docs/mainpage.md b/docs/mainpage.md index 8c96382..06ab595 100644 --- a/docs/mainpage.md +++ b/docs/mainpage.md @@ -32,6 +32,10 @@ libusb for communication with supported instruments. - thread-safe publication of the latest decoded waveform and trigger point. - a per-device capture profile selected from the supported-device table. - deterministic voltage and timebase scaling of raw capture samples. +- live two-channel waveform rendering on the display grid. +- Auto, Normal, and Single edge triggering with a movable trigger Position, + a fixed "T" reference marker, and a display window that keeps the trace + filling the full width with pre/post-trigger reserve. ## Architecture @@ -81,6 +85,8 @@ complete installation, build, and run instructions. ## Planned work -The next task is displaying live waveforms. Instrument controls, broader -recovery behavior, and persistent configuration follow in later milestones. +The next task is driving the horizontal display window length from the Timebase +control using the device's real sample period. A vertical Level slider, +per-channel zero-position controls, cursor-based measurements, broader recovery +behavior, and persistent configuration follow in later milestones. diff --git a/tests/waveform_trigger_test.cpp b/tests/waveform_trigger_test.cpp new file mode 100755 index 0000000..96d2941 --- /dev/null +++ b/tests/waveform_trigger_test.cpp @@ -0,0 +1,203 @@ +/** + * @file waveform_trigger_test.cpp + * @version 0.2.11 + * @authors Anton Chernov + * @date 2026-09-07 + * @date @showdate "%Y-%m-%d" + * @par + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/******************************* Included files *******************************/ +#include +#include + +#include "waveform_trigger.h" + +/********************************* Definitions ********************************/ + +namespace { + +using oscilloscope::core::ETriggerSlope; +using oscilloscope::core::findEdgeTrigger; + +/* A single low-to-high step: samples cross the level 128 between index 3 + * and index 4. */ +static const uint8_t kRisingStep[8] = + {10U, 20U, 30U, 40U, 200U, 210U, 220U, 230U}; + +/* A single high-to-low step: samples cross the level 128 between index 3 + * and index 4. */ +static const uint8_t kFallingStep[8] = + {230U, 220U, 210U, 200U, 40U, 30U, 20U, 10U}; + +/* Two rising crossings of level 128: between 1..2 and between 5..6. */ +static const uint8_t kTwoRising[8] = + {10U, 100U, 200U, 210U, 90U, 100U, 200U, 210U}; + +static const uint8_t kTriggerLevel = 128U; + +/***************************** Private prototypes *****************************/ + +static bool expect(bool condition, const char *message); +static bool testRisingEdge(); +static bool testFallingEdge(); +static bool testNoCrossing(); +static bool testStartIndexSkipsFirstEdge(); +static bool testGuards(); + +/****************************** Private functions *****************************/ + +/** @fn expect */ +static bool expect(bool condition, const char *message) { + bool result = condition; + + if (!condition) { + std::cerr << "FAILED: " << message << std::endl; + } + + return result; +} +/*----------------------------------------------------------------------------*/ + +/** @fn testRisingEdge */ +static bool testRisingEdge() { + bool passed = true; + size_t triggerIndex = 0U; + const bool found = findEdgeTrigger( + kRisingStep, 8U, kTriggerLevel, ETriggerSlope::eRising, 0U, + &triggerIndex + ); + + passed = expect(found, "Rising step must trigger") && passed; + passed = + expect(triggerIndex == 4U, "Rising trigger must land on the crossing") + && passed; + + return passed; +} +/*----------------------------------------------------------------------------*/ + +/** @fn testFallingEdge */ +static bool testFallingEdge() { + bool passed = true; + size_t triggerIndex = 0U; + const bool found = findEdgeTrigger( + kFallingStep, 8U, kTriggerLevel, ETriggerSlope::eFalling, 0U, + &triggerIndex + ); + + passed = expect(found, "Falling step must trigger") && passed; + passed = + expect(triggerIndex == 4U, "Falling trigger must land on the crossing") + && passed; + + return passed; +} +/*----------------------------------------------------------------------------*/ + +/** @fn testNoCrossing */ +static bool testNoCrossing() { + bool passed = true; + size_t triggerIndex = 42U; + const bool found = findEdgeTrigger( + kRisingStep, 8U, kTriggerLevel, ETriggerSlope::eFalling, 0U, + &triggerIndex + ); + + passed = + expect(!found, "A rising-only buffer must not fire a falling trigger") + && passed; + passed = + expect(triggerIndex == 42U, "Trigger index must be untouched on miss") + && passed; + + return passed; +} +/*----------------------------------------------------------------------------*/ + +/** @fn testStartIndexSkipsFirstEdge */ +static bool testStartIndexSkipsFirstEdge() { + bool passed = true; + size_t triggerIndex = 0U; + const bool found = findEdgeTrigger( + kTwoRising, 8U, kTriggerLevel, ETriggerSlope::eRising, 3U, + &triggerIndex + ); + + passed = expect(found, "Second rising edge must be found") && passed; + passed = + expect( + triggerIndex == 6U, + "Start index must skip the first edge and find the second" + ) && passed; + + return passed; +} +/*----------------------------------------------------------------------------*/ + +/** @fn testGuards */ +static bool testGuards() { + bool passed = true; + size_t triggerIndex = 0U; + + passed = + expect( + !findEdgeTrigger( + NULL, 8U, kTriggerLevel, ETriggerSlope::eRising, 0U, + &triggerIndex + ), + "Null samples must not trigger" + ) && passed; + passed = + expect( + !findEdgeTrigger( + kRisingStep, 1U, kTriggerLevel, ETriggerSlope::eRising, 0U, + &triggerIndex + ), + "A single-sample buffer has no edge" + ) && passed; + passed = + expect( + !findEdgeTrigger( + kRisingStep, 8U, kTriggerLevel, ETriggerSlope::eRising, 0U, + NULL + ), + "Null output pointer must not trigger" + ) && passed; + + return passed; +} + +} // namespace + +/********************* Application Programming Interface *********************/ + +/** @fn main */ +int main() { + bool passed = true; + int result = 1; + + passed = testRisingEdge() && passed; + passed = testFallingEdge() && passed; + passed = testNoCrossing() && passed; + passed = testStartIndexSkipsFirstEdge() && passed; + passed = testGuards() && passed; + if (passed) { + result = 0; + } + + return result; +}