From cc10c630efbec4155cee1c6f236ecef9b5006e34 Mon Sep 17 00:00:00 2001 From: TreeNewbeer Date: Tue, 10 Nov 2026 03:05:28 -0800 Subject: [PATCH 1/4] feat: support DSLogic hardware trigger in capture Co-Authored-By: Claude Opus 5.5 --- crates/dsview-cli/src/device_options.rs | 99 ++ crates/dsview-cli/src/main.rs | 270 +++- crates/dsview-cli/tests/device_options_cli.rs | 9 +- crates/dsview-cli/tests/trigger_cli.rs | 455 ++++++ crates/dsview-core/src/lib.rs | 118 +- crates/dsview-core/src/trigger.rs | 1363 +++++++++++++++++ crates/dsview-core/tests/acquisition.rs | 1 + crates/dsview-core/tests/export_artifacts.rs | 3 + crates/dsview-sys/bridge_runtime.c | 371 +++++ .../native/windows/dsview_runtime.def | 11 + crates/dsview-sys/src/lib.rs | 315 ++++ crates/dsview-sys/tests/trigger.rs | 253 +++ crates/dsview-sys/wrapper.h | 61 + 13 files changed, 3307 insertions(+), 22 deletions(-) create mode 100644 crates/dsview-cli/tests/trigger_cli.rs create mode 100644 crates/dsview-core/src/trigger.rs create mode 100644 crates/dsview-sys/tests/trigger.rs diff --git a/crates/dsview-cli/src/device_options.rs b/crates/dsview-cli/src/device_options.rs index 7858067..61227c1 100644 --- a/crates/dsview-cli/src/device_options.rs +++ b/crates/dsview-cli/src/device_options.rs @@ -7,6 +7,10 @@ use crate::capture_device_options::{ }; use dsview_core::{ CurrentDeviceOptionValues, DeviceOptionsSnapshot, LegacyThresholdMetadataSnapshot, + TRIGGER_CHANNEL_CAPACITY, TRIGGER_CONFIG_VERSION, TRIGGER_MAX_SERIAL_DATA_BITS, + TRIGGER_MAX_STAGE_COUNT, TRIGGER_MAX_STAGES, TRIGGER_POSITION_DEFAULT_PERCENT, + TRIGGER_POSITION_MAX_PERCENT, TRIGGER_POSITION_MIN_PERCENT, TRIGGER_STREAM_POSITION_PERCENT, + TriggerCondition, }; use serde::Serialize; @@ -57,6 +61,63 @@ pub struct ThresholdGuideResponse { pub legacy_metadata: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TriggerPositionGuideResponse { + pub min_percent: u8, + pub max_percent: u8, + pub default_percent: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TriggerStreamModeGuideResponse { + pub modes: Vec<&'static str>, + pub position_percent: u8, +} + +/// Hardware trigger capabilities and the capture flags that use them. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TriggerGuideResponse { + pub trigger_flag: &'static str, + pub trigger_config_flag: &'static str, + pub trigger_position_flag: &'static str, + pub config_version: u32, + pub modes: Vec<&'static str>, + pub conditions: Vec<&'static str>, + pub channel_capacity: u16, + pub max_stages: usize, + pub max_stage_count: u32, + pub max_serial_data_bits: u8, + pub position_percent: TriggerPositionGuideResponse, + pub stream_mode: TriggerStreamModeGuideResponse, +} + +pub fn build_trigger_guide_response() -> TriggerGuideResponse { + TriggerGuideResponse { + trigger_flag: "--trigger", + trigger_config_flag: "--trigger-config", + trigger_position_flag: "--trigger-position", + config_version: TRIGGER_CONFIG_VERSION, + modes: vec!["simple", "stage", "serial"], + conditions: TriggerCondition::ALL + .iter() + .map(|condition| condition.as_str()) + .collect(), + channel_capacity: TRIGGER_CHANNEL_CAPACITY, + max_stages: TRIGGER_MAX_STAGES, + max_stage_count: TRIGGER_MAX_STAGE_COUNT, + max_serial_data_bits: TRIGGER_MAX_SERIAL_DATA_BITS, + position_percent: TriggerPositionGuideResponse { + min_percent: TRIGGER_POSITION_MIN_PERCENT, + max_percent: TRIGGER_POSITION_MAX_PERCENT, + default_percent: TRIGGER_POSITION_DEFAULT_PERCENT, + }, + stream_mode: TriggerStreamModeGuideResponse { + modes: vec!["simple"], + position_percent: TRIGGER_STREAM_POSITION_PERCENT, + }, + } +} + #[derive(Debug, Clone, PartialEq, Serialize)] pub struct DeviceOptionsResponse { pub selected_handle: u64, @@ -68,6 +129,7 @@ pub struct DeviceOptionsResponse { pub threshold: ThresholdGuideResponse, pub channel_modes_by_operation_mode: Vec, pub capture_guide: CaptureTokenGuide, + pub trigger: TriggerGuideResponse, } pub fn build_device_options_response(snapshot: &DeviceOptionsSnapshot) -> DeviceOptionsResponse { @@ -133,6 +195,7 @@ pub fn build_device_options_response(snapshot: &DeviceOptionsSnapshot) -> Device }) .collect(), capture_guide, + trigger: build_trigger_guide_response(), } } @@ -245,9 +308,45 @@ pub fn render_device_options_text(response: &DeviceOptionsResponse) -> String { )); append_token_options(&mut lines, &response.filters); + lines.extend(render_trigger_guide_text(&response.trigger)); + lines.join("\n") } +fn render_trigger_guide_text(trigger: &TriggerGuideResponse) -> Vec { + let position = &trigger.position_percent; + vec![ + "trigger".to_string(), + format!( + " {} CH=COND[,CH=COND...] simple trigger; all conditions on one sample", + trigger.trigger_flag + ), + format!( + " {} PATH JSON trigger config (version {}): {}", + trigger.trigger_config_flag, + trigger.config_version, + trigger.modes.join(", ") + ), + format!( + " {} PERCENT {}..={} in buffer mode, default {}; stream mode fixes it at {}", + trigger.trigger_position_flag, + position.min_percent, + position.max_percent, + position.default_percent, + trigger.stream_mode.position_percent + ), + format!(" conditions: {}", trigger.conditions.join(", ")), + format!( + " limits: channels 0..{}, up to {} stages, stage count 1..={}, serial data up to {} bits", + trigger.channel_capacity, + trigger.max_stages, + trigger.max_stage_count, + trigger.max_serial_data_bits + ), + format!(" stream_mode: {} only", trigger.stream_mode.modes.join(", ")), + ] +} + fn build_current_values( current: &CurrentDeviceOptionValues, lookup_maps: &crate::capture_device_options::CaptureTokenLookupMaps, diff --git a/crates/dsview-cli/src/main.rs b/crates/dsview-cli/src/main.rs index c5e5fab..da8a27c 100644 --- a/crates/dsview-cli/src/main.rs +++ b/crates/dsview-cli/src/main.rs @@ -8,7 +8,7 @@ use std::sync::{ }; use std::time::Duration; -use clap::{Args, Parser, Subcommand, ValueEnum}; +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use dsview_cli::{ DecodeInspectResponse, DecodeListResponse, DecodeReportWriteError, DecodeValidateResponse, DeviceOptionsResponse, build_decode_contract_failure_report, @@ -35,11 +35,14 @@ use dsview_core::{ MetadataCaptureInfo, MetadataToolInfo, NativeErrorCode, OfflineDecodeInput, OfflineDecodeInputError, OfflineDecodeRunError, OperationModeValidationCapabilities, RuntimeError, SelectionHandle, SupportedDevice, ThresholdCapabilitySnapshot, - ValidatedCaptureConfig, ValidatedDecodeConfig, decode_inspect as core_decode_inspect, + TriggerConfigParseError, TriggerReport, TriggerRequest, TriggerValidationContext, + TriggerValidationError, ValidatedCaptureConfig, ValidatedDecodeConfig, ValidatedTrigger, + decode_inspect as core_decode_inspect, decode_list as core_decode_list, decode_registry_for_config, describe_native_error, parse_decode_config_slice, resolve_capture_artifact_paths, run_offline_decode as core_run_offline_decode, validate_decode_config, validate_decode_config_file as core_validate_decode_config_file, + parse_simple_trigger_tokens, parse_trigger_config, validate_trigger_request, validated_capture_config_from_device_options, }; use serde::Serialize; @@ -267,6 +270,8 @@ struct CaptureArgs { channels: Vec, #[command(flatten)] device_options: CaptureDeviceOptionArgs, + #[command(flatten)] + trigger: CaptureTriggerArgs, #[arg( long = "output", value_name = "PATH", @@ -340,6 +345,42 @@ struct CaptureDeviceOptionArgs { filter: Option, } +#[derive(Args, Debug, Clone, Default, PartialEq)] +#[command(next_help_heading = "Trigger")] +#[command(group( + ArgGroup::new("trigger_source") + .args(["trigger", "trigger_config"]) + .multiple(false) +))] +struct CaptureTriggerArgs { + #[arg( + long = "trigger", + value_name = "CH=COND[,CH=COND...]", + value_delimiter = ',', + help = "Arm a simple hardware trigger; every listed channel condition must hold on the same sample. Conditions: low, high, rising, falling, edge" + )] + trigger: Vec, + #[arg( + long = "trigger-config", + value_name = "PATH", + help = "Arm a simple, stage, or serial hardware trigger from a JSON trigger config file" + )] + trigger_config: Option, + #[arg( + long = "trigger-position", + value_name = "PERCENT", + requires = "trigger_source", + help = "Share of the capture kept before the trigger point: 1-90 in buffer mode (default 1); stream mode fixes it at 1" + )] + trigger_position: Option, +} + +impl CaptureTriggerArgs { + fn is_requested(&self) -> bool { + !self.trigger.is_empty() || self.trigger_config.is_some() + } +} + impl dsview_cli::capture_device_options::CaptureDeviceOptionInput for CaptureDeviceOptionArgs { fn operation_mode(&self) -> Option<&str> { self.operation_mode.as_deref() @@ -408,6 +449,8 @@ struct CaptureResponse { saw_terminal_normal_end: bool, cleanup_succeeded: bool, device_options: CaptureDeviceOptionFacts, + #[serde(skip_serializing_if = "Option::is_none")] + trigger: Option, artifacts: CaptureArtifactsResponse, } @@ -1281,7 +1324,10 @@ fn run_capture(args: CaptureArgs) -> Result<(), FailedCommand> { }; let handle = SelectionHandle::new(args.handle) .ok_or_else(|| command_error(args.runtime.format, invalid_handle_error()))?; + let trigger_request = load_trigger_request(&args.trigger) + .map_err(|error| command_error(args.runtime.format, error))?; let mut validated_device_options = None; + let mut validated_trigger = None; let mut device_options_snapshot = None; if uses_device_option_validation(&args) { let (snapshot, capabilities) = @@ -1315,6 +1361,17 @@ fn run_capture(args: CaptureArgs) -> Result<(), FailedCommand> { let validated = capabilities.validate_request(&request).map_err(|error| { command_error(args.runtime.format, classify_validation_error(&error)) })?; + if let Some(trigger_request) = trigger_request.as_ref() { + let context = TriggerValidationContext::from_device_options(&capabilities, &validated); + validated_trigger = Some( + validate_trigger_request(trigger_request, &context).map_err(|error| { + command_error( + args.runtime.format, + classify_trigger_validation_error(&error), + ) + })?, + ); + } validated_device_options = Some(validated); } #[cfg(debug_assertions)] @@ -1331,6 +1388,7 @@ fn run_capture(args: CaptureArgs) -> Result<(), FailedCommand> { validated_config, validated_device_options, device_options_snapshot, + validated_trigger, mode, ); } @@ -1347,6 +1405,7 @@ fn run_capture(args: CaptureArgs) -> Result<(), FailedCommand> { stop_after: args.duration_ms.map(Duration::from_millis), stop_on_interrupt: interrupt_flag.clone(), stream_vcd_path: streaming_vcd_path.clone(), + trigger: validated_trigger.clone(), wait_timeout: Duration::from_millis(args.wait_timeout_ms), poll_interval: Duration::from_millis(args.poll_interval_ms), }; @@ -1361,9 +1420,19 @@ fn run_capture(args: CaptureArgs) -> Result<(), FailedCommand> { })? }; let capture_started_at = std::time::SystemTime::now(); - let result = discovery - .run_capture(&run_request) - .map_err(|error| command_error(args.runtime.format, classify_capture_error(&error)))?; + let result = discovery.run_capture(&run_request).map_err(|error| { + let trigger_observation = validated_trigger + .as_ref() + .and_then(|_| discovery.trigger_observation().ok()); + command_error( + args.runtime.format, + classify_capture_error_with_trigger( + &error, + validated_trigger.as_ref(), + trigger_observation.as_ref(), + ), + ) + })?; let device_options_snapshot = if let Some(snapshot) = device_options_snapshot { snapshot } else { @@ -1396,6 +1465,7 @@ fn run_capture(args: CaptureArgs) -> Result<(), FailedCommand> { saw_terminal_normal_end: result.summary.saw_terminal_normal_end, cleanup_succeeded: result.cleanup.succeeded(), device_options: export.metadata.device_options.clone(), + trigger: export.metadata.trigger.clone(), artifacts: CaptureArtifactsResponse { vcd_path: export.vcd_path.display().to_string(), metadata_path: export.metadata_path.display().to_string(), @@ -1440,6 +1510,7 @@ fn streaming_vcd_temp_path(vcd_path: &Path) -> PathBuf { enum CaptureTestFixtureMode { Success, ApplyFailureFilter, + TriggerNotFired, } #[cfg(debug_assertions)] @@ -1453,6 +1524,7 @@ fn capture_test_fixture_mode(handle: SelectionHandle) -> Option Some(CaptureTestFixtureMode::ApplyFailureFilter), + Some("trigger-not-fired") => Some(CaptureTestFixtureMode::TriggerNotFired), Some(_) => Some(CaptureTestFixtureMode::Success), None => None, } @@ -1499,9 +1571,36 @@ fn run_capture_with_test_fixture( validated_config: ValidatedCaptureConfig, validated_device_options: Option, device_options_snapshot: Option, + validated_trigger: Option, mode: CaptureTestFixtureMode, ) -> Result<(), FailedCommand> { match mode { + CaptureTestFixtureMode::TriggerNotFired => { + let error = CaptureRunError::Timeout { + summary: AcquisitionSummary { + saw_terminal_normal_end: false, + terminal_event: AcquisitionTerminalEvent::None, + saw_logic_packet: false, + saw_end_packet: false, + saw_end_packet_ok: false, + is_collecting: true, + ..fixture_acquisition_summary() + }, + cleanup: CaptureCleanup { + stop_attempted: true, + stop_succeeded: true, + ..fixture_capture_cleanup() + }, + }; + Err(command_error( + format, + classify_capture_error_with_trigger( + &error, + validated_trigger.as_ref(), + Some(&dsview_core::TriggerObservation::default()), + ), + )) + } CaptureTestFixtureMode::ApplyFailureFilter => { let error = DeviceOptionApplyFailure { applied_steps: vec![ @@ -1540,13 +1639,18 @@ fn run_capture_with_test_fixture( &validated_config, ) }); + let trigger = validated_trigger.map(|requested| { + fixture_trigger_outcome(requested, validated_config.effective_sample_limit) + }); let capture = dsview_core::CaptureRunSummary { completion: CaptureCompletion::CleanSuccess, summary: fixture_acquisition_summary(), cleanup: fixture_capture_cleanup(), effective_device_options, streamed_vcd: None, + trigger, }; + let trigger_report = capture.trigger.as_ref().map(TriggerReport::from_outcome); let export_request = dsview_core::CaptureExportRequest { capture: capture.clone(), validated_config: validated_config.clone(), @@ -1570,8 +1674,14 @@ fn run_capture_with_test_fixture( ), ) })?; - write_capture_test_fixture_artifacts(artifact_paths, handle, &validated_config, &facts) - .map_err(|detail| { + write_capture_test_fixture_artifacts( + artifact_paths, + handle, + &validated_config, + &facts, + trigger_report.clone(), + ) + .map_err(|detail| { command_error( format, fixture_error_response("capture_test_fixture_write_failed", &detail), @@ -1585,6 +1695,7 @@ fn run_capture_with_test_fixture( saw_terminal_normal_end: capture.summary.saw_terminal_normal_end, cleanup_succeeded: capture.cleanup.succeeded(), device_options: facts, + trigger: trigger_report, artifacts: CaptureArtifactsResponse { vcd_path: artifact_paths.vcd_path.display().to_string(), metadata_path: artifact_paths.metadata_path.display().to_string(), @@ -1618,6 +1729,27 @@ fn fixture_acquisition_summary() -> AcquisitionSummary { } } +/// Synthetic trigger hit at the requested pre-trigger position. +#[cfg(debug_assertions)] +fn fixture_trigger_outcome( + requested: ValidatedTrigger, + sample_limit: u64, +) -> dsview_core::CaptureTriggerOutcome { + let real_pos = sample_limit * u64::from(requested.position_percent) / 100; + dsview_core::CaptureTriggerOutcome { + requested, + observation: dsview_core::TriggerObservation { + saw_trigger_packet: true, + packet_status: None, + position: Some(dsview_core::TriggerPosition { + raw_status: 1, + real_pos: u32::try_from(real_pos).unwrap_or(u32::MAX), + remain_count: 0, + }), + }, + } +} + #[cfg(debug_assertions)] fn fixture_capture_cleanup() -> CaptureCleanup { CaptureCleanup { @@ -1666,6 +1798,7 @@ fn write_capture_test_fixture_artifacts( handle: SelectionHandle, validated_config: &ValidatedCaptureConfig, facts: &CaptureDeviceOptionFacts, + trigger: Option, ) -> Result<(), String> { if let Some(parent) = artifact_paths.vcd_path.parent() { fs::create_dir_all(parent).map_err(|error| error.to_string())?; @@ -1705,6 +1838,7 @@ fn write_capture_test_fixture_artifacts( metadata_path: artifact_paths.metadata_path.display().to_string(), }, device_options: facts.clone(), + trigger, }; let metadata_bytes = serde_json::to_vec_pretty(&metadata).map_err(|error| error.to_string())?; fs::write(&artifact_paths.metadata_path, metadata_bytes).map_err(|error| error.to_string()) @@ -2110,8 +2244,108 @@ fn device_record(device: &SupportedDevice) -> DeviceRecord { } } +fn load_trigger_request(args: &CaptureTriggerArgs) -> Result, ErrorResponse> { + let spec = if let Some(path) = args.trigger_config.as_ref() { + let text = fs::read_to_string(path).map_err(|error| ErrorResponse { + code: "trigger_config_unreadable", + message: format!("failed to read trigger config `{}`", path.display()), + detail: Some(error.to_string()), + native_error: None, + terminal_event: None, + cleanup: None, + })?; + parse_trigger_config(&text) + .map_err(|error| classify_trigger_config_parse_error(&error, Some(path)))? + } else if !args.trigger.is_empty() { + parse_simple_trigger_tokens(&args.trigger) + .map_err(|error| classify_trigger_config_parse_error(&error, None))? + } else { + return Ok(None); + }; + + Ok(Some(TriggerRequest { + spec, + position_percent: args.trigger_position, + })) +} + +fn classify_trigger_config_parse_error( + error: &TriggerConfigParseError, + path: Option<&Path>, +) -> ErrorResponse { + ErrorResponse { + code: "trigger_config_invalid", + message: error.to_string(), + detail: path.map(|path| format!("trigger config `{}`", path.display())), + native_error: None, + terminal_event: None, + cleanup: None, + } +} + +fn classify_trigger_validation_error(error: &TriggerValidationError) -> ErrorResponse { + let code = match error { + TriggerValidationError::ChannelOutsideChannelMode { .. } => "trigger_channel_unsupported", + TriggerValidationError::ChannelNotEnabled { .. } => "trigger_channel_not_enabled", + TriggerValidationError::ModeUnsupportedInStream { .. } => "trigger_mode_unsupported", + TriggerValidationError::StreamPositionFixed { .. } + | TriggerValidationError::PositionOutOfRange { .. } => "trigger_position_out_of_range", + TriggerValidationError::EmptySimpleTrigger + | TriggerValidationError::StageCountOutOfRange { .. } + | TriggerValidationError::InvalidStage { .. } + | TriggerValidationError::InvalidSerial { .. } => "trigger_config_invalid", + }; + ErrorResponse { + code, + message: error.to_string(), + detail: None, + native_error: None, + terminal_event: None, + cleanup: None, + } +} + +/// Reports a capture that waited for a trigger that never fired as +/// `capture_trigger_not_fired` instead of a generic timeout or incomplete run. +fn classify_capture_error_with_trigger( + error: &CaptureRunError, + trigger: Option<&ValidatedTrigger>, + observation: Option<&dsview_core::TriggerObservation>, +) -> ErrorResponse { + let mut response = classify_capture_error(error); + let Some(trigger) = trigger else { + return response; + }; + let waited_for_trigger = matches!( + error, + CaptureRunError::Timeout { .. } | CaptureRunError::Incomplete { .. } + ); + let fired = observation.is_some_and(|observation| observation.fired()); + if waited_for_trigger && !fired { + let original_code = response.code; + let hint = if matches!(error, CaptureRunError::Timeout { .. }) { + "raise --wait-timeout-ms" + } else { + "extend --duration-ms or wait longer before interrupting" + }; + response.code = "capture_trigger_not_fired"; + response.message = format!( + "the {} trigger condition was not met before the capture stopped", + trigger.mode_name() + ); + response.detail = Some(format!( + "{original_code}: {}; trigger_packet={}; {hint}, or check the trigger condition and wiring", + response.detail.as_deref().unwrap_or("no detail"), + observation.is_some_and(|observation| observation.saw_trigger_packet), + )); + } + response +} + fn uses_device_option_validation(args: &CaptureArgs) -> bool { - !args.channels.is_empty() || args.device_options.has_overrides() + !args.channels.is_empty() + || args.device_options.has_overrides() + || args.trigger.is_requested() } #[cfg(debug_assertions)] @@ -3092,11 +3326,28 @@ pub(crate) fn capture_success_text(response: &CaptureResponse) -> String { lines.extend(render_effective_capture_options_text( &response.device_options, )); + if let Some(trigger) = response.trigger.as_ref() { + lines.push(trigger_report_text(trigger)); + } lines.push(format!("vcd {}", response.artifacts.vcd_path)); lines.push(format!("metadata {}", response.artifacts.metadata_path)); lines.join("\n") } +fn trigger_report_text(trigger: &TriggerReport) -> String { + let mode = trigger.requested.spec.mode_name(); + let position = trigger.requested.position_percent; + match trigger.observed.sample_index { + Some(sample_index) if trigger.observed.fired => { + format!("trigger {mode} fired at sample {sample_index} (position {position}%)") + } + _ => format!( + "trigger {mode} not reported as fired (position {position}%, trigger_packet={})", + trigger.observed.trigger_packet + ), + } +} + fn decode_run_failure_text( response: &DecodeRunFailureResponse, output_path: Option<&Path>, @@ -3812,6 +4063,7 @@ mod tests { sample_limit: 4096, channels: vec![0, 1, 2, 3], device_options: CaptureDeviceOptionArgs::default(), + trigger: CaptureTriggerArgs::default(), output: PathBuf::from("artifacts/run.vcd"), metadata_output: None, wait_timeout_ms: 10_000, @@ -3860,6 +4112,7 @@ mod tests { metadata_path: "/tmp/run.json".to_string(), }, device_options: sample_capture_device_option_facts(), + trigger: None, }; let payload = serde_json::to_value(response).unwrap(); @@ -3889,6 +4142,7 @@ mod tests { metadata_path: "/tmp/run.json".to_string(), }, device_options: sample_capture_device_option_facts(), + trigger: None, }; let text = capture_success_text(&response); diff --git a/crates/dsview-cli/tests/device_options_cli.rs b/crates/dsview-cli/tests/device_options_cli.rs index 378b3fc..13b98f4 100644 --- a/crates/dsview-cli/tests/device_options_cli.rs +++ b/crates/dsview-cli/tests/device_options_cli.rs @@ -370,7 +370,14 @@ fn device_options_text_shows_copy_paste_capture_flags() { "filters\n", " current: off | stable_id=filter:1 | native_code=1\n", " option: token=off | stable_id=filter:1 | native_code=1 | label=Off\n", - " option: token=1-sample | stable_id=filter:2 | native_code=2 | label=1 Sample" + " option: token=1-sample | stable_id=filter:2 | native_code=2 | label=1 Sample\n", + "trigger\n", + " --trigger CH=COND[,CH=COND...] simple trigger; all conditions on one sample\n", + " --trigger-config PATH JSON trigger config (version 1): simple, stage, serial\n", + " --trigger-position PERCENT 1..=90 in buffer mode, default 1; stream mode fixes it at 1\n", + " conditions: low, high, rising, falling, edge\n", + " limits: channels 0..16, up to 16 stages, stage count 1..=2147483647, serial data up to 16 bits\n", + " stream_mode: simple only" ); assert_eq!(rendered, expected); diff --git a/crates/dsview-cli/tests/trigger_cli.rs b/crates/dsview-cli/tests/trigger_cli.rs new file mode 100644 index 0000000..a7a127c --- /dev/null +++ b/crates/dsview-cli/tests/trigger_cli.rs @@ -0,0 +1,455 @@ +//! Capture trigger CLI contract tests. +//! +//! These run the debug-only capture fixture (handle 7 with +//! `DSVIEW_CLI_TEST_DEVICE_OPTIONS_FIXTURE`), so they exercise argument +//! parsing, trigger validation, and output shape without hardware. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use assert_cmd::Command; +use predicates::prelude::*; +use serde_json::{Value, json}; + +fn cli_command() -> Command { + Command::cargo_bin("dsview-cli").expect("dsview-cli binary should build for CLI tests") +} + +fn fixture_cli_command(fixture: &str) -> Command { + let mut command = cli_command(); + command.env("DSVIEW_CLI_TEST_DEVICE_OPTIONS_FIXTURE", fixture); + command +} + +fn unique_dir(stem: &str) -> PathBuf { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "dsview-cli-trigger-{stem}-{}-{timestamp}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("temporary dir should be created"); + dir +} + +fn parse_json(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).expect("stdout should contain valid JSON") +} + +/// Buffer-mode fixture capture of 4096 samples on channels 0-3. +fn buffer_capture_args(dir: &Path) -> Vec { + [ + "capture", + "--handle", + "7", + "--sample-rate-hz", + "100000000", + "--sample-limit", + "4096", + "--channels", + "0,1,2,3", + "--operation-mode", + "buffer", + "--channel-mode", + "buffer-100x16", + "--output", + ] + .into_iter() + .map(str::to_string) + .chain([dir.join("capture.vcd").display().to_string()]) + .collect() +} + +fn stream_capture_args(dir: &Path) -> Vec { + [ + "capture", + "--handle", + "7", + "--sample-rate-hz", + "100000000", + "--sample-limit", + "4096", + "--channels", + "0,1", + "--operation-mode", + "stream", + "--channel-mode", + "stream-100x16", + "--output", + ] + .into_iter() + .map(str::to_string) + .chain([dir.join("capture.vcd").display().to_string()]) + .collect() +} + +fn failure_code(output: &[u8]) -> String { + parse_json(output)["code"] + .as_str() + .expect("error JSON should carry a code") + .to_string() +} + +fn run_failure(args: &[String]) -> Value { + let output = fixture_cli_command("1") + .args(args) + .assert() + .failure() + .code(1) + .get_output() + .stdout + .clone(); + parse_json(&output) +} + +#[test] +fn capture_help_lists_trigger_flags() { + cli_command() + .args(["capture", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Trigger:")) + .stdout(predicate::str::contains("--trigger ")) + .stdout(predicate::str::contains("low, high, rising, falling, edge")) + .stdout(predicate::str::contains("--trigger-config ")) + .stdout(predicate::str::contains("--trigger-position ")); +} + +#[test] +fn simple_trigger_capture_reports_requested_and_observed_trigger() { + let dir = unique_dir("simple"); + let mut args = buffer_capture_args(&dir); + args.extend( + ["--trigger", "0=rising,D3=high", "--trigger-position", "25"] + .into_iter() + .map(str::to_string), + ); + + let output = fixture_cli_command("1") + .args(&args) + .assert() + .success() + .get_output() + .stdout + .clone(); + let payload = parse_json(&output); + let expected = json!({ + "requested": { + "mode": "simple", + "channels": {"0": "rising", "3": "high"}, + "position_percent": 25 + }, + "observed": { + "trigger_packet": true, + "fired": true, + "sample_index": 1024, + "raw_status": 1 + } + }); + assert_eq!(payload["completion"], "clean_success"); + assert_eq!(payload["trigger"], expected); + + let metadata = parse_json(&fs::read(dir.join("capture.json")).expect("metadata is written")); + assert_eq!(metadata["schema_version"], 2); + assert_eq!(metadata["trigger"], expected); +} + +#[test] +fn simple_trigger_text_output_reports_the_trigger_sample() { + let dir = unique_dir("text"); + let mut args = buffer_capture_args(&dir); + args.extend( + ["--trigger", "1=falling", "--format", "text"] + .into_iter() + .map(str::to_string), + ); + + fixture_cli_command("1") + .args(&args) + .assert() + .success() + .stdout(predicate::str::contains( + "trigger simple fired at sample 40 (position 1%)", + )); +} + +#[test] +fn capture_without_trigger_keeps_the_previous_output_shape() { + let dir = unique_dir("none"); + let output = fixture_cli_command("1") + .args(buffer_capture_args(&dir)) + .assert() + .success() + .get_output() + .stdout + .clone(); + assert!(parse_json(&output).get("trigger").is_none()); + + let metadata = parse_json(&fs::read(dir.join("capture.json")).expect("metadata is written")); + assert!(metadata.get("trigger").is_none()); +} + +#[test] +fn stage_trigger_config_file_is_reported_in_metadata() { + let dir = unique_dir("stage"); + let config = dir.join("trigger.json"); + fs::write( + &config, + r#"{ + "version": 1, + "mode": "stage", + "stages": [ + {"pattern_a": {"channels": {"0": "rising"}}}, + {"pattern_a": {"channels": {"1": "high", "2": "low"}}, "count": 3, "contiguous": true} + ] + }"#, + ) + .unwrap(); + let mut args = buffer_capture_args(&dir); + args.extend([ + "--trigger-config".to_string(), + config.display().to_string(), + "--trigger-position".to_string(), + "50".to_string(), + ]); + + let output = fixture_cli_command("1") + .args(&args) + .assert() + .success() + .get_output() + .stdout + .clone(); + let requested = &parse_json(&output)["trigger"]["requested"]; + assert_eq!(requested["mode"], "stage"); + assert_eq!(requested["position_percent"], 50); + assert_eq!(requested["stages"].as_array().unwrap().len(), 2); + assert_eq!( + requested["stages"][1], + json!({ + "pattern_a": {"channels": {"1": "high", "2": "low"}, "invert": false}, + "pattern_b": {"channels": {}, "invert": false}, + "combine": "and", + "count": 3, + "contiguous": true + }) + ); +} + +#[test] +fn serial_trigger_config_normalizes_the_data_value() { + let dir = unique_dir("serial"); + let config = dir.join("trigger.json"); + fs::write( + &config, + r#"{"mode":"serial","start":{"2":"rising"},"stop":{"2":"falling"},"clock":{"0":"rising"},"data_channel":1,"data_bits":8,"data_value":"0x5A"}"#, + ) + .unwrap(); + let mut args = buffer_capture_args(&dir); + args.extend(["--trigger-config".to_string(), config.display().to_string()]); + + let output = fixture_cli_command("1") + .args(&args) + .assert() + .success() + .get_output() + .stdout + .clone(); + let requested = &parse_json(&output)["trigger"]["requested"]; + assert_eq!(requested["mode"], "serial"); + assert_eq!(requested["data_channel"], 1); + assert_eq!(requested["data_value"], "01011010"); +} + +#[test] +fn serial_trigger_without_edge_flags_is_rejected() { + let dir = unique_dir("serial-levels"); + let config = dir.join("trigger.json"); + fs::write( + &config, + r#"{"mode":"serial","start":{"2":"high"},"stop":{"2":"low"},"clock":{"0":"rising"},"data_channel":1,"data_bits":4,"data_value":"0101"}"#, + ) + .unwrap(); + let mut args = buffer_capture_args(&dir); + args.extend(["--trigger-config".to_string(), config.display().to_string()]); + + let error = run_failure(&args); + assert_eq!(error["code"], "trigger_config_invalid"); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("start needs at least one edge") + ); +} + +#[test] +fn trigger_on_a_disabled_channel_is_rejected() { + let dir = unique_dir("disabled"); + let mut args = buffer_capture_args(&dir); + args.extend(["--trigger".to_string(), "5=high".to_string()]); + + let error = run_failure(&args); + assert_eq!(error["code"], "trigger_channel_not_enabled"); + assert!(error["message"].as_str().unwrap().contains("--channels")); +} + +#[test] +fn trigger_position_outside_the_buffer_range_is_rejected() { + let dir = unique_dir("position"); + let mut args = buffer_capture_args(&dir); + args.extend( + ["--trigger", "0=high", "--trigger-position", "95"] + .into_iter() + .map(str::to_string), + ); + + assert_eq!(run_failure(&args)["code"], "trigger_position_out_of_range"); +} + +#[test] +fn stream_mode_accepts_simple_trigger_but_rejects_stage_trigger_and_position() { + let dir = unique_dir("stream"); + + let mut simple = stream_capture_args(&dir); + simple.extend(["--trigger".to_string(), "0=rising".to_string()]); + let output = fixture_cli_command("1") + .args(&simple) + .assert() + .success() + .get_output() + .stdout + .clone(); + assert_eq!( + parse_json(&output)["trigger"]["requested"]["position_percent"], + 1 + ); + + let mut positioned = stream_capture_args(&dir); + positioned.extend( + ["--trigger", "0=rising", "--trigger-position", "10"] + .into_iter() + .map(str::to_string), + ); + assert_eq!( + run_failure(&positioned)["code"], + "trigger_position_out_of_range" + ); + + let config = dir.join("stage.json"); + fs::write( + &config, + r#"{"mode":"stage","stages":[{"pattern_a":{"channels":{"0":"rising"}}}]}"#, + ) + .unwrap(); + let mut stage = stream_capture_args(&dir); + stage.extend(["--trigger-config".to_string(), config.display().to_string()]); + assert_eq!(run_failure(&stage)["code"], "trigger_mode_unsupported"); +} + +#[test] +fn malformed_trigger_requests_fail_before_runtime_work() { + let dir = unique_dir("malformed"); + + for trigger in ["0=up", "rising", "x=high", "0=high,0=low"] { + let mut args = buffer_capture_args(&dir); + args.extend(["--trigger".to_string(), trigger.to_string()]); + // No fixture env: parsing must fail before any runtime or device work. + let output = cli_command() + .args(&args) + .assert() + .failure() + .code(1) + .get_output() + .stdout + .clone(); + assert_eq!(failure_code(&output), "trigger_config_invalid", "{trigger}"); + } + + let config = dir.join("bad.json"); + fs::write( + &config, + r#"{"mode":"simple","channels":{"0":"rising"},"bogus":true}"#, + ) + .unwrap(); + let mut args = buffer_capture_args(&dir); + args.extend(["--trigger-config".to_string(), config.display().to_string()]); + let output = cli_command() + .args(&args) + .assert() + .failure() + .code(1) + .get_output() + .stdout + .clone(); + assert_eq!(failure_code(&output), "trigger_config_invalid"); + + let mut args = buffer_capture_args(&dir); + args.extend([ + "--trigger-config".to_string(), + dir.join("missing.json").display().to_string(), + ]); + let output = cli_command() + .args(&args) + .assert() + .failure() + .code(1) + .get_output() + .stdout + .clone(); + assert_eq!(failure_code(&output), "trigger_config_unreadable"); +} + +#[test] +fn trigger_flags_conflict_and_position_requires_a_trigger() { + let dir = unique_dir("conflict"); + + let mut both = buffer_capture_args(&dir); + both.extend( + ["--trigger", "0=high", "--trigger-config", "trigger.json"] + .into_iter() + .map(str::to_string), + ); + cli_command() + .args(&both) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("cannot be used with")); + + let mut position_only = buffer_capture_args(&dir); + position_only.extend(["--trigger-position".to_string(), "10".to_string()]); + cli_command() + .args(&position_only) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains( + "required arguments were not provided", + )); +} + +#[test] +fn trigger_that_never_fires_reports_a_dedicated_error_code() { + let dir = unique_dir("not-fired"); + let mut args = buffer_capture_args(&dir); + args.extend(["--trigger".to_string(), "0=rising".to_string()]); + + let output = fixture_cli_command("trigger-not-fired") + .args(&args) + .assert() + .failure() + .code(1) + .get_output() + .stdout + .clone(); + let error = parse_json(&output); + assert_eq!(error["code"], "capture_trigger_not_fired"); + let detail = error["detail"].as_str().unwrap(); + assert!(detail.starts_with("capture_timeout"), "{detail}"); + assert!(detail.contains("--wait-timeout-ms"), "{detail}"); + assert_eq!(error["cleanup"]["stop_succeeded"], true); +} diff --git a/crates/dsview-core/src/lib.rs b/crates/dsview-core/src/lib.rs index 3464163..659a4ab 100644 --- a/crates/dsview-core/src/lib.rs +++ b/crates/dsview-core/src/lib.rs @@ -14,6 +14,7 @@ use std::time::{Duration, Instant, SystemTime}; mod capture_config; mod device_option_validation; mod device_options; +mod trigger; pub use capture_config::{ CaptureCapabilities, CaptureConfigError, CaptureConfigRequest, ChannelModeCapability, @@ -31,6 +32,17 @@ pub use device_options::{ normalize_device_options_snapshot, }; use dsview_sys::{AcquisitionPacketStatus, DecodeRuntimeBridge, RuntimeBridge}; +pub use dsview_sys::{TriggerObservation, TriggerPosition}; +pub use trigger::{ + CaptureTriggerOutcome, TRIGGER_CHANNEL_CAPACITY, TRIGGER_CONFIG_VERSION, + TRIGGER_MAX_SERIAL_DATA_BITS, TRIGGER_MAX_STAGE_COUNT, TRIGGER_MAX_STAGES, + TRIGGER_POSITION_DEFAULT_PERCENT, TRIGGER_POSITION_MAX_PERCENT, TRIGGER_POSITION_MIN_PERCENT, + TRIGGER_STREAM_POSITION_PERCENT, TriggerChannelConditions, TriggerCombine, TriggerCondition, + TriggerConfigParseError, TriggerObservedReport, TriggerPattern, TriggerReport, TriggerRequest, + TriggerRequestedReport, TriggerSerialSpec, TriggerSpec, TriggerStageSpec, + TriggerValidationContext, TriggerValidationError, ValidatedTrigger, build_trigger_program, + parse_simple_trigger_tokens, parse_trigger_config, validate_trigger_request, +}; pub use dsview_sys::{ AcquisitionSummary, AcquisitionTerminalEvent, DecodeExecutionLogicFormat, DecodeOptionValueKind, DecodeRuntimeError, DecodeRuntimeErrorCode, DecodeSessionChannelBinding, @@ -1626,6 +1638,7 @@ pub enum DeviceOptionApplyStep { EnabledChannels, SampleLimit, SampleRate, + Trigger, } impl DeviceOptionApplyStep { @@ -1639,6 +1652,7 @@ impl DeviceOptionApplyStep { Self::EnabledChannels => "enabled_channels", Self::SampleLimit => "sample_limit", Self::SampleRate => "sample_rate", + Self::Trigger => "trigger", } } } @@ -1694,6 +1708,8 @@ pub struct CaptureRunSummary { pub cleanup: CaptureCleanup, pub effective_device_options: Option, pub streamed_vcd: Option, + /// Requested trigger and device-reported trigger facts, when a trigger was armed. + pub trigger: Option, } #[derive(Debug, Clone, PartialEq)] @@ -1780,6 +1796,9 @@ pub struct CaptureMetadata { pub acquisition: MetadataAcquisitionInfo, pub artifacts: MetadataArtifactInfo, pub device_options: CaptureDeviceOptionFacts, + /// Present only when the capture armed a hardware trigger. + #[serde(skip_serializing_if = "Option::is_none")] + pub trigger: Option, } #[derive(Debug, Clone, PartialEq)] @@ -1822,6 +1841,8 @@ pub struct CaptureRunRequest { pub stop_after: Option, pub stop_on_interrupt: Option>, pub stream_vcd_path: Option, + /// Hardware trigger to arm; requires `validated_device_options`. + pub trigger: Option, pub wait_timeout: Duration, pub poll_interval: Duration, } @@ -2253,6 +2274,53 @@ pub fn apply_validated_device_options( }) } +/// Aborts an active streaming VCD and removes its partial temp file. +/// +/// A failed stream capture, for example one whose trigger never fired, must not +/// leave a truncated `*.streaming.tmp` file next to the requested output. +fn discard_streaming_vcd(runtime: &RuntimeBridge, stream_vcd_path: Option<&Path>) { + if let Some(path) = stream_vcd_path { + runtime.abort_streaming_vcd(); + let _ = fs::remove_file(path); + } +} + +/// Steps `apply_validated_device_options` completes for `request`, in order. +fn device_option_apply_steps(request: &ValidatedDeviceOptionRequest) -> Vec { + let mut steps = vec![DeviceOptionApplyStep::OperationMode]; + if request.stop_option_code.is_some() { + steps.push(DeviceOptionApplyStep::StopOption); + } + steps.push(DeviceOptionApplyStep::ChannelMode); + if request.threshold_volts.is_some() { + steps.push(DeviceOptionApplyStep::ThresholdVolts); + } + if request.filter_code.is_some() { + steps.push(DeviceOptionApplyStep::Filter); + } + steps.extend([ + DeviceOptionApplyStep::EnabledChannels, + DeviceOptionApplyStep::SampleLimit, + DeviceOptionApplyStep::SampleRate, + ]); + steps +} + +/// Programs the validated trigger after the device options it was validated against. +pub fn apply_validated_trigger( + runtime: &RuntimeBridge, + request: &ValidatedDeviceOptionRequest, + trigger: &ValidatedTrigger, +) -> Result<(), DeviceOptionApplyFailure> { + runtime + .apply_trigger(&trigger.program) + .map_err(|runtime_error| DeviceOptionApplyFailure { + applied_steps: device_option_apply_steps(request), + failed_step: DeviceOptionApplyStep::Trigger, + runtime_error, + }) +} + #[derive(Debug)] pub struct OpenedDevice<'a> { runtime: &'a RuntimeBridge, @@ -2500,9 +2568,7 @@ impl Discovery { .map_err(BringUpError::Runtime)?; self.begin_streaming_vcd_if_requested(&request, stream_vcd_path)?; if let Err(error) = self.runtime.register_acquisition_callbacks() { - if stream_vcd_path.is_some() { - self.runtime.abort_streaming_vcd(); - } + discard_streaming_vcd(&self.runtime, stream_vcd_path); return Err(BringUpError::Runtime(error).into()); } Ok(CaptureSession { opened }) @@ -2541,23 +2607,41 @@ impl Discovery { let effective_device_options = apply_capture_request_device_options(&self.runtime, request, total_channel_count)? .expect("option-aware session requires validated device options"); + if let (Some(trigger), Some(validated_device_options)) = ( + request.trigger.as_ref(), + request.validated_device_options.as_ref(), + ) { + apply_validated_trigger(&self.runtime, validated_device_options, trigger)?; + } self.runtime .reset_acquisition_summary() .map_err(BringUpError::Runtime)?; self.begin_streaming_vcd_if_requested(&request.config, request.stream_vcd_path.as_deref())?; if let Err(error) = self.runtime.register_acquisition_callbacks() { - if request.stream_vcd_path.is_some() { - self.runtime.abort_streaming_vcd(); - } + discard_streaming_vcd(&self.runtime, request.stream_vcd_path.as_deref()); return Err(BringUpError::Runtime(error).into()); } Ok((CaptureSession { opened }, effective_device_options)) } + /// Trigger header facts recorded during the latest acquisition. + pub fn trigger_observation(&self) -> Result { + self.runtime + .trigger_observation() + .map_err(BringUpError::Runtime) + } + pub fn run_capture( &self, request: &CaptureRunRequest, ) -> Result { + if request.trigger.is_some() && request.validated_device_options.is_none() { + return Err(CaptureRunError::BringUp(BringUpError::Runtime( + RuntimeError::InvalidArgument( + "a hardware trigger requires validated device options".to_string(), + ), + ))); + } let (session, effective_device_options) = if request.validated_device_options.is_some() { let (session, effective_device_options) = self.prepare_option_aware_capture_session(request)?; @@ -2584,9 +2668,7 @@ impl Discovery { .map_err(BringUpError::Runtime)?; if !started.start_status.is_ok() { let cleanup = session.cleanup(&self.runtime, true); - if request.stream_vcd_path.is_some() { - self.runtime.abort_streaming_vcd(); - } + discard_streaming_vcd(&self.runtime, request.stream_vcd_path.as_deref()); return Err(if cleanup.succeeded() { CaptureRunError::StartFailed { code: started.start_status, @@ -2671,11 +2753,18 @@ impl Discovery { classify_capture_completion(&summary) }; let cleanup = session.cleanup(&self.runtime, true); + // The observation only fails to read when the bridge is unloaded; report + // "no trigger header" then rather than discarding the capture. + let trigger = request + .trigger + .as_ref() + .map(|requested| CaptureTriggerOutcome { + requested: requested.clone(), + observation: self.runtime.trigger_observation().unwrap_or_default(), + }); if !cleanup.succeeded() { - if request.stream_vcd_path.is_some() { - self.runtime.abort_streaming_vcd(); - } + discard_streaming_vcd(&self.runtime, request.stream_vcd_path.as_deref()); return Err(CaptureRunError::CleanupFailed { during: capture_completion_stage(completion), summary, @@ -2695,7 +2784,7 @@ impl Discovery { .map_err(BringUpError::Runtime)?, }), _ => { - self.runtime.abort_streaming_vcd(); + discard_streaming_vcd(&self.runtime, Some(path.as_path())); None } } @@ -2712,6 +2801,7 @@ impl Discovery { cleanup, effective_device_options, streamed_vcd, + trigger, }), CaptureCompletion::StartFailure => Err(CaptureRunError::StartFailed { code: NativeErrorCode::from_raw(summary.start_status), @@ -3266,6 +3356,7 @@ fn build_capture_metadata( metadata_path: metadata_path.display().to_string(), }, device_options, + trigger: request.capture.trigger.as_ref().map(TriggerReport::from_outcome), }) } @@ -3813,6 +3904,7 @@ mod tests { }, effective_device_options: None, streamed_vcd: None, + trigger: None, }, validated_config: ValidatedCaptureConfig { sample_rate_hz: 100_000_000, diff --git a/crates/dsview-core/src/trigger.rs b/crates/dsview-core/src/trigger.rs new file mode 100644 index 0000000..a31f0ba --- /dev/null +++ b/crates/dsview-core/src/trigger.rs @@ -0,0 +1,1363 @@ +//! Hardware trigger configuration for DSLogic captures. +//! +//! This module parses trigger requests (CLI tokens or a JSON config file), +//! validates them against the resolved device options, and builds the native +//! trigger program that `dsview-sys` replays through the DSView setters. +//! The program layout mirrors what DSView's trigger dock writes for the same +//! settings, so a CLI trigger behaves like the equivalent GUI trigger. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; + +use dsview_sys::{ + TRIGGER_PROBE_CAPACITY, TRIGGER_STAGE_CAPACITY, TriggerObservation, TriggerProgram, + TriggerProgramMode, TriggerStageProgram, +}; + +use crate::capture_config::is_stream_mode_label; +use crate::device_option_validation::{ + DeviceOptionValidationCapabilities, ValidatedDeviceOptionRequest, +}; + +/// Version accepted in trigger config files. +pub const TRIGGER_CONFIG_VERSION: u32 = 1; +/// Default pre-trigger share of the capture, as in DSView. +pub const TRIGGER_POSITION_DEFAULT_PERCENT: u8 = 1; +/// Smallest trigger position DSView accepts. +pub const TRIGGER_POSITION_MIN_PERCENT: u8 = 1; +/// Largest buffer-mode trigger position (`DS_MAX_TRIG_PERCENT`). +pub const TRIGGER_POSITION_MAX_PERCENT: u8 = 90; +/// Stream mode fixes the trigger position at this value. +pub const TRIGGER_STREAM_POSITION_PERCENT: u8 = 1; +/// Number of sequential stages in the stage trigger. +pub const TRIGGER_MAX_STAGES: usize = TRIGGER_STAGE_CAPACITY; +/// Number of logic channels a trigger pattern covers. +pub const TRIGGER_CHANNEL_CAPACITY: u16 = TRIGGER_PROBE_CAPACITY as u16; +/// Widest serial trigger data word. +pub const TRIGGER_MAX_SERIAL_DATA_BITS: u8 = 16; +/// Largest stage counter DSView's trigger dock accepts. +pub const TRIGGER_MAX_STAGE_COUNT: u32 = i32::MAX as u32; + +const DONT_CARE: u8 = b'X'; + +/// Per-channel trigger condition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TriggerCondition { + Low, + High, + Rising, + Falling, + Edge, +} + +impl TriggerCondition { + pub const ALL: [Self; 5] = [ + Self::Low, + Self::High, + Self::Rising, + Self::Falling, + Self::Edge, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Low => "low", + Self::High => "high", + Self::Rising => "rising", + Self::Falling => "falling", + Self::Edge => "edge", + } + } + + /// The DSView pattern character for this condition. + /// True for conditions that match a transition rather than a level. + pub const fn is_edge(self) -> bool { + matches!(self, Self::Rising | Self::Falling | Self::Edge) + } + + pub const fn dsview_symbol(self) -> char { + match self { + Self::Low => '0', + Self::High => '1', + Self::Rising => 'R', + Self::Falling => 'F', + Self::Edge => 'C', + } + } + + /// Parses a condition name or its DSView character, ignoring case. + pub fn parse(token: &str) -> Option { + let token = token.trim(); + Self::ALL.into_iter().find(|condition| { + token.eq_ignore_ascii_case(condition.as_str()) + || token.eq_ignore_ascii_case(condition.dsview_symbol().encode_utf8(&mut [0; 4])) + }) + } + + const fn native(self) -> u8 { + self.dsview_symbol() as u8 + } +} + +impl<'de> Deserialize<'de> for TriggerCondition { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let token = String::deserialize(deserializer)?; + Self::parse(&token).ok_or_else(|| { + serde::de::Error::custom(format!( + "unknown trigger condition `{token}`; expected one of {}", + condition_names() + )) + }) + } +} + +fn condition_names() -> String { + TriggerCondition::ALL + .iter() + .map(|condition| condition.as_str()) + .collect::>() + .join(", ") +} + +/// Conditions keyed by logic channel index. Channels not listed are "don't care". +pub type TriggerChannelConditions = BTreeMap; + +/// How a stage combines its two patterns. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TriggerCombine { + #[default] + And, + Or, +} + +/// A set of channel conditions that must hold on one sample, optionally inverted. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +pub struct TriggerPattern { + pub channels: TriggerChannelConditions, + pub invert: bool, +} + +impl TriggerPattern { + pub fn is_empty(&self) -> bool { + self.channels.is_empty() + } +} + +/// One stage of a stage trigger. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TriggerStageSpec { + pub pattern_a: TriggerPattern, + pub pattern_b: TriggerPattern, + pub combine: TriggerCombine, + pub count: u32, + pub contiguous: bool, +} + +/// Serial trigger: match a data word sampled on one channel. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TriggerSerialSpec { + pub start: TriggerChannelConditions, + pub stop: TriggerChannelConditions, + pub clock: TriggerChannelConditions, + pub data_channel: u16, + pub data_bits: u8, + /// MSB-first bit pattern of `0`, `1`, and `X`, `data_bits` characters long. + pub data_value: String, +} + +/// A parsed trigger condition before device validation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum TriggerSpec { + Simple { channels: TriggerChannelConditions }, + Stage { stages: Vec }, + Serial(TriggerSerialSpec), +} + +impl TriggerSpec { + pub const fn mode_name(&self) -> &'static str { + match self { + Self::Simple { .. } => "simple", + Self::Stage { .. } => "stage", + Self::Serial(_) => "serial", + } + } + + fn referenced_channels(&self) -> BTreeSet { + let mut channels = BTreeSet::new(); + match self { + Self::Simple { + channels: conditions, + } => channels.extend(conditions.keys()), + Self::Stage { stages } => { + for stage in stages { + channels.extend(stage.pattern_a.channels.keys()); + channels.extend(stage.pattern_b.channels.keys()); + } + } + Self::Serial(serial) => { + channels.extend(serial.start.keys()); + channels.extend(serial.stop.keys()); + channels.extend(serial.clock.keys()); + channels.insert(serial.data_channel); + } + } + channels + } +} + +/// A trigger request: what to trigger on and where the trigger sits in the capture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TriggerRequest { + pub spec: TriggerSpec, + /// Pre-trigger share of the capture in percent; `None` uses the default. + pub position_percent: Option, +} + +/// Device facts a trigger request is validated against. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TriggerValidationContext { + pub operation_mode_id: String, + pub stream_mode: bool, + pub channel_mode_id: String, + /// Channels usable by the trigger in the selected channel mode (`0..limit`). + pub channel_limit: u16, + pub enabled_channels: BTreeSet, +} + +impl TriggerValidationContext { + pub fn from_device_options( + capabilities: &DeviceOptionValidationCapabilities, + request: &ValidatedDeviceOptionRequest, + ) -> Self { + let operation_mode = capabilities + .operation_modes + .iter() + .find(|mode| mode.id == request.operation_mode_id); + let stream_mode = operation_mode.is_some_and(|mode| is_stream_mode_label(&mode.label)); + let channel_limit = operation_mode + .and_then(|mode| { + mode.channel_modes + .iter() + .find(|channel_mode| channel_mode.id == request.channel_mode_id) + }) + .map(|channel_mode| channel_mode.max_enabled_channels) + .unwrap_or(capabilities.total_channel_count) + .min(TRIGGER_CHANNEL_CAPACITY); + + Self { + operation_mode_id: request.operation_mode_id.clone(), + stream_mode, + channel_mode_id: request.channel_mode_id.clone(), + channel_limit, + enabled_channels: request.enabled_channels.iter().copied().collect(), + } + } +} + +/// A trigger request accepted for the selected device options. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedTrigger { + pub spec: TriggerSpec, + pub position_percent: u8, + pub program: TriggerProgram, +} + +impl ValidatedTrigger { + pub const fn mode_name(&self) -> &'static str { + self.spec.mode_name() + } +} + +/// Trigger facts for one finished capture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CaptureTriggerOutcome { + pub requested: ValidatedTrigger, + pub observation: TriggerObservation, +} + +/// Requested trigger as reported in capture output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TriggerRequestedReport { + #[serde(flatten)] + pub spec: TriggerSpec, + pub position_percent: u8, +} + +/// Device-reported trigger facts as reported in capture output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TriggerObservedReport { + /// The device sent its trigger header (`SR_DF_TRIGGER`). + pub trigger_packet: bool, + /// The trigger header reported a trigger hit. + pub fired: bool, + /// Sample index of the trigger point in the exported capture. + pub sample_index: Option, + /// Raw `ds_trigger_pos.status` word. + pub raw_status: Option, +} + +/// The `trigger` block of capture metadata and CLI output. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TriggerReport { + pub requested: TriggerRequestedReport, + pub observed: TriggerObservedReport, +} + +impl TriggerReport { + pub fn from_outcome(outcome: &CaptureTriggerOutcome) -> Self { + let position = outcome.observation.position; + let fired = outcome.observation.fired(); + Self { + requested: TriggerRequestedReport { + spec: outcome.requested.spec.clone(), + position_percent: outcome.requested.position_percent, + }, + observed: TriggerObservedReport { + trigger_packet: outcome.observation.saw_trigger_packet, + fired, + sample_index: position + .filter(|_| fired) + .map(|position| u64::from(position.real_pos)), + raw_status: position.map(|position| position.raw_status), + }, + } + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum TriggerConfigParseError { + #[error("trigger condition `{token}` must use CHANNEL=CONDITION, for example `0=rising`")] + InvalidConditionToken { token: String }, + #[error("trigger channel `{token}` is not a channel index such as `0` or `D0`")] + InvalidChannel { token: String }, + #[error("unknown trigger condition `{token}`; expected one of {expected}")] + UnknownCondition { token: String, expected: String }, + #[error("trigger channel {channel} is listed more than once")] + DuplicateChannel { channel: u16 }, + #[error("trigger config is not valid JSON: {detail}")] + InvalidJson { detail: String }, + #[error("trigger config must be a JSON object")] + NotAnObject, + #[error("trigger config version {version} is not supported; expected {expected}")] + UnsupportedVersion { version: u64, expected: u32 }, + #[error("trigger config field `mode` must be one of simple, stage, serial")] + InvalidMode, + #[error("trigger config for mode `{mode}` is invalid: {detail}")] + InvalidField { mode: &'static str, detail: String }, + #[error("serial trigger data_value `{value}` is invalid: {detail}")] + InvalidSerialDataValue { value: String, detail: String }, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum TriggerValidationError { + #[error("simple trigger needs at least one channel condition")] + EmptySimpleTrigger, + #[error( + "trigger channel {channel} is not usable in channel mode `{channel_mode_id}`; it supports channels 0..{channel_limit}" + )] + ChannelOutsideChannelMode { + channel: u16, + channel_mode_id: String, + channel_limit: u16, + }, + #[error("trigger channel {channel} is not enabled; add it to --channels")] + ChannelNotEnabled { channel: u16 }, + #[error("trigger mode `{mode}` is not supported in stream mode; use a simple trigger")] + ModeUnsupportedInStream { mode: &'static str }, + #[error( + "trigger position {position_percent}% is not supported in stream mode; stream mode fixes it at {fixed}%" + )] + StreamPositionFixed { position_percent: u8, fixed: u8 }, + #[error("trigger position {position_percent}% is outside {min}..={max}%")] + PositionOutOfRange { + position_percent: u8, + min: u8, + max: u8, + }, + #[error("stage trigger needs 1..={max} stages, got {count}")] + StageCountOutOfRange { count: usize, max: usize }, + #[error("trigger stage {stage}: {detail}")] + InvalidStage { stage: usize, detail: String }, + #[error("serial trigger: {detail}")] + InvalidSerial { detail: String }, +} + +/// Parses `CHANNEL=CONDITION` tokens into a simple trigger. +pub fn parse_simple_trigger_tokens>( + tokens: &[S], +) -> Result { + let mut channels = TriggerChannelConditions::new(); + for token in tokens { + let token = token.as_ref().trim(); + let (channel_token, condition_token) = token + .split_once('=') + .or_else(|| token.split_once(':')) + .ok_or_else(|| TriggerConfigParseError::InvalidConditionToken { + token: token.to_string(), + })?; + let channel = parse_channel_token(channel_token)?; + let condition = TriggerCondition::parse(condition_token).ok_or_else(|| { + TriggerConfigParseError::UnknownCondition { + token: condition_token.trim().to_string(), + expected: condition_names(), + } + })?; + if channels.insert(channel, condition).is_some() { + return Err(TriggerConfigParseError::DuplicateChannel { channel }); + } + } + Ok(TriggerSpec::Simple { channels }) +} + +fn parse_channel_token(token: &str) -> Result { + let trimmed = token.trim(); + let digits = trimmed + .strip_prefix('D') + .or_else(|| trimmed.strip_prefix('d')) + .unwrap_or(trimmed); + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(TriggerConfigParseError::InvalidChannel { + token: trimmed.to_string(), + }); + } + digits + .parse::() + .map_err(|_| TriggerConfigParseError::InvalidChannel { + token: trimmed.to_string(), + }) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawSimpleConfig { + channels: BTreeMap, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawPattern { + #[serde(default)] + channels: BTreeMap, + #[serde(default)] + invert: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawStage { + pattern_a: RawPattern, + #[serde(default)] + pattern_b: Option, + #[serde(default)] + combine: TriggerCombine, + #[serde(default = "default_stage_count")] + count: u32, + #[serde(default)] + contiguous: bool, +} + +fn default_stage_count() -> u32 { + 1 +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawStageConfig { + stages: Vec, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum RawChannelRef { + Index(u16), + Token(String), +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum RawSerialValue { + Integer(u64), + Text(String), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawSerialConfig { + #[serde(default)] + start: BTreeMap, + #[serde(default)] + stop: BTreeMap, + clock: BTreeMap, + data_channel: RawChannelRef, + data_bits: u8, + data_value: RawSerialValue, +} + +/// Parses a version-1 trigger config document. +pub fn parse_trigger_config(json: &str) -> Result { + let value: Value = + serde_json::from_str(json).map_err(|error| TriggerConfigParseError::InvalidJson { + detail: error.to_string(), + })?; + let Value::Object(mut object) = value else { + return Err(TriggerConfigParseError::NotAnObject); + }; + + if let Some(version) = object.remove("version") { + let version = version.as_u64().unwrap_or(u64::MAX); + if version != u64::from(TRIGGER_CONFIG_VERSION) { + return Err(TriggerConfigParseError::UnsupportedVersion { + version, + expected: TRIGGER_CONFIG_VERSION, + }); + } + } + let mode = match object.remove("mode") { + Some(Value::String(mode)) => mode, + _ => return Err(TriggerConfigParseError::InvalidMode), + }; + let body = Value::Object(object); + + match mode.as_str() { + "simple" => { + let raw: RawSimpleConfig = from_body("simple", body)?; + Ok(TriggerSpec::Simple { + channels: convert_conditions(raw.channels)?, + }) + } + "stage" => { + let raw: RawStageConfig = from_body("stage", body)?; + let stages = raw + .stages + .into_iter() + .map(convert_stage) + .collect::, _>>()?; + Ok(TriggerSpec::Stage { stages }) + } + "serial" => { + let raw: RawSerialConfig = from_body("serial", body)?; + convert_serial(raw).map(TriggerSpec::Serial) + } + _ => Err(TriggerConfigParseError::InvalidMode), + } +} + +fn from_body Deserialize<'de>>( + mode: &'static str, + body: Value, +) -> Result { + serde_json::from_value(body).map_err(|error| TriggerConfigParseError::InvalidField { + mode, + detail: error.to_string(), + }) +} + +fn convert_conditions( + raw: BTreeMap, +) -> Result { + let mut channels = TriggerChannelConditions::new(); + for (token, condition) in raw { + let channel = parse_channel_token(&token)?; + if channels.insert(channel, condition).is_some() { + return Err(TriggerConfigParseError::DuplicateChannel { channel }); + } + } + Ok(channels) +} + +fn convert_pattern(raw: RawPattern) -> Result { + Ok(TriggerPattern { + channels: convert_conditions(raw.channels)?, + invert: raw.invert, + }) +} + +fn convert_stage(raw: RawStage) -> Result { + Ok(TriggerStageSpec { + pattern_a: convert_pattern(raw.pattern_a)?, + pattern_b: raw + .pattern_b + .map(convert_pattern) + .transpose()? + .unwrap_or_default(), + combine: raw.combine, + count: raw.count, + contiguous: raw.contiguous, + }) +} + +fn convert_serial(raw: RawSerialConfig) -> Result { + let data_channel = match raw.data_channel { + RawChannelRef::Index(index) => index, + RawChannelRef::Token(token) => parse_channel_token(&token)?, + }; + if raw.data_bits == 0 || raw.data_bits > TRIGGER_MAX_SERIAL_DATA_BITS { + return Err(TriggerConfigParseError::InvalidField { + mode: "serial", + detail: format!( + "data_bits must be 1..={TRIGGER_MAX_SERIAL_DATA_BITS}, got {}", + raw.data_bits + ), + }); + } + Ok(TriggerSerialSpec { + start: convert_conditions(raw.start)?, + stop: convert_conditions(raw.stop)?, + clock: convert_conditions(raw.clock)?, + data_channel, + data_bits: raw.data_bits, + data_value: normalize_serial_data_value(raw.data_value, raw.data_bits)?, + }) +} + +fn normalize_serial_data_value( + raw: RawSerialValue, + data_bits: u8, +) -> Result { + let width = usize::from(data_bits); + let invalid = |value: String, detail: String| TriggerConfigParseError::InvalidSerialDataValue { + value, + detail, + }; + let integer_bits = |value: u64, original: String| { + if width < 64 && value >> width != 0 { + return Err(invalid( + original, + format!("value does not fit in {data_bits} data bits"), + )); + } + Ok((0..width) + .rev() + .map(|bit| if value >> bit & 1 == 1 { '1' } else { '0' }) + .collect()) + }; + + match raw { + RawSerialValue::Integer(value) => integer_bits(value, value.to_string()), + RawSerialValue::Text(text) => { + let compact: String = text + .chars() + .filter(|character| *character != '_' && *character != ' ') + .collect(); + if let Some(hex) = compact + .strip_prefix("0x") + .or_else(|| compact.strip_prefix("0X")) + { + let value = u64::from_str_radix(hex, 16).map_err(|_| { + invalid(text.clone(), "hex digits expected after 0x".to_string()) + })?; + return integer_bits(value, text); + } + let bits = compact + .strip_prefix("0b") + .or_else(|| compact.strip_prefix("0B")) + .unwrap_or(&compact) + .to_ascii_uppercase(); + if bits.len() != width { + return Err(invalid( + text, + format!("bit pattern must have exactly {data_bits} characters"), + )); + } + if !bits.chars().all(|bit| matches!(bit, '0' | '1' | 'X')) { + return Err(invalid( + text, + "bit pattern may only contain 0, 1, and X".to_string(), + )); + } + Ok(bits) + } + } +} + +/// Validates a trigger request against the selected device options and builds +/// the native program. +pub fn validate_trigger_request( + request: &TriggerRequest, + context: &TriggerValidationContext, +) -> Result { + let position_percent = validate_position(request.position_percent, context)?; + + if context.stream_mode && !matches!(request.spec, TriggerSpec::Simple { .. }) { + return Err(TriggerValidationError::ModeUnsupportedInStream { + mode: request.spec.mode_name(), + }); + } + + match &request.spec { + TriggerSpec::Simple { channels } if channels.is_empty() => { + return Err(TriggerValidationError::EmptySimpleTrigger); + } + TriggerSpec::Stage { stages } => validate_stages(stages)?, + TriggerSpec::Serial(serial) => validate_serial(serial)?, + TriggerSpec::Simple { .. } => {} + } + + for channel in request.spec.referenced_channels() { + if channel >= context.channel_limit { + return Err(TriggerValidationError::ChannelOutsideChannelMode { + channel, + channel_mode_id: context.channel_mode_id.clone(), + channel_limit: context.channel_limit, + }); + } + if !context.enabled_channels.contains(&channel) { + return Err(TriggerValidationError::ChannelNotEnabled { channel }); + } + } + + Ok(ValidatedTrigger { + program: build_trigger_program(&request.spec, position_percent), + spec: request.spec.clone(), + position_percent, + }) +} + +fn validate_position( + position_percent: Option, + context: &TriggerValidationContext, +) -> Result { + if context.stream_mode { + return match position_percent { + None => Ok(TRIGGER_STREAM_POSITION_PERCENT), + Some(position) if position == TRIGGER_STREAM_POSITION_PERCENT => Ok(position), + Some(position) => Err(TriggerValidationError::StreamPositionFixed { + position_percent: position, + fixed: TRIGGER_STREAM_POSITION_PERCENT, + }), + }; + } + + let position = position_percent.unwrap_or(TRIGGER_POSITION_DEFAULT_PERCENT); + if !(TRIGGER_POSITION_MIN_PERCENT..=TRIGGER_POSITION_MAX_PERCENT).contains(&position) { + return Err(TriggerValidationError::PositionOutOfRange { + position_percent: position, + min: TRIGGER_POSITION_MIN_PERCENT, + max: TRIGGER_POSITION_MAX_PERCENT, + }); + } + Ok(position) +} + +fn validate_stages(stages: &[TriggerStageSpec]) -> Result<(), TriggerValidationError> { + if stages.is_empty() || stages.len() > TRIGGER_MAX_STAGES { + return Err(TriggerValidationError::StageCountOutOfRange { + count: stages.len(), + max: TRIGGER_MAX_STAGES, + }); + } + + for (index, stage) in stages.iter().enumerate() { + let invalid = |detail: &str| TriggerValidationError::InvalidStage { + stage: index, + detail: detail.to_string(), + }; + if stage.pattern_a.is_empty() && stage.pattern_b.is_empty() { + return Err(invalid( + "pattern_a or pattern_b needs at least one channel condition", + )); + } + // An empty pattern matches every sample and an inverted empty pattern + // matches none, so these combinations always or never fire. + if stage.combine == TriggerCombine::Or + && (stage.pattern_a.is_empty() || stage.pattern_b.is_empty()) + { + return Err(invalid( + "`or` with an empty pattern matches every sample; give both patterns conditions or use `and`", + )); + } + if (stage.pattern_a.invert && stage.pattern_a.is_empty()) + || (stage.pattern_b.invert && stage.pattern_b.is_empty()) + { + return Err(invalid( + "an inverted pattern needs at least one channel condition", + )); + } + if stage.count == 0 || stage.count > TRIGGER_MAX_STAGE_COUNT { + return Err(invalid(&format!( + "count must be 1..={TRIGGER_MAX_STAGE_COUNT}, got {}", + stage.count + ))); + } + } + Ok(()) +} + +fn validate_serial(serial: &TriggerSerialSpec) -> Result<(), TriggerValidationError> { + // On DSLogic Plus hardware a serial trigger whose start, stop, or clock + // flag has no edge condition never fires: an empty flag or a level-only + // flag keeps re-arming the serial engine. + for (flag, conditions) in [ + ("start", &serial.start), + ("stop", &serial.stop), + ("clock", &serial.clock), + ] { + if !conditions.values().any(|condition| condition.is_edge()) { + return Err(TriggerValidationError::InvalidSerial { + detail: format!( + "{flag} needs at least one edge condition (rising, falling, or edge); without one the hardware never fires" + ), + }); + } + } + if serial.data_bits == 0 || serial.data_bits > TRIGGER_MAX_SERIAL_DATA_BITS { + return Err(TriggerValidationError::InvalidSerial { + detail: format!( + "data_bits must be 1..={TRIGGER_MAX_SERIAL_DATA_BITS}, got {}", + serial.data_bits + ), + }); + } + if serial.data_value.len() != usize::from(serial.data_bits) { + return Err(TriggerValidationError::InvalidSerial { + detail: "data_value length must match data_bits".to_string(), + }); + } + Ok(()) +} + +fn native_pattern(conditions: &TriggerChannelConditions) -> [u8; TRIGGER_PROBE_CAPACITY] { + let mut pattern = [DONT_CARE; TRIGGER_PROBE_CAPACITY]; + for (&channel, condition) in conditions { + if let Some(slot) = pattern.get_mut(usize::from(channel)) { + *slot = condition.native(); + } + } + pattern +} + +/// Builds the native program DSView's trigger dock would write for `spec`. +pub fn build_trigger_program(spec: &TriggerSpec, position_percent: u8) -> TriggerProgram { + let position_percent = u16::from(position_percent); + match spec { + TriggerSpec::Simple { channels } => TriggerProgram { + mode: TriggerProgramMode::Simple, + position_percent, + stage_selector: 0, + simple: native_pattern(channels), + stages: Vec::new(), + }, + TriggerSpec::Stage { stages } => TriggerProgram { + mode: TriggerProgramMode::Stage, + position_percent, + stage_selector: stages.len().saturating_sub(1) as u16, + simple: [DONT_CARE; TRIGGER_PROBE_CAPACITY], + stages: stages.iter().map(stage_program).collect(), + }, + TriggerSpec::Serial(serial) => TriggerProgram { + mode: TriggerProgramMode::Serial, + position_percent, + // DSView keeps its stage selector at the default single stage here. + stage_selector: 0, + simple: [DONT_CARE; TRIGGER_PROBE_CAPACITY], + stages: serial_stage_programs(serial), + }, + } +} + +fn stage_program(stage: &TriggerStageSpec) -> TriggerStageProgram { + let and = u8::from(stage.combine == TriggerCombine::And); + TriggerStageProgram { + value0: native_pattern(&stage.pattern_a.channels), + value1: native_pattern(&stage.pattern_b.channels), + logic: (u8::from(stage.contiguous) << 1) | and, + inv0: stage.pattern_a.invert, + inv1: stage.pattern_b.invert, + count0: stage.count, + count1: 0, + } +} + +fn serial_stage_programs(serial: &TriggerSerialSpec) -> Vec { + let mut stages = vec![TriggerStageProgram::dont_care(); 4]; + + // Stage 0: frame start (value0) and stop (value1) flags. + stages[0].value0 = native_pattern(&serial.start); + stages[0].value1 = native_pattern(&serial.stop); + + // Stage 1: clock flag, sampled once per bit. + stages[1].value0 = native_pattern(&serial.clock); + stages[1].count0 = 1; + + // Stage 2: the data channel, marked with `0`. + let mut data_channel = [DONT_CARE; TRIGGER_PROBE_CAPACITY]; + if let Some(slot) = data_channel.get_mut(usize::from(serial.data_channel)) { + *slot = b'0'; + } + stages[2].value0 = data_channel; + + // Stage 3: the data word, bit k in slot k, and the bit count minus one. + let mut data_value = [DONT_CARE; TRIGGER_PROBE_CAPACITY]; + for (bit, symbol) in serial.data_value.bytes().rev().enumerate() { + if let Some(slot) = data_value.get_mut(bit) { + *slot = symbol; + } + } + stages[3].value0 = data_value; + stages[3].count0 = u32::from(serial.data_bits.saturating_sub(1)); + + stages +} + +#[cfg(test)] +mod tests { + use super::*; + + fn context(stream_mode: bool, channel_limit: u16, enabled: &[u16]) -> TriggerValidationContext { + TriggerValidationContext { + operation_mode_id: "operation-mode:0".to_string(), + stream_mode, + channel_mode_id: "channel-mode:20".to_string(), + channel_limit, + enabled_channels: enabled.iter().copied().collect(), + } + } + + fn pattern(conditions: &[(usize, u8)]) -> [u8; TRIGGER_PROBE_CAPACITY] { + let mut values = [DONT_CARE; TRIGGER_PROBE_CAPACITY]; + for &(probe, condition) in conditions { + values[probe] = condition; + } + values + } + + #[test] + fn condition_tokens_accept_names_and_dsview_symbols() { + for (token, expected) in [ + ("low", TriggerCondition::Low), + ("HIGH", TriggerCondition::High), + ("r", TriggerCondition::Rising), + ("F", TriggerCondition::Falling), + ("edge", TriggerCondition::Edge), + ("c", TriggerCondition::Edge), + ("0", TriggerCondition::Low), + ("1", TriggerCondition::High), + ] { + assert_eq!(TriggerCondition::parse(token), Some(expected), "{token}"); + } + assert_eq!(TriggerCondition::parse("x"), None); + assert_eq!(TriggerCondition::parse("up"), None); + } + + #[test] + fn simple_tokens_parse_channel_prefixes_and_reject_duplicates() { + let spec = parse_simple_trigger_tokens(&["0=rising", "D3=high", "d15:F"]).unwrap(); + assert_eq!( + spec, + TriggerSpec::Simple { + channels: BTreeMap::from([ + (0, TriggerCondition::Rising), + (3, TriggerCondition::High), + (15, TriggerCondition::Falling), + ]), + } + ); + + assert_eq!( + parse_simple_trigger_tokens(&["0=rising", "D0=low"]), + Err(TriggerConfigParseError::DuplicateChannel { channel: 0 }) + ); + assert!(matches!( + parse_simple_trigger_tokens(&["rising"]), + Err(TriggerConfigParseError::InvalidConditionToken { .. }) + )); + assert!(matches!( + parse_simple_trigger_tokens(&["x0=rising"]), + Err(TriggerConfigParseError::InvalidChannel { .. }) + )); + assert!(matches!( + parse_simple_trigger_tokens(&["0=up"]), + Err(TriggerConfigParseError::UnknownCondition { .. }) + )); + } + + #[test] + fn simple_program_places_conditions_by_channel() { + let spec = parse_simple_trigger_tokens(&["0=rising", "3=high", "7=edge"]).unwrap(); + let validated = validate_trigger_request( + &TriggerRequest { + spec, + position_percent: Some(25), + }, + &context(false, 16, &[0, 1, 2, 3, 7]), + ) + .unwrap(); + + assert_eq!(validated.position_percent, 25); + assert_eq!(validated.program.mode, TriggerProgramMode::Simple); + assert_eq!(validated.program.position_percent, 25); + assert_eq!( + validated.program.simple, + pattern(&[(0, b'R'), (3, b'1'), (7, b'C')]) + ); + assert!(validated.program.stages.is_empty()); + } + + #[test] + fn stage_config_parses_defaults_and_builds_dsview_layout() { + let spec = parse_trigger_config( + r#"{ + "version": 1, + "mode": "stage", + "stages": [ + {"pattern_a": {"channels": {"0": "rising", "1": "low"}}}, + { + "pattern_a": {"channels": {"D2": "high"}, "invert": true}, + "pattern_b": {"channels": {"3": "falling"}}, + "combine": "or", + "count": 4, + "contiguous": true + } + ] + }"#, + ) + .unwrap(); + let validated = validate_trigger_request( + &TriggerRequest { + spec, + position_percent: None, + }, + &context(false, 16, &[0, 1, 2, 3]), + ) + .unwrap(); + + let program = &validated.program; + assert_eq!(validated.position_percent, TRIGGER_POSITION_DEFAULT_PERCENT); + assert_eq!(program.mode, TriggerProgramMode::Stage); + assert_eq!(program.stage_selector, 1); + assert_eq!(program.stages.len(), 2); + assert_eq!(program.stages[0].value0, pattern(&[(0, b'R'), (1, b'0')])); + assert_eq!(program.stages[0].value1, pattern(&[])); + assert_eq!(program.stages[0].logic, 0b01); + assert_eq!(program.stages[0].count0, 1); + assert_eq!(program.stages[1].value0, pattern(&[(2, b'1')])); + assert_eq!(program.stages[1].value1, pattern(&[(3, b'F')])); + assert_eq!(program.stages[1].logic, 0b10); + assert!(program.stages[1].inv0); + assert!(!program.stages[1].inv1); + assert_eq!(program.stages[1].count0, 4); + } + + #[test] + fn serial_config_builds_the_four_dsview_stages() { + let spec = parse_trigger_config( + r#"{ + "mode": "serial", + "start": {"1": "falling", "3": "high"}, + "stop": {"1": "rising", "3": "high"}, + "clock": {"0": "rising"}, + "data_channel": "D2", + "data_bits": 8, + "data_value": "0xA5" + }"#, + ) + .unwrap(); + let TriggerSpec::Serial(serial) = &spec else { + panic!("serial spec expected"); + }; + assert_eq!(serial.data_value, "10100101"); + + let validated = validate_trigger_request( + &TriggerRequest { + spec, + position_percent: Some(10), + }, + &context(false, 16, &[0, 1, 2, 3]), + ) + .unwrap(); + let program = &validated.program; + assert_eq!(program.mode, TriggerProgramMode::Serial); + assert_eq!(program.stage_selector, 0); + assert_eq!(program.stages.len(), 4); + assert_eq!(program.stages[0].value0, pattern(&[(1, b'F'), (3, b'1')])); + assert_eq!(program.stages[0].value1, pattern(&[(1, b'R'), (3, b'1')])); + assert_eq!(program.stages[1].value0, pattern(&[(0, b'R')])); + assert_eq!(program.stages[1].count0, 1); + assert_eq!(program.stages[2].value0, pattern(&[(2, b'0')])); + assert_eq!( + program.stages[3].value0, + pattern(&[ + (0, b'1'), + (1, b'0'), + (2, b'1'), + (3, b'0'), + (4, b'0'), + (5, b'1'), + (6, b'0'), + (7, b'1'), + ]) + ); + assert_eq!(program.stages[3].count0, 7); + for stage in &program.stages { + assert_eq!(stage.logic, 1); + assert!(!stage.inv0 && !stage.inv1); + } + } + + #[test] + fn serial_data_value_accepts_bits_integers_and_wildcards() { + let parse = |value: &str, bits: u8| { + parse_trigger_config(&format!( + r#"{{"mode":"serial","start":{{"2":"rising"}},"stop":{{"2":"falling"}},"clock":{{"0":"rising"}},"data_channel":1,"data_bits":{bits},"data_value":{value}}}"# + )) + }; + let value_of = |spec: TriggerSpec| match spec { + TriggerSpec::Serial(serial) => serial.data_value, + _ => unreachable!(), + }; + + assert_eq!(value_of(parse(r#""01x1""#, 4).unwrap()), "01X1"); + assert_eq!(value_of(parse(r#""0b1010_0101""#, 8).unwrap()), "10100101"); + assert_eq!(value_of(parse("5", 4).unwrap()), "0101"); + assert!(matches!( + parse(r#""0x1FF""#, 8), + Err(TriggerConfigParseError::InvalidSerialDataValue { .. }) + )); + assert!(matches!( + parse(r#""0101""#, 8), + Err(TriggerConfigParseError::InvalidSerialDataValue { .. }) + )); + assert!(matches!( + parse(r#""01A1""#, 4), + Err(TriggerConfigParseError::InvalidSerialDataValue { .. }) + )); + assert!(matches!( + parse(r#""0""#, 17), + Err(TriggerConfigParseError::InvalidField { .. }) + )); + } + + #[test] + fn config_rejects_unknown_fields_versions_and_modes() { + assert!(matches!( + parse_trigger_config(r#"{"mode":"simple","channels":{"0":"rising"},"extra":1}"#), + Err(TriggerConfigParseError::InvalidField { mode: "simple", .. }) + )); + assert_eq!( + parse_trigger_config(r#"{"version":2,"mode":"simple","channels":{}}"#), + Err(TriggerConfigParseError::UnsupportedVersion { + version: 2, + expected: 1 + }) + ); + assert_eq!( + parse_trigger_config(r#"{"mode":"window"}"#), + Err(TriggerConfigParseError::InvalidMode) + ); + assert_eq!( + parse_trigger_config("[]"), + Err(TriggerConfigParseError::NotAnObject) + ); + assert!(matches!( + parse_trigger_config(r#"{"mode":"simple","channels":{"0":"up"}}"#), + Err(TriggerConfigParseError::InvalidField { .. }) + )); + } + + #[test] + fn validation_enforces_channels_position_and_stream_limits() { + let simple = |tokens: &[&str], position_percent| TriggerRequest { + spec: parse_simple_trigger_tokens(tokens).unwrap(), + position_percent, + }; + + assert_eq!( + validate_trigger_request(&simple(&["2=high"], None), &context(false, 16, &[0, 1])), + Err(TriggerValidationError::ChannelNotEnabled { channel: 2 }) + ); + assert!(matches!( + validate_trigger_request(&simple(&["5=high"], None), &context(false, 4, &[0, 5])), + Err(TriggerValidationError::ChannelOutsideChannelMode { + channel: 5, + channel_limit: 4, + .. + }) + )); + assert_eq!( + validate_trigger_request(&simple(&["0=high"], Some(91)), &context(false, 16, &[0])), + Err(TriggerValidationError::PositionOutOfRange { + position_percent: 91, + min: 1, + max: 90 + }) + ); + assert_eq!( + validate_trigger_request(&simple(&["0=high"], Some(0)), &context(false, 16, &[0])) + .unwrap_err(), + TriggerValidationError::PositionOutOfRange { + position_percent: 0, + min: 1, + max: 90 + } + ); + assert_eq!( + validate_trigger_request(&simple(&["0=high"], Some(10)), &context(true, 16, &[0])), + Err(TriggerValidationError::StreamPositionFixed { + position_percent: 10, + fixed: 1 + }) + ); + assert_eq!( + validate_trigger_request(&simple(&["0=high"], None), &context(true, 16, &[0])) + .unwrap() + .position_percent, + 1 + ); + assert_eq!( + validate_trigger_request(&simple(&[], None), &context(false, 16, &[0])), + Err(TriggerValidationError::EmptySimpleTrigger) + ); + + let stage = TriggerRequest { + spec: parse_trigger_config( + r#"{"mode":"stage","stages":[{"pattern_a":{"channels":{"0":"rising"}}}]}"#, + ) + .unwrap(), + position_percent: None, + }; + assert_eq!( + validate_trigger_request(&stage, &context(true, 16, &[0])), + Err(TriggerValidationError::ModeUnsupportedInStream { mode: "stage" }) + ); + } + + #[test] + fn stage_validation_rejects_patterns_that_always_or_never_fire() { + let validate = |json: &str| { + validate_trigger_request( + &TriggerRequest { + spec: parse_trigger_config(json).unwrap(), + position_percent: None, + }, + &context(false, 16, &[0, 1]), + ) + }; + + assert!(matches!( + validate(r#"{"mode":"stage","stages":[]}"#), + Err(TriggerValidationError::StageCountOutOfRange { count: 0, .. }) + )); + let seventeen = vec![r#"{"pattern_a":{"channels":{"0":"high"}}}"#; 17].join(","); + assert!(matches!( + validate(&format!(r#"{{"mode":"stage","stages":[{seventeen}]}}"#)), + Err(TriggerValidationError::StageCountOutOfRange { count: 17, .. }) + )); + assert!(matches!( + validate(r#"{"mode":"stage","stages":[{"pattern_a":{}}]}"#), + Err(TriggerValidationError::InvalidStage { stage: 0, .. }) + )); + assert!(matches!( + validate( + r#"{"mode":"stage","stages":[{"pattern_a":{"channels":{"0":"high"}},"combine":"or"}]}"# + ), + Err(TriggerValidationError::InvalidStage { stage: 0, .. }) + )); + assert!(matches!( + validate( + r#"{"mode":"stage","stages":[{"pattern_a":{"channels":{"0":"high"}},"pattern_b":{"invert":true}}]}"# + ), + Err(TriggerValidationError::InvalidStage { stage: 0, .. }) + )); + assert!(matches!( + validate( + r#"{"mode":"stage","stages":[{"pattern_a":{"channels":{"0":"high"}}},{"pattern_a":{"channels":{"1":"low"}},"count":0}]}"# + ), + Err(TriggerValidationError::InvalidStage { stage: 1, .. }) + )); + } + + #[test] + fn serial_validation_requires_edge_start_stop_and_clock_flags() { + let validate = |start: &str, stop: &str, clock: &str| { + validate_trigger_request( + &TriggerRequest { + spec: parse_trigger_config(&format!( + r#"{{"mode":"serial","start":{start},"stop":{stop},"clock":{clock},"data_channel":1,"data_bits":4,"data_value":"0101"}}"# + )) + .unwrap(), + position_percent: None, + }, + &context(false, 16, &[0, 1, 2]), + ) + }; + + assert!( + validate( + r#"{"2":"rising"}"#, + r#"{"2":"falling"}"#, + r#"{"0":"rising"}"# + ) + .is_ok() + ); + for (start, stop, clock, flag) in [ + ("{}", r#"{"2":"falling"}"#, r#"{"0":"rising"}"#, "start"), + (r#"{"2":"rising"}"#, "{}", r#"{"0":"rising"}"#, "stop"), + ( + r#"{"2":"high"}"#, + r#"{"2":"low"}"#, + r#"{"0":"rising"}"#, + "start", + ), + ( + r#"{"2":"rising"}"#, + r#"{"2":"falling"}"#, + r#"{"0":"high"}"#, + "clock", + ), + ] { + match validate(start, stop, clock) { + Err(TriggerValidationError::InvalidSerial { detail }) => { + assert!(detail.starts_with(flag), "{detail}"); + } + other => panic!("expected {flag} rejection, got {other:?}"), + } + } + } + + #[test] + fn report_exposes_sample_index_only_when_fired() { + let validated = validate_trigger_request( + &TriggerRequest { + spec: parse_simple_trigger_tokens(&["0=rising"]).unwrap(), + position_percent: Some(50), + }, + &context(false, 16, &[0]), + ) + .unwrap(); + let outcome = |raw_status| CaptureTriggerOutcome { + requested: validated.clone(), + observation: TriggerObservation { + saw_trigger_packet: true, + packet_status: Some(dsview_sys::AcquisitionPacketStatus::Ok), + position: Some(dsview_sys::TriggerPosition { + raw_status, + real_pos: 4096, + remain_count: 0, + }), + }, + }; + + let fired = TriggerReport::from_outcome(&outcome(1)); + assert!(fired.observed.fired); + assert_eq!(fired.observed.sample_index, Some(4096)); + let json = serde_json::to_value(&fired).unwrap(); + assert_eq!(json["requested"]["mode"], "simple"); + assert_eq!(json["requested"]["channels"]["0"], "rising"); + assert_eq!(json["requested"]["position_percent"], 50); + assert_eq!(json["observed"]["sample_index"], 4096); + + let missed = TriggerReport::from_outcome(&outcome(0)); + assert!(!missed.observed.fired); + assert_eq!(missed.observed.sample_index, None); + assert_eq!(missed.observed.raw_status, Some(0)); + } +} diff --git a/crates/dsview-core/tests/acquisition.rs b/crates/dsview-core/tests/acquisition.rs index d8f898c..4b05dfc 100644 --- a/crates/dsview-core/tests/acquisition.rs +++ b/crates/dsview-core/tests/acquisition.rs @@ -520,6 +520,7 @@ fn capture_without_validated_device_options_keeps_config_only_baseline() { stop_after: None, stop_on_interrupt: None, stream_vcd_path: None, + trigger: None, wait_timeout: Duration::from_millis(100), poll_interval: Duration::from_millis(10), }; diff --git a/crates/dsview-core/tests/export_artifacts.rs b/crates/dsview-core/tests/export_artifacts.rs index a425183..21766b4 100644 --- a/crates/dsview-core/tests/export_artifacts.rs +++ b/crates/dsview-core/tests/export_artifacts.rs @@ -46,6 +46,7 @@ fn clean_capture() -> CaptureRunSummary { }, effective_device_options: None, streamed_vcd: None, + trigger: None, } } @@ -355,6 +356,7 @@ fn metadata_sidecar_schema_uses_numeric_capture_fields_and_utc_timestamp() { sample_limit: 4096, }, }, + trigger: None, } ) .unwrap(); @@ -435,6 +437,7 @@ fn metadata_schema_version_is_2_when_device_option_facts_are_present() { }, device_options: build_capture_device_option_facts(&export_request(CaptureCompletion::CleanSuccess)) .expect("device option facts should build"), + trigger: None, }; assert_eq!(serde_json::to_value(metadata).unwrap()["schema_version"], 2); diff --git a/crates/dsview-sys/bridge_runtime.c b/crates/dsview-sys/bridge_runtime.c index 20b8648..3b2625c 100644 --- a/crates/dsview-sys/bridge_runtime.c +++ b/crates/dsview-sys/bridge_runtime.c @@ -32,6 +32,24 @@ typedef int (*ds_start_collect_fn)(void); typedef int (*ds_stop_collect_fn)(void); typedef int (*ds_is_collecting_fn)(void); typedef int (*ds_enable_device_channel_index_fn)(int channel_index, gboolean enable); +typedef int (*ds_trigger_reset_fn)(void); +typedef int (*ds_trigger_set_en_fn)(uint16_t enable); +typedef int (*ds_trigger_set_mode_fn)(uint16_t mode); +typedef int (*ds_trigger_set_pos_fn)(uint16_t position); +typedef int (*ds_trigger_set_stage_fn)(uint16_t stages); +typedef int (*ds_trigger_probe_set_fn)(uint16_t probe, unsigned char trigger0, unsigned char trigger1); +typedef int (*ds_trigger_stage_set_value_fn)(uint16_t stage, uint16_t probes, char *trigger0, char *trigger1); +typedef int (*ds_trigger_stage_set_logic_fn)(uint16_t stage, uint16_t probes, unsigned char trigger_logic); +typedef int (*ds_trigger_stage_set_inv_fn)( + uint16_t stage, + uint16_t probes, + unsigned char trigger0_inv, + unsigned char trigger1_inv); +typedef int (*ds_trigger_stage_set_count_fn)( + uint16_t stage, + uint16_t probes, + uint32_t trigger0_count, + uint32_t trigger1_count); typedef const struct sr_output_module *(*sr_output_find_fn)(char *id); typedef const struct sr_output *(*sr_output_new_fn)(const struct sr_output_module *omod, GHashTable *options, const struct sr_dev_inst *sdi); typedef int (*sr_output_send_fn)(const struct sr_output *o, const struct sr_datafeed_packet *packet, GString **out); @@ -112,6 +130,18 @@ struct dsview_bridge_api { ds_get_actived_device_config_list_fn ds_get_actived_device_config_list; ds_set_actived_device_config_fn ds_set_actived_device_config; ds_enable_device_channel_index_fn ds_enable_device_channel_index; + ds_trigger_reset_fn ds_trigger_reset; + ds_trigger_set_en_fn ds_trigger_set_en; + ds_trigger_set_mode_fn ds_trigger_set_mode; + ds_trigger_set_pos_fn ds_trigger_set_pos; + ds_trigger_set_stage_fn ds_trigger_set_stage; + ds_trigger_probe_set_fn ds_trigger_probe_set; + ds_trigger_stage_set_value_fn ds_trigger_stage_set_value; + ds_trigger_stage_set_logic_fn ds_trigger_stage_set_logic; + ds_trigger_stage_set_inv_fn ds_trigger_stage_set_inv; + ds_trigger_stage_set_count_fn ds_trigger_stage_set_count; + /* Optional data export; only used to read the programmed state back. */ + struct ds_trigger **trigger_state; sr_output_find_fn sr_output_find; sr_output_new_fn sr_output_new; sr_output_send_fn sr_output_send; @@ -211,6 +241,7 @@ static struct dsview_bridge_acquisition_summary g_acquisition_summary; static int g_acquisition_callback_registration_active = 0; static struct dsview_recorded_stream g_recorded_stream; static struct dsview_streaming_vcd_writer g_streaming_vcd_writer; +static struct dsview_trigger_observation g_trigger_observation; #define DSVIEW_BRIDGE_INITIAL_PACKET_CAPACITY 1024U static uint8_t g_enabled_channel_state[DSVIEW_BRIDGE_CHANNEL_TRACK_CAPACITY]; @@ -218,6 +249,7 @@ static uint8_t g_enabled_channel_state[DSVIEW_BRIDGE_CHANNEL_TRACK_CAPACITY]; static void dsview_bridge_clear_registered_callbacks(void); static void dsview_bridge_reset_recorded_stream(void); static void dsview_bridge_reset_streaming_vcd_writer(void); +static void dsview_bridge_reset_trigger_observation(void); static int dsview_bridge_prepare_recording_capacity(void); static int dsview_bridge_record_packet(const struct sr_datafeed_packet *packet); static int dsview_bridge_export_stream(const struct dsview_vcd_export_request *request, const struct dsview_recorded_stream *stream, struct dsview_export_buffer *out_buffer); @@ -3075,6 +3107,79 @@ int dsview_bridge_load_library(const char *path) return status; } + g_bridge_api.ds_trigger_reset = + (ds_trigger_reset_fn)dsview_bridge_load_symbol("ds_trigger_reset", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_set_en = + (ds_trigger_set_en_fn)dsview_bridge_load_symbol("ds_trigger_set_en", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_set_mode = + (ds_trigger_set_mode_fn)dsview_bridge_load_symbol("ds_trigger_set_mode", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_set_pos = + (ds_trigger_set_pos_fn)dsview_bridge_load_symbol("ds_trigger_set_pos", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_set_stage = + (ds_trigger_set_stage_fn)dsview_bridge_load_symbol("ds_trigger_set_stage", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_probe_set = + (ds_trigger_probe_set_fn)dsview_bridge_load_symbol("ds_trigger_probe_set", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_stage_set_value = + (ds_trigger_stage_set_value_fn)dsview_bridge_load_symbol("ds_trigger_stage_set_value", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_stage_set_logic = + (ds_trigger_stage_set_logic_fn)dsview_bridge_load_symbol("ds_trigger_stage_set_logic", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_stage_set_inv = + (ds_trigger_stage_set_inv_fn)dsview_bridge_load_symbol("ds_trigger_stage_set_inv", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.ds_trigger_stage_set_count = + (ds_trigger_stage_set_count_fn)dsview_bridge_load_symbol("ds_trigger_stage_set_count", &status); + if (status != DSVIEW_BRIDGE_OK) { + dsview_bridge_unload_library(); + return status; + } + + g_bridge_api.trigger_state = + (struct ds_trigger **)dsview_bridge_dlsym(g_bridge_api.library_handle, "trigger"); + g_bridge_api.sr_output_find = (sr_output_find_fn)dsview_bridge_load_symbol("sr_output_find", &status); if (status != DSVIEW_BRIDGE_OK) { @@ -3119,6 +3224,7 @@ void dsview_bridge_unload_library(void) memset(&g_bridge_api, 0, sizeof(g_bridge_api)); memset(&g_acquisition_summary, 0, sizeof(g_acquisition_summary)); + dsview_bridge_reset_trigger_observation(); } int dsview_bridge_is_loaded(void) @@ -3782,6 +3888,37 @@ static void dsview_bridge_event_callback(int event) } } +static void dsview_bridge_reset_trigger_observation(void) +{ + memset(&g_trigger_observation, 0, sizeof(g_trigger_observation)); + g_trigger_observation.packet_status = -1; +} + +static void dsview_bridge_record_trigger_packet(const struct sr_datafeed_packet *packet) +{ + const struct ds_trigger_pos *position; + + /* A logic capture reports one trigger header; keep the first one. */ + if (g_trigger_observation.saw_trigger_packet) { + return; + } + + g_trigger_observation.saw_trigger_packet = 1; + g_trigger_observation.packet_status = packet->status; + + position = (const struct ds_trigger_pos *)packet->payload; + if (position == NULL || packet->status != SR_PKT_OK) { + return; + } + + /* The payload is the USB transfer buffer, which is freed after forwarding. */ + g_trigger_observation.has_position = 1; + g_trigger_observation.raw_status = position->status; + g_trigger_observation.real_pos = position->real_pos; + g_trigger_observation.remain_count = + ((unsigned long long)position->remain_cnt_h << 32) | (unsigned long long)position->remain_cnt_l; +} + static void dsview_bridge_datafeed_callback(const struct sr_dev_inst *sdi, const struct sr_datafeed_packet *packet) { int status; @@ -3794,6 +3931,8 @@ static void dsview_bridge_datafeed_callback(const struct sr_dev_inst *sdi, const if (packet->type == SR_DF_LOGIC) { g_acquisition_summary.saw_logic_packet = 1; + } else if (packet->type == SR_DF_TRIGGER) { + dsview_bridge_record_trigger_packet(packet); } else if (packet->type == SR_DF_END) { g_acquisition_summary.saw_end_packet = 1; g_acquisition_summary.end_packet_status = packet->status; @@ -3929,6 +4068,7 @@ int dsview_bridge_ds_reset_acquisition_summary(void) g_acquisition_summary.terminal_event = DSVIEW_ACQ_TERMINAL_NONE; g_acquisition_summary.end_packet_status = -1; g_acquisition_summary.callback_registration_active = g_acquisition_callback_registration_active ? 1 : 0; + dsview_bridge_reset_trigger_observation(); dsview_bridge_reset_recorded_stream(); dsview_bridge_refresh_last_error(); dsview_bridge_refresh_collecting_flag(); @@ -3947,6 +4087,237 @@ int dsview_bridge_ds_get_acquisition_summary(struct dsview_bridge_acquisition_su return SR_OK; } +static int dsview_bridge_trigger_symbols_loaded(void) +{ + return g_bridge_api.ds_trigger_reset != NULL + && g_bridge_api.ds_trigger_set_en != NULL + && g_bridge_api.ds_trigger_set_mode != NULL + && g_bridge_api.ds_trigger_set_pos != NULL + && g_bridge_api.ds_trigger_set_stage != NULL + && g_bridge_api.ds_trigger_probe_set != NULL + && g_bridge_api.ds_trigger_stage_set_value != NULL + && g_bridge_api.ds_trigger_stage_set_logic != NULL + && g_bridge_api.ds_trigger_stage_set_inv != NULL + && g_bridge_api.ds_trigger_stage_set_count != NULL; +} + +static int dsview_bridge_trigger_condition_is_valid(char value) +{ + switch (value) { + case 'X': + case '0': + case '1': + case 'R': + case 'F': + case 'C': + return 1; + default: + return 0; + } +} + +static int dsview_bridge_trigger_pattern_is_valid(const char *values) +{ + size_t index; + + for (index = 0; index < DSVIEW_TRIGGER_PROBE_CAPACITY; index++) { + if (!dsview_bridge_trigger_condition_is_valid(values[index])) { + return 0; + } + } + return 1; +} + +static int dsview_bridge_trigger_program_is_valid(const struct dsview_trigger_program *program) +{ + uint16_t stage; + + if (program->mode != DSVIEW_TRIGGER_MODE_SIMPLE + && program->mode != DSVIEW_TRIGGER_MODE_STAGE + && program->mode != DSVIEW_TRIGGER_MODE_SERIAL) { + return 0; + } + /* The DSView setters assert on these bounds, so reject them up front. */ + if (program->position_percent > 100 + || program->stage_count > DSVIEW_TRIGGER_STAGE_CAPACITY + || program->stage_selector >= TriggerStages) { + return 0; + } + if (program->mode != DSVIEW_TRIGGER_MODE_SIMPLE && program->stage_count == 0) { + return 0; + } + if (!dsview_bridge_trigger_pattern_is_valid(program->simple)) { + return 0; + } + for (stage = 0; stage < program->stage_count; stage++) { + const struct dsview_trigger_stage_program *entry = &program->stages[stage]; + if (!dsview_bridge_trigger_pattern_is_valid(entry->value0) + || !dsview_bridge_trigger_pattern_is_valid(entry->value1) + || entry->logic > 3 + || entry->inv0 > 1 + || entry->inv1 > 1) { + return 0; + } + } + return 1; +} + +static void dsview_bridge_format_trigger_pattern(const char *values, char *out) +{ + size_t index; + + /* + * ds_trigger_stage_set_value() reads character (j * 2) of a + * space-separated, MSB-first string into probe (probes - j - 1). + */ + memset(out, ' ', DSVIEW_TRIGGER_PROBE_CAPACITY * 2); + for (index = 0; index < DSVIEW_TRIGGER_PROBE_CAPACITY; index++) { + out[index * 2] = values[DSVIEW_TRIGGER_PROBE_CAPACITY - 1 - index]; + } + out[DSVIEW_TRIGGER_PROBE_CAPACITY * 2 - 1] = '\0'; +} + +static int dsview_bridge_program_trigger(const struct dsview_trigger_program *program) +{ + int status; + uint16_t probe; + uint16_t stage; + char pattern0[DSVIEW_TRIGGER_PROBE_CAPACITY * 2]; + char pattern1[DSVIEW_TRIGGER_PROBE_CAPACITY * 2]; + + status = g_bridge_api.ds_trigger_set_pos(program->position_percent); + if (status != SR_OK) { + return status; + } + status = g_bridge_api.ds_trigger_set_mode((uint16_t)program->mode); + if (status != SR_OK) { + return status; + } + + for (probe = 0; probe < DSVIEW_TRIGGER_PROBE_CAPACITY; probe++) { + status = g_bridge_api.ds_trigger_probe_set(probe, (unsigned char)program->simple[probe], 'X'); + if (status != SR_OK) { + return status; + } + } + + if (program->mode != DSVIEW_TRIGGER_MODE_SIMPLE) { + status = g_bridge_api.ds_trigger_set_stage(program->stage_selector); + if (status != SR_OK) { + return status; + } + + for (stage = 0; stage < program->stage_count; stage++) { + const struct dsview_trigger_stage_program *entry = &program->stages[stage]; + + dsview_bridge_format_trigger_pattern(entry->value0, pattern0); + dsview_bridge_format_trigger_pattern(entry->value1, pattern1); + status = g_bridge_api.ds_trigger_stage_set_value( + stage, DSVIEW_TRIGGER_PROBE_CAPACITY, pattern0, pattern1); + if (status != SR_OK) { + return status; + } + status = g_bridge_api.ds_trigger_stage_set_logic( + stage, DSVIEW_TRIGGER_PROBE_CAPACITY, entry->logic); + if (status != SR_OK) { + return status; + } + status = g_bridge_api.ds_trigger_stage_set_inv( + stage, DSVIEW_TRIGGER_PROBE_CAPACITY, entry->inv0, entry->inv1); + if (status != SR_OK) { + return status; + } + status = g_bridge_api.ds_trigger_stage_set_count( + stage, DSVIEW_TRIGGER_PROBE_CAPACITY, entry->count0, entry->count1); + if (status != SR_OK) { + return status; + } + } + } + + return g_bridge_api.ds_trigger_set_en(1); +} + +int dsview_bridge_ds_apply_trigger(const struct dsview_trigger_program *program) +{ + int status; + + if (program == NULL) { + return DSVIEW_BRIDGE_ERR_ARG; + } + if (!dsview_bridge_trigger_symbols_loaded()) { + return DSVIEW_BRIDGE_ERR_NOT_LOADED; + } + if (program->enabled && !dsview_bridge_trigger_program_is_valid(program)) { + return DSVIEW_BRIDGE_ERR_ARG; + } + + status = g_bridge_api.ds_trigger_reset(); + if (status != SR_OK || !program->enabled) { + return status; + } + + status = dsview_bridge_program_trigger(program); + if (status != SR_OK) { + /* Never leave a partially programmed trigger armed. */ + g_bridge_api.ds_trigger_reset(); + } + return status; +} + +int dsview_bridge_ds_get_trigger_state(struct dsview_trigger_state *out_state) +{ + const struct ds_trigger *state; + size_t probe; + size_t stage; + + if (out_state == NULL) { + return DSVIEW_BRIDGE_ERR_ARG; + } + if (g_bridge_api.trigger_state == NULL) { + return DSVIEW_BRIDGE_ERR_NOT_LOADED; + } + + state = *g_bridge_api.trigger_state; + if (state == NULL) { + return SR_ERR_CALL_STATUS; + } + + memset(out_state, 0, sizeof(*out_state)); + out_state->enabled = state->trigger_en; + out_state->mode = state->trigger_mode; + out_state->position_percent = state->trigger_pos; + out_state->stage_selector = state->trigger_stages; + for (probe = 0; probe < DSVIEW_TRIGGER_PROBE_CAPACITY; probe++) { + out_state->simple0[probe] = state->trigger0[TriggerStages][probe]; + out_state->simple1[probe] = state->trigger1[TriggerStages][probe]; + } + for (stage = 0; stage < DSVIEW_TRIGGER_STAGE_CAPACITY; stage++) { + struct dsview_trigger_stage_program *entry = &out_state->stages[stage]; + + for (probe = 0; probe < DSVIEW_TRIGGER_PROBE_CAPACITY; probe++) { + entry->value0[probe] = state->trigger0[stage][probe]; + entry->value1[probe] = state->trigger1[stage][probe]; + } + entry->logic = state->trigger_logic[stage]; + entry->inv0 = state->trigger0_inv[stage]; + entry->inv1 = state->trigger1_inv[stage]; + entry->count0 = state->trigger0_count[stage]; + entry->count1 = state->trigger1_count[stage]; + } + return SR_OK; +} + +int dsview_bridge_ds_get_trigger_observation(struct dsview_trigger_observation *out_observation) +{ + if (out_observation == NULL) { + return DSVIEW_BRIDGE_ERR_ARG; + } + + *out_observation = g_trigger_observation; + return SR_OK; +} + static int dsview_bridge_build_vcd_device(const struct dsview_vcd_export_request *request, struct sr_dev_inst **out_sdi) { struct sr_dev_inst *sdi; diff --git a/crates/dsview-sys/native/windows/dsview_runtime.def b/crates/dsview-sys/native/windows/dsview_runtime.def index 58d0581..1f64552 100644 --- a/crates/dsview-sys/native/windows/dsview_runtime.def +++ b/crates/dsview-sys/native/windows/dsview_runtime.def @@ -17,8 +17,19 @@ EXPORTS ds_set_firmware_resource_dir ds_start_collect ds_stop_collect + ds_trigger_probe_set + ds_trigger_reset + ds_trigger_set_en + ds_trigger_set_mode + ds_trigger_set_pos + ds_trigger_set_stage + ds_trigger_stage_set_count + ds_trigger_stage_set_inv + ds_trigger_stage_set_logic + ds_trigger_stage_set_value sr_get_lib_version_string sr_output_find sr_output_free sr_output_new sr_output_send + trigger DATA diff --git a/crates/dsview-sys/src/lib.rs b/crates/dsview-sys/src/lib.rs index 959c9af..0a13fcf 100644 --- a/crates/dsview-sys/src/lib.rs +++ b/crates/dsview-sys/src/lib.rs @@ -100,6 +100,11 @@ unsafe extern "C" { fn dsview_bridge_ds_is_collecting(value: *mut c_int) -> c_int; fn dsview_bridge_ds_reset_acquisition_summary() -> c_int; fn dsview_bridge_ds_get_acquisition_summary(out_summary: *mut RawAcquisitionSummary) -> c_int; + fn dsview_bridge_ds_apply_trigger(program: *const RawTriggerProgram) -> c_int; + fn dsview_bridge_ds_get_trigger_state(out_state: *mut RawTriggerState) -> c_int; + fn dsview_bridge_ds_get_trigger_observation( + out_observation: *mut RawTriggerObservation, + ) -> c_int; fn dsview_bridge_ds_export_recorded_vcd( request: *const RawVcdExportRequest, out_buffer: *mut RawExportBuffer, @@ -759,6 +764,243 @@ struct RawAcquisitionSummary { is_collecting: c_int, } +/// Number of logic probes covered by one DSView trigger pattern. +pub const TRIGGER_PROBE_CAPACITY: usize = 16; +/// Number of stages in the DSLogic stage trigger. +pub const TRIGGER_STAGE_CAPACITY: usize = 16; + +const TRIGGER_MODE_SIMPLE: c_int = 0; +const TRIGGER_MODE_STAGE: c_int = 1; +const TRIGGER_MODE_SERIAL: c_int = 2; +const TRIGGER_CONDITION_DONT_CARE: u8 = b'X'; + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawTriggerStageProgram { + value0: [c_char; TRIGGER_PROBE_CAPACITY], + value1: [c_char; TRIGGER_PROBE_CAPACITY], + logic: u8, + inv0: u8, + inv1: u8, + count0: u32, + count1: u32, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawTriggerProgram { + enabled: c_int, + mode: c_int, + position_percent: u16, + stage_selector: u16, + simple: [c_char; TRIGGER_PROBE_CAPACITY], + stage_count: u16, + stages: [RawTriggerStageProgram; TRIGGER_STAGE_CAPACITY], +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawTriggerState { + enabled: u16, + mode: u16, + position_percent: u16, + stage_selector: u16, + simple0: [c_char; TRIGGER_PROBE_CAPACITY], + simple1: [c_char; TRIGGER_PROBE_CAPACITY], + stages: [RawTriggerStageProgram; TRIGGER_STAGE_CAPACITY], +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawTriggerObservation { + saw_trigger_packet: c_int, + packet_status: c_int, + has_position: c_int, + raw_status: u32, + real_pos: u32, + remain_count: u64, +} + +impl RawTriggerStageProgram { + const fn zeroed() -> Self { + Self { + value0: [0; TRIGGER_PROBE_CAPACITY], + value1: [0; TRIGGER_PROBE_CAPACITY], + logic: 0, + inv0: 0, + inv1: 0, + count0: 0, + count1: 0, + } + } +} + +/// DSView trigger engine selected for a capture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TriggerProgramMode { + /// Per-channel conditions that must all hold on the same sample. + Simple, + /// Up to 16 sequential stages, each combining two patterns. + Stage, + /// Serial data match on one channel, framed by start, stop, and clock patterns. + Serial, +} + +impl TriggerProgramMode { + const fn to_raw(self) -> c_int { + match self { + Self::Simple => TRIGGER_MODE_SIMPLE, + Self::Stage => TRIGGER_MODE_STAGE, + Self::Serial => TRIGGER_MODE_SERIAL, + } + } +} + +/// One programmed trigger stage. Patterns are indexed by probe number and +/// hold DSView condition characters (`X`, `0`, `1`, `R`, `F`, `C`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TriggerStageProgram { + pub value0: [u8; TRIGGER_PROBE_CAPACITY], + pub value1: [u8; TRIGGER_PROBE_CAPACITY], + /// `(contiguous << 1) | and`, as written by DSView's trigger dock. + pub logic: u8, + pub inv0: bool, + pub inv1: bool, + pub count0: u32, + pub count1: u32, +} + +impl TriggerStageProgram { + /// The state `ds_trigger_init()` leaves in every stage. + pub const fn dont_care() -> Self { + Self { + value0: [TRIGGER_CONDITION_DONT_CARE; TRIGGER_PROBE_CAPACITY], + value1: [TRIGGER_CONDITION_DONT_CARE; TRIGGER_PROBE_CAPACITY], + logic: 1, + inv0: false, + inv1: false, + count0: 0, + count1: 0, + } + } + + fn to_raw(self) -> RawTriggerStageProgram { + RawTriggerStageProgram { + value0: self.value0.map(|value| value as c_char), + value1: self.value1.map(|value| value as c_char), + logic: self.logic, + inv0: u8::from(self.inv0), + inv1: u8::from(self.inv1), + count0: self.count0, + count1: self.count1, + } + } + + fn from_raw(raw: RawTriggerStageProgram) -> Self { + Self { + value0: raw.value0.map(|value| value as u8), + value1: raw.value1.map(|value| value as u8), + logic: raw.logic, + inv0: raw.inv0 != 0, + inv1: raw.inv1 != 0, + count0: raw.count0, + count1: raw.count1, + } + } +} + +/// A complete native trigger program, replayed through the DSView setters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TriggerProgram { + pub mode: TriggerProgramMode, + pub position_percent: u16, + /// Value passed to `ds_trigger_set_stage()`; the last active stage index. + pub stage_selector: u16, + /// Simple-trigger condition per probe. + pub simple: [u8; TRIGGER_PROBE_CAPACITY], + pub stages: Vec, +} + +impl TriggerProgram { + fn to_raw(&self) -> Result { + if self.stages.len() > TRIGGER_STAGE_CAPACITY { + return Err(RuntimeError::InvalidArgument(format!( + "trigger program has {} stages; the device supports {TRIGGER_STAGE_CAPACITY}", + self.stages.len() + ))); + } + let mut stages = [RawTriggerStageProgram::zeroed(); TRIGGER_STAGE_CAPACITY]; + for (slot, stage) in stages.iter_mut().zip(&self.stages) { + *slot = stage.to_raw(); + } + Ok(RawTriggerProgram { + enabled: 1, + mode: self.mode.to_raw(), + position_percent: self.position_percent, + stage_selector: self.stage_selector, + simple: self.simple.map(|value| value as c_char), + stage_count: self.stages.len() as u16, + stages, + }) + } +} + +/// Read-back copy of the native trigger state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TriggerState { + pub enabled: bool, + pub mode: u16, + pub position_percent: u16, + pub stage_selector: u16, + pub simple0: [u8; TRIGGER_PROBE_CAPACITY], + pub simple1: [u8; TRIGGER_PROBE_CAPACITY], + pub stages: Vec, +} + +/// Trigger position reported by the device in its trigger header. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TriggerPosition { + /// Raw `ds_trigger_pos.status`; bit 0 is set when the trigger fired. + pub raw_status: u32, + /// Trigger sample index (`ds_trigger_pos.real_pos`). + pub real_pos: u32, + pub remain_count: u64, +} + +impl TriggerPosition { + pub const fn fired(&self) -> bool { + self.raw_status & 0x01 != 0 + } +} + +/// Facts from the `SR_DF_TRIGGER` packet of the latest acquisition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TriggerObservation { + pub saw_trigger_packet: bool, + pub packet_status: Option, + pub position: Option, +} + +impl TriggerObservation { + fn from_raw(raw: RawTriggerObservation) -> Self { + Self { + saw_trigger_packet: raw.saw_trigger_packet != 0, + packet_status: (raw.saw_trigger_packet != 0) + .then(|| AcquisitionPacketStatus::from_raw(raw.packet_status)), + position: (raw.has_position != 0).then_some(TriggerPosition { + raw_status: raw.raw_status, + real_pos: raw.real_pos, + remain_count: raw.remain_count, + }), + } + } + + /// True when the device reported a valid trigger header with the hit flag set. + pub fn fired(&self) -> bool { + self.position.is_some_and(|position| position.fired()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AcquisitionTerminalEvent { None, @@ -1565,6 +1807,79 @@ impl RuntimeBridge { Ok(AcquisitionSummary::from_raw(raw)) } + /// Resets the native trigger state and programs `program` into it. + pub fn apply_trigger(&self, program: &TriggerProgram) -> Result<(), RuntimeError> { + let raw = program.to_raw()?; + let status = unsafe { dsview_bridge_ds_apply_trigger(&raw) }; + if status == DSVIEW_BRIDGE_ERR_ARG { + return Err(RuntimeError::InvalidArgument( + "bridge rejected the trigger program".to_string(), + )); + } + native_call_status("ds_apply_trigger", status) + } + + /// Resets the native trigger state to DSView's disabled default. + pub fn disable_trigger(&self) -> Result<(), RuntimeError> { + let mut raw = TriggerProgram { + mode: TriggerProgramMode::Simple, + position_percent: 0, + stage_selector: 0, + simple: [TRIGGER_CONDITION_DONT_CARE; TRIGGER_PROBE_CAPACITY], + stages: Vec::new(), + } + .to_raw()?; + raw.enabled = 0; + native_call_status("ds_apply_trigger", unsafe { + dsview_bridge_ds_apply_trigger(&raw) + }) + } + + /// Reads the native trigger state back from the runtime. + pub fn trigger_state(&self) -> Result { + let mut raw = RawTriggerState { + enabled: 0, + mode: 0, + position_percent: 0, + stage_selector: 0, + simple0: [0; TRIGGER_PROBE_CAPACITY], + simple1: [0; TRIGGER_PROBE_CAPACITY], + stages: [RawTriggerStageProgram::zeroed(); TRIGGER_STAGE_CAPACITY], + }; + native_call_status("ds_get_trigger_state", unsafe { + dsview_bridge_ds_get_trigger_state(&mut raw) + })?; + Ok(TriggerState { + enabled: raw.enabled != 0, + mode: raw.mode, + position_percent: raw.position_percent, + stage_selector: raw.stage_selector, + simple0: raw.simple0.map(|value| value as u8), + simple1: raw.simple1.map(|value| value as u8), + stages: raw + .stages + .into_iter() + .map(TriggerStageProgram::from_raw) + .collect(), + }) + } + + /// Returns the trigger header facts recorded during the latest acquisition. + pub fn trigger_observation(&self) -> Result { + let mut raw = RawTriggerObservation { + saw_trigger_packet: 0, + packet_status: -1, + has_position: 0, + raw_status: 0, + real_pos: 0, + remain_count: 0, + }; + native_call_status("ds_get_trigger_observation", unsafe { + dsview_bridge_ds_get_trigger_observation(&mut raw) + })?; + Ok(TriggerObservation::from_raw(raw)) + } + pub fn start_collect(&self) -> Result { let status = unsafe { dsview_bridge_ds_start_collect() }; let summary = self.acquisition_summary()?; diff --git a/crates/dsview-sys/tests/trigger.rs b/crates/dsview-sys/tests/trigger.rs new file mode 100644 index 0000000..260a8bf --- /dev/null +++ b/crates/dsview-sys/tests/trigger.rs @@ -0,0 +1,253 @@ +//! Trigger boundary tests against the source-built runtime library. +//! +//! The DSView trigger setters only touch the process-global `struct ds_trigger`, +//! so these tests need the runtime library but no attached device. Each test +//! applies a program and reads the native state back to check that the bridge +//! writes the same layout DSView's trigger dock writes. + +use std::sync::{Mutex, MutexGuard, OnceLock}; + +use dsview_sys::{ + RuntimeBridge, RuntimeError, TRIGGER_PROBE_CAPACITY, TRIGGER_STAGE_CAPACITY, + TriggerObservation, TriggerProgram, TriggerProgramMode, TriggerStageProgram, + source_runtime_library_path, +}; + +const SIMPLE_TRIGGER: u16 = 0; +const ADV_TRIGGER: u16 = 1; +const SERIAL_TRIGGER: u16 = 2; + +fn runtime_test_guard() -> MutexGuard<'static, ()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn load_runtime() -> Option { + let Some(path) = source_runtime_library_path() else { + eprintln!("skipping trigger boundary test: no source-built runtime library"); + return None; + }; + Some(RuntimeBridge::load(path).expect("source-built runtime library loads")) +} + +fn pattern(conditions: &[(usize, u8)]) -> [u8; TRIGGER_PROBE_CAPACITY] { + let mut values = [b'X'; TRIGGER_PROBE_CAPACITY]; + for &(probe, condition) in conditions { + values[probe] = condition; + } + values +} + +fn simple_program(conditions: &[(usize, u8)], position_percent: u16) -> TriggerProgram { + TriggerProgram { + mode: TriggerProgramMode::Simple, + position_percent, + stage_selector: 0, + simple: pattern(conditions), + stages: Vec::new(), + } +} + +#[test] +fn simple_trigger_program_writes_the_simple_row() { + let _guard = runtime_test_guard(); + let Some(runtime) = load_runtime() else { + return; + }; + + runtime + .apply_trigger(&simple_program(&[(0, b'R'), (3, b'1'), (15, b'C')], 10)) + .expect("simple trigger applies"); + let state = runtime.trigger_state().expect("trigger state reads back"); + + assert!(state.enabled); + assert_eq!(state.mode, SIMPLE_TRIGGER); + assert_eq!(state.position_percent, 10); + assert_eq!(state.stage_selector, 0); + assert_eq!(state.simple0, pattern(&[(0, b'R'), (3, b'1'), (15, b'C')])); + assert_eq!(state.simple1, pattern(&[])); + assert_eq!(state.stages.len(), TRIGGER_STAGE_CAPACITY); + for stage in &state.stages { + assert_eq!(*stage, TriggerStageProgram::dont_care()); + } +} + +#[test] +fn stage_trigger_program_maps_patterns_by_probe_index() { + let _guard = runtime_test_guard(); + let Some(runtime) = load_runtime() else { + return; + }; + + let first = TriggerStageProgram { + value0: pattern(&[(0, b'R'), (15, b'0')]), + value1: pattern(&[(5, b'F')]), + logic: 0b10, + inv0: false, + inv1: true, + count0: 3, + count1: 0, + }; + let second = TriggerStageProgram { + value0: pattern(&[(7, b'1'), (8, b'0')]), + value1: pattern(&[]), + logic: 0b01, + inv0: true, + inv1: false, + count0: 1, + count1: 0, + }; + let program = TriggerProgram { + mode: TriggerProgramMode::Stage, + position_percent: 50, + stage_selector: 1, + simple: pattern(&[]), + stages: vec![first, second], + }; + + runtime + .apply_trigger(&program) + .expect("stage trigger applies"); + let state = runtime.trigger_state().expect("trigger state reads back"); + + assert!(state.enabled); + assert_eq!(state.mode, ADV_TRIGGER); + assert_eq!(state.position_percent, 50); + assert_eq!(state.stage_selector, 1); + assert_eq!(state.stages[0], first); + assert_eq!(state.stages[1], second); + for stage in &state.stages[2..] { + assert_eq!(*stage, TriggerStageProgram::dont_care()); + } +} + +#[test] +fn serial_trigger_program_sets_serial_mode() { + let _guard = runtime_test_guard(); + let Some(runtime) = load_runtime() else { + return; + }; + + let mut stages = vec![TriggerStageProgram::dont_care(); 4]; + stages[0].value0 = pattern(&[(1, b'0')]); + stages[0].value1 = pattern(&[(1, b'1')]); + stages[1].value0 = pattern(&[(0, b'R')]); + stages[1].count0 = 1; + stages[2].value0 = pattern(&[(2, b'0')]); + stages[3].value0 = pattern(&[(0, b'1'), (1, b'0'), (2, b'1'), (3, b'0')]); + stages[3].count0 = 3; + let program = TriggerProgram { + mode: TriggerProgramMode::Serial, + position_percent: 1, + stage_selector: 0, + simple: pattern(&[]), + stages: stages.clone(), + }; + + runtime + .apply_trigger(&program) + .expect("serial trigger applies"); + let state = runtime.trigger_state().expect("trigger state reads back"); + + assert!(state.enabled); + assert_eq!(state.mode, SERIAL_TRIGGER); + assert_eq!(state.stage_selector, 0); + assert_eq!(&state.stages[..4], stages.as_slice()); +} + +#[test] +fn disable_trigger_restores_the_dsview_defaults() { + let _guard = runtime_test_guard(); + let Some(runtime) = load_runtime() else { + return; + }; + + runtime + .apply_trigger(&simple_program(&[(2, b'F')], 20)) + .expect("simple trigger applies"); + runtime.disable_trigger().expect("trigger disables"); + let state = runtime.trigger_state().expect("trigger state reads back"); + + assert!(!state.enabled); + assert_eq!(state.mode, SIMPLE_TRIGGER); + assert_eq!(state.position_percent, 0); + assert_eq!(state.stage_selector, 0); + assert_eq!(state.simple0, pattern(&[])); + for stage in &state.stages { + assert_eq!(*stage, TriggerStageProgram::dont_care()); + } +} + +#[test] +fn bridge_rejects_programs_that_would_trip_dsview_asserts() { + let _guard = runtime_test_guard(); + let Some(runtime) = load_runtime() else { + return; + }; + + runtime + .apply_trigger(&simple_program(&[(4, b'1')], 5)) + .expect("baseline trigger applies"); + + let invalid_programs = [ + simple_program(&[(0, b'Z')], 5), + simple_program(&[(0, b'R')], 101), + TriggerProgram { + mode: TriggerProgramMode::Stage, + position_percent: 5, + stage_selector: 0, + simple: pattern(&[]), + stages: Vec::new(), + }, + TriggerProgram { + mode: TriggerProgramMode::Stage, + position_percent: 5, + stage_selector: TRIGGER_STAGE_CAPACITY as u16, + simple: pattern(&[]), + stages: vec![TriggerStageProgram::dont_care()], + }, + TriggerProgram { + mode: TriggerProgramMode::Stage, + position_percent: 5, + stage_selector: 0, + simple: pattern(&[]), + stages: vec![TriggerStageProgram::dont_care(); TRIGGER_STAGE_CAPACITY + 1], + }, + ]; + + for program in &invalid_programs { + let error = runtime + .apply_trigger(program) + .expect_err("invalid program is rejected"); + assert!( + matches!(error, RuntimeError::InvalidArgument(_)), + "unexpected error {error:?}" + ); + } + + // Rejected programs never reach the native setters. + let state = runtime.trigger_state().expect("trigger state reads back"); + assert!(state.enabled); + assert_eq!(state.position_percent, 5); + assert_eq!(state.simple0, pattern(&[(4, b'1')])); +} + +#[test] +fn trigger_observation_is_empty_before_any_acquisition() { + let _guard = runtime_test_guard(); + let Some(runtime) = load_runtime() else { + return; + }; + + runtime + .reset_acquisition_summary() + .expect("acquisition summary resets"); + let observation = runtime + .trigger_observation() + .expect("trigger observation reads"); + assert_eq!(observation, TriggerObservation::default()); + assert!(!observation.fired()); +} diff --git a/crates/dsview-sys/wrapper.h b/crates/dsview-sys/wrapper.h index 286d9b3..844c6b4 100644 --- a/crates/dsview-sys/wrapper.h +++ b/crates/dsview-sys/wrapper.h @@ -56,6 +56,64 @@ struct dsview_stream_export_facts { unsigned long long output_bytes; }; +#define DSVIEW_TRIGGER_PROBE_CAPACITY 16 +#define DSVIEW_TRIGGER_STAGE_CAPACITY 16 + +enum dsview_trigger_mode { + DSVIEW_TRIGGER_MODE_SIMPLE = 0, + DSVIEW_TRIGGER_MODE_STAGE = 1, + DSVIEW_TRIGGER_MODE_SERIAL = 2, +}; + +/* + * One programmed trigger stage. Pattern arrays are indexed by probe number and + * hold DSView condition characters: 'X', '0', '1', 'R', 'F', or 'C'. + */ +struct dsview_trigger_stage_program { + char value0[DSVIEW_TRIGGER_PROBE_CAPACITY]; + char value1[DSVIEW_TRIGGER_PROBE_CAPACITY]; + uint8_t logic; + uint8_t inv0; + uint8_t inv1; + uint32_t count0; + uint32_t count1; +}; + +/* + * A complete trigger program. The bridge resets the native trigger state and + * replays this program through the DSView setters without interpreting it. + */ +struct dsview_trigger_program { + int enabled; + int mode; + uint16_t position_percent; + uint16_t stage_selector; + char simple[DSVIEW_TRIGGER_PROBE_CAPACITY]; + uint16_t stage_count; + struct dsview_trigger_stage_program stages[DSVIEW_TRIGGER_STAGE_CAPACITY]; +}; + +/* Read-back copy of the native trigger state for the first 16 probes. */ +struct dsview_trigger_state { + uint16_t enabled; + uint16_t mode; + uint16_t position_percent; + uint16_t stage_selector; + char simple0[DSVIEW_TRIGGER_PROBE_CAPACITY]; + char simple1[DSVIEW_TRIGGER_PROBE_CAPACITY]; + struct dsview_trigger_stage_program stages[DSVIEW_TRIGGER_STAGE_CAPACITY]; +}; + +/* Facts from the SR_DF_TRIGGER packet of the latest acquisition. */ +struct dsview_trigger_observation { + int saw_trigger_packet; + int packet_status; + int has_position; + uint32_t raw_status; + uint32_t real_pos; + unsigned long long remain_count; +}; + enum dsview_bridge_status { DSVIEW_BRIDGE_OK = 0, DSVIEW_BRIDGE_ERR_ARG = -1, @@ -336,6 +394,9 @@ int dsview_bridge_ds_stop_collect(void); int dsview_bridge_ds_is_collecting(int *value); int dsview_bridge_ds_reset_acquisition_summary(void); int dsview_bridge_ds_get_acquisition_summary(struct dsview_bridge_acquisition_summary *out_summary); +int dsview_bridge_ds_apply_trigger(const struct dsview_trigger_program *program); +int dsview_bridge_ds_get_trigger_state(struct dsview_trigger_state *out_state); +int dsview_bridge_ds_get_trigger_observation(struct dsview_trigger_observation *out_observation); int dsview_bridge_ds_export_recorded_vcd( const struct dsview_vcd_export_request *request, struct dsview_export_buffer *out_buffer); From fa2ea448548650977dc00dc5d4046c12b35d031c Mon Sep 17 00:00:00 2001 From: TreeNewbeer Date: Tue, 10 Nov 2026 03:05:28 -0800 Subject: [PATCH 2/4] docs: document hardware trigger capture Co-Authored-By: Claude Opus 5.5 --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++ README.zh-CN.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/README.md b/README.md index 905d763..172e9cd 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ DSView CLI is a command-line tool for DSLogic Plus devices. It brings the DSView - Capture finite logic analyzer sessions from DSLogic Plus devices. - Export VCD waveform files plus JSON metadata for downstream tooling. +- Arm the DSLogic Plus hardware trigger (simple, stage, and serial) and report the trigger sample. - Inspect DSView protocol decoders from the command line. - Run offline protocol decoding from JSON logic sample input. - Ship as self-contained release bundles with native runtimes, firmware resources, decoder scripts, and a bundled Python runtime. @@ -152,6 +153,66 @@ dsview-cli capture \ --output capture.vcd ``` +### Capture on a Hardware Trigger + +Arm the DSLogic Plus hardware trigger to place the capture window around an event instead of starting it immediately. + +A simple trigger takes `CHANNEL=CONDITION` pairs, and all of them must hold on the same sample. The conditions are `low`, `high`, `rising`, `falling`, and `edge`; the DSView letters `0`, `1`, `R`, `F`, and `C` also work. + +```bash +dsview-cli capture \ + --handle 1 \ + --operation-mode buffer \ + --sample-rate-hz 100000000 \ + --sample-limit 1000000 \ + --channels 0,1,2,3 \ + --trigger 0=falling,1=high \ + --trigger-position 10 \ + --wait-timeout-ms 30000 \ + --output capture.vcd +``` + +- `--trigger-position` sets how much of the capture comes before the trigger, as a percentage: 1-90 in buffer mode, default 1. +- Stream mode supports only simple triggers, and the position is fixed at 1. +- Trigger channels must be enabled with `--channels` and must be usable in the selected channel mode. +- Waiting for the trigger counts against `--wait-timeout-ms` for finite captures, and against `--duration-ms` or the interrupt for stream captures. If the condition never occurs, the command fails with `capture_trigger_not_fired` and releases the device. + +Stage and serial triggers are read from a JSON trigger config: + +```json +{ + "version": 1, + "mode": "stage", + "stages": [ + {"pattern_a": {"channels": {"0": "falling"}}}, + {"pattern_a": {"channels": {"1": "rising", "2": "low"}}, "count": 3} + ] +} +``` + +```bash +dsview-cli capture ... --trigger-config trigger.json --trigger-position 50 +``` + +- `stage`: up to 16 stages, matched in order. Each stage has a `pattern_a` and an optional `pattern_b`, combined with `combine` (`and` by default, or `or`). Set `invert: true` to negate a pattern. A stage passes after `count` matches (default 1); with `contiguous: true`, the matches must be consecutive samples. +- `serial`: after a `start` flag, the device samples `data_channel` on each `clock` edge and compares the first `data_bits` bits with `data_value`. A `stop` flag aborts the frame. `start`, `stop`, and `clock` each need at least one edge condition. `data_value` is either a `0`/`1`/`X` pattern, written first-received bit first (MSB first), or a `0x` hex value. For example, a serial config can match an I2C address byte: `{"version": 1, "mode": "serial", "start": {"1": "falling", "0": "high"}, "stop": {"1": "rising", "0": "high"}, "clock": {"0": "rising"}, "data_channel": 1, "data_bits": 8, "data_value": "0xA0"}`. +- `simple`: `{"version": 1, "mode": "simple", "channels": {"0": "rising"}}`. + +When a trigger is armed, the capture JSON and the metadata sidecar both include a `trigger` block: + +```json +"trigger": { + "requested": {"mode": "simple", "channels": {"0": "falling", "1": "high"}, "position_percent": 10}, + "observed": {"trigger_packet": true, "fired": true, "sample_index": 100032, "raw_status": 1} +} +``` + +- `observed.sample_index` is the trigger sample in the exported VCD; its time is `sample_index / sample_rate_hz`. +- If the condition occurs before the pre-trigger part of the capture has been recorded, the capture starts early. In that case `sample_index` is smaller than the requested position. This is common with periodic signals, whose event recurs within the pre-trigger time. +- Otherwise the position is approximate: in hardware tests the trigger landed up to about 0.3% of the capture after the requested share. Use `observed.sample_index`, not the percentage, to locate the trigger. +- The `trigger` block is omitted when no trigger is armed. +- `devices options` lists the trigger limits. + ### List Protocol Decoders ```bash @@ -255,6 +316,8 @@ dsview-cli capture \ [--channel-mode TOKEN] \ [--threshold-volts VOLTS] \ [--filter TOKEN] \ + [--trigger CH=COND[,CH=COND...] | --trigger-config PATH] \ + [--trigger-position PERCENT] \ [--wait-timeout-ms MS] \ [--poll-interval-ms MS] \ [--format json|text] \ diff --git a/README.zh-CN.md b/README.zh-CN.md index a9ea69c..085a2e7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -18,6 +18,7 @@ DSView CLI 是面向 DSLogic Plus 设备的命令行工具。它把 DSView/libsi - 对 DSLogic Plus 执行有限长度逻辑分析采集。 - 导出 VCD 波形文件和 JSON 元数据,便于后续工具处理。 +- 支持 DSLogic Plus 硬件触发(简单、多级、串行),并输出触发点所在的采样位置。 - 通过命令行查看 DSView 协议解码器。 - 从 JSON 逻辑采样输入执行离线协议解码。 - 发布为自包含 release bundle,内含原生运行时、固件资源、解码器脚本和 Python 运行时。 @@ -152,6 +153,74 @@ dsview-cli capture \ --output capture.vcd ``` +### 硬件触发采集 + +启用 DSLogic Plus 的硬件触发后,采集窗口会围绕触发事件放置,而不是一启动就开始记录。 + +简单触发由若干 `通道=条件` 组成,所有条件必须在同一个采样点同时满足。条件可以是 `low`、`high`、`rising`、`falling`、`edge`,也可以写成 DSView 的字母 `0`、`1`、`R`、`F`、`C`。 + +```bash +dsview-cli capture \ + --handle 1 \ + --operation-mode buffer \ + --sample-rate-hz 100000000 \ + --sample-limit 1000000 \ + --channels 0,1,2,3 \ + --trigger 0=falling,1=high \ + --trigger-position 10 \ + --wait-timeout-ms 30000 \ + --output capture.vcd +``` + +- `--trigger-position` 表示触发点之前的数据占整段采集的百分比。Buffer 模式下可取 1-90,默认 1。 +- Stream 模式只支持简单触发,位置固定为 1。 +- 触发通道必须已通过 `--channels` 启用,并且在当前通道模式下可用。 +- 等待触发的时间计入超时:有限长度采集计入 `--wait-timeout-ms`,stream 采集计入 `--duration-ms` 或中断前的时长。条件一直不满足时,命令会以 `capture_trigger_not_fired` 失败,并释放设备。 + +多级触发和串行触发通过 JSON 触发配置文件设置: + +```json +{ + "version": 1, + "mode": "stage", + "stages": [ + {"pattern_a": {"channels": {"0": "falling"}}}, + {"pattern_a": {"channels": {"1": "rising", "2": "low"}}, "count": 3} + ] +} +``` + +```bash +dsview-cli capture ... --trigger-config trigger.json --trigger-position 50 +``` + +- `stage`:最多 16 级,按顺序逐级匹配。 + - 每一级由 `pattern_a` 和可选的 `pattern_b` 组成,两者用 `combine` 组合:默认 `and`,也可以是 `or`。 + - `invert: true` 表示对该 pattern 取反。 + - 该级匹配满 `count` 次(默认 1)后进入下一级。设置 `contiguous: true` 时,这些匹配必须落在连续的采样点上。 +- `serial`: + - 出现 `start` 标志后,设备在 `clock` 的每个边沿采样 `data_channel`,把收到的前 `data_bits` 位与 `data_value` 比较。 + - 出现 `stop` 标志时放弃当前帧。 + - `start`、`stop`、`clock` 都必须至少包含一个边沿条件。 + - `data_value` 可以是 `0`/`1`/`X` 组成的位串,先收到的位写在最左边(MSB 在前),也可以是 `0x` 十六进制值。 + - 例如,下面的配置可以匹配一个 I2C 地址字节:`{"version": 1, "mode": "serial", "start": {"1": "falling", "0": "high"}, "stop": {"1": "rising", "0": "high"}, "clock": {"0": "rising"}, "data_channel": 1, "data_bits": 8, "data_value": "0xA0"}`。 +- `simple`:`{"version": 1, "mode": "simple", "channels": {"0": "rising"}}`。 + +启用触发后,采集的 JSON 输出和元数据文件中都会多出一个 `trigger` 块: + +```json +"trigger": { + "requested": {"mode": "simple", "channels": {"0": "falling", "1": "high"}, "position_percent": 10}, + "observed": {"trigger_packet": true, "fired": true, "sample_index": 100032, "raw_status": 1} +} +``` + +- `observed.sample_index` 是触发点在导出 VCD 中的采样序号,对应时间为 `sample_index / sample_rate_hz`。 +- 如果条件在触发前的数据还没录够时就已满足,采集会提前开始,此时 `sample_index` 会小于按请求位置算出的值。周期信号经常出现这种情况,因为事件在触发前的这段时间内就会再次出现。 +- 其余情况下位置是近似值:硬件实测中,触发点最多比请求的位置晚约 0.3% 的采集长度。定位触发点请以 `observed.sample_index` 为准,不要按百分比推算。 +- 未启用触发时不输出 `trigger` 块。 +- `devices options` 会列出触发相关的限制。 + ### 列出协议解码器 ```bash @@ -255,6 +324,8 @@ dsview-cli capture \ [--channel-mode TOKEN] \ [--threshold-volts VOLTS] \ [--filter TOKEN] \ + [--trigger CH=COND[,CH=COND...] | --trigger-config PATH] \ + [--trigger-position PERCENT] \ [--wait-timeout-ms MS] \ [--poll-interval-ms MS] \ [--format json|text] \ From 63d7b17e14c834a2e27d4d3aedd3c5d7e1ef6dfd Mon Sep 17 00:00:00 2001 From: TreeNewbeer Date: Tue, 10 Nov 2026 03:05:28 -0800 Subject: [PATCH 3/4] docs: record hardware trigger quick task Co-Authored-By: Claude Opus 5.5 --- .../261110-trg-PLAN.md | 49 +++++++++++++ .../261110-trg-SUMMARY.md | 69 +++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 .planning/quick/261110-trg-expose-dslogic-hardware-trigger/261110-trg-PLAN.md create mode 100644 .planning/quick/261110-trg-expose-dslogic-hardware-trigger/261110-trg-SUMMARY.md diff --git a/.planning/quick/261110-trg-expose-dslogic-hardware-trigger/261110-trg-PLAN.md b/.planning/quick/261110-trg-expose-dslogic-hardware-trigger/261110-trg-PLAN.md new file mode 100644 index 0000000..a1eb157 --- /dev/null +++ b/.planning/quick/261110-trg-expose-dslogic-hardware-trigger/261110-trg-PLAN.md @@ -0,0 +1,49 @@ +--- +mode: quick +description: Expose the DSLogic Plus hardware trigger (simple, stage, and serial) on the capture command and report trigger results in capture output. +created_at: 2026-11-10 +--- + +# Quick Plan + +Requirement `TRIG-01` (deferred in `v1.1-REQUIREMENTS.md`): the user can configure DSView trigger settings from the CLI. + +## Upstream facts (DSView `e93e644f`, read-only) + +- `libsigrok4DSL/trigger.c` keeps one global `struct ds_trigger`. `dsl_fpga_arm()` writes it to the FPGA when collection starts. The setters are exported `SR_API` symbols in the runtime library: `ds_trigger_reset`, `ds_trigger_set_en`, `ds_trigger_set_mode`, `ds_trigger_set_pos`, `ds_trigger_set_stage`, `ds_trigger_probe_set`, `ds_trigger_stage_set_value`, `ds_trigger_stage_set_logic`, `ds_trigger_stage_set_inv`, `ds_trigger_stage_set_count`. +- Simple trigger: per-channel condition `X/0/1/R/F/C` in row `TriggerStages`. All set channels must match on the same sample (`LogicSignal::commit_trig`). +- Stage trigger (`ADV_TRIGGER`): 1-16 stages. Each stage has two 16-channel patterns, each with its own `==`/`!=` inversion, combined with `or`/`and`, plus a counter and a contiguous flag (`TriggerDock::commit_trigger`). +- Serial trigger (`SERIAL_TRIGGER`), as stages: + - stage 0: start and stop flags + - stage 1: clock flag, count 1 + - stage 2: data channel marked `0` + - stage 3: data value, count `bits - 1` +- Position is a percent of the sample limit (GUI range 1-90, default 1). Stream mode forbids advanced triggers and fixes the position at 1. +- The device reports the trigger point in an `SR_DF_TRIGGER` packet: `ds_trigger_pos.status & 1` is the hit flag and `real_pos` is the sample index. + +## Tasks + +1. `dsview-sys`: + - Load the trigger setters. + - Add `dsview_bridge_ds_apply_trigger` to program a fixed-size trigger request mechanically. + - Add `dsview_bridge_ds_get_trigger_state` to read the native struct back. + - Record `SR_DF_TRIGGER` packets as a trigger observation, reset together with the acquisition summary. + - Add safe Rust wrappers and a boundary test that applies programs and reads back the native state. +2. `dsview-core`, new `trigger` module: + - Condition tokens. + - JSON trigger config (version 1; modes `simple`, `stage`, `serial`). + - Validation against the validated device options: enabled channels, channel-mode width, stream-mode limits, position range. + - Native program building. + - Apply the trigger as a `trigger` device-option apply step. + - Return the trigger outcome in `CaptureRunSummary`. + - Add an optional `trigger` block in schema-v2 metadata (omitted when no trigger is requested). +3. `dsview-cli`: + - Add `--trigger CH=COND[,...]`, `--trigger-config PATH` and `--trigger-position PERCENT` to `capture`. + - Add trigger-specific error codes, including a trigger-not-fired classification for timeouts. + - Add a `trigger` block in the capture JSON and text output. + - Report trigger capabilities in `devices options`. +4. Verify: + - `cargo build`, `cargo test --workspace`, `cargo fmt --check`, `cargo clippy`. + - DSLogic Plus hardware runs on the `internal-test` counter: simple level and edge triggers, a stage sequence, the trigger position, and a never-firing trigger timeout. + - Keep the raw logs. +5. Update the README (EN and zh-CN) and this quick task's summary. diff --git a/.planning/quick/261110-trg-expose-dslogic-hardware-trigger/261110-trg-SUMMARY.md b/.planning/quick/261110-trg-expose-dslogic-hardware-trigger/261110-trg-SUMMARY.md new file mode 100644 index 0000000..56e7d5a --- /dev/null +++ b/.planning/quick/261110-trg-expose-dslogic-hardware-trigger/261110-trg-SUMMARY.md @@ -0,0 +1,69 @@ +--- +mode: quick +description: Expose the DSLogic Plus hardware trigger (simple, stage, and serial) on the capture command and report trigger results in capture output. +completed_at: 2026-11-10 +status: complete +--- + +# Quick Summary + +This change implements `TRIG-01` (configure DSView trigger settings from the CLI) for DSLogic Plus captures. + +## Delivered + +- `dsview-sys`: + - `bridge_runtime.c` resolves the ten exported `ds_trigger_*` setters and, optionally, the `trigger` data symbol. + - `dsview_bridge_ds_apply_trigger` resets the native state and replays a fixed-size program through the setters. It rejects values that would trip DSView `assert`s and resets again if a setter fails part-way. + - `dsview_bridge_ds_get_trigger_state` reads the native struct back. + - `SR_DF_TRIGGER` packets are recorded as a trigger observation (`status`, `real_pos`, `remain_cnt`), reset together with the acquisition summary. + - The Windows `.def` exports the trigger setters and `trigger DATA`. + - Safe wrappers: `RuntimeBridge::{apply_trigger, disable_trigger, trigger_state, trigger_observation}`. +- `dsview-core`, new `trigger` module: + - Condition tokens, and parsing for `--trigger` tokens and version-1 JSON configs (`simple`, `stage`, `serial`). + - Validation against the validated device options: + - trigger channels must be enabled and within the channel-mode width + - position 1-90 (default 1) + - stream mode is simple-only, with the position fixed at 1 + - stage rules: 1-16 stages, count 1..=i32::MAX, no always-true or never-true patterns + - serial `start`, `stop` and `clock` flags each need an edge + - Native program building that mirrors `TriggerDock::commit_trigger` and `LogicSignal::commit_trig`. + - The trigger is applied as the `trigger` device-option apply step, after the other options. + - `CaptureRunSummary.trigger`, plus an optional metadata `trigger` block (`requested` / `observed`) that is omitted when no trigger is armed. The schema version stays 2 because the change is additive. + - Failed stream captures now delete their partial `*.streaming.tmp` file. +- `dsview-cli`: + - `capture` gains `--trigger CH=COND[,…]`, `--trigger-config PATH` and `--trigger-position PERCENT` (Trigger help heading; the two sources are mutually exclusive, and the position requires one of them). + - New error codes: `trigger_config_invalid`, `trigger_config_unreadable`, `trigger_channel_not_enabled`, `trigger_channel_unsupported`, `trigger_mode_unsupported`, `trigger_position_out_of_range`, and `capture_trigger_not_fired` for timeouts or incomplete stream runs that never saw a trigger hit. + - A `trigger` block in the capture JSON, and a text line. + - A `trigger` capability guide in `devices options` (JSON and text). +- Docs: README (EN and zh-CN) gains a Features bullet, a "Capture on a Hardware Trigger" section, and the new reference flags. + +## Hardware findings (DSLogic Plus, internal-test counter and idle inputs) + +- `observed.sample_index` (`ds_trigger_pos.real_pos`) is the exact trigger sample in the exported VCD. This was verified on 16-channel level, edge, stage-count, sequence, invert, OR, contiguous and serial triggers. +- The position is the pre-trigger share and is approximate. On real signals the trigger landed 0.03 to 0.30% of the capture after the requested share. If the event comes before the pre-trigger window is full, which is common with periodic signals, the capture starts early and `sample_index` is smaller. +- Stage `count` is the number of matches: count 3 fired on the 3rd D15 rise. +- Serial trigger: + - it compares only the first `data_bits` bits after the start flag; there is no sliding window + - the first-received bit is the MSB, the leftmost character of `data_value` + - an empty or level-only start or stop flag never fires, which is why validation requires edges +- The 200 MHz ×8 and 400 MHz ×4 buffer modes fail at the `enabled_channels` apply step with `SR_ERR_ARG`, with or without a trigger. The released v1.2.4 fails the same way, because `set_enabled_channels` walks 16 channels while those modes expose 8 or 4. This is an existing bug and is out of scope here, so the halved and quartered trigger-mask paths are covered only by validation, not by hardware. + +## Real-signal validation + +The installed `v1.2.4-trigger-dev` bundle passed 23 of 23 cases on real signals. The source was an FPGA board driving two 3.3 V GPIO lines with a fixed 10 ms frame; captures used a 1.6 V threshold and buffer mode at 100 MHz unless noted. A script checked in each VCD that the requested condition holds at `trigger.observed.sample_index`. + +- Simple triggers fired with the named edge at offset 0 and the named levels holding: `1=rising`, `0=rising,1=high`, `0=falling,1=low`, `1=falling`, `0=edge`, `0=high,1=high`. +- The two-stage trigger (channel 1 falling, then 8 channel-0 rises) fired on the 8th rise. +- The serial trigger with 8 one-bits fired on the 8th clock edge after the start flag. +- Conditions that never occur returned `capture_trigger_not_fired`, and the device stayed usable. +- Stream mode at 10 MHz fired at offset 0. +- Refusals worked: `trigger_channel_not_enabled` and `trigger_position_out_of_range`. +- Position 10/50/90% landed 0.03 to 0.30% of the capture after the requested share. + +Raw hardware logs from both rounds are kept on the test host and are not committed. + +## Verification + +- `cargo test --workspace`: 315 passed, 0 failed across 24 test binaries. The new tests are `dsview-sys/tests/trigger.rs` (6), the `dsview-core` trigger unit tests (11) and `dsview-cli/tests/trigger_cli.rs` (13). +- The new files pass rustfmt. The repository was not rustfmt-clean before, so the existing files were not reformatted. +- Clippy reports no new lints in the trigger code, apart from the codebase-wide `result_large_err` pattern. From 4399a7534a0919cd195260e82fc4e438bfdd300d Mon Sep 17 00:00:00 2001 From: TreeNewbeer Date: Wed, 11 Nov 2026 15:09:00 +0800 Subject: [PATCH 4/4] ci: remove Windows build targets and steps from GitHub Actions --- .github/actions/setup-msvc-dev-cmd/action.yml | 85 --------------- .../actions/setup-native-prereqs/action.yml | 56 ---------- .github/workflows/ci.yml | 99 ++--------------- .github/workflows/release.yml | 102 ++---------------- 4 files changed, 19 insertions(+), 323 deletions(-) delete mode 100644 .github/actions/setup-msvc-dev-cmd/action.yml diff --git a/.github/actions/setup-msvc-dev-cmd/action.yml b/.github/actions/setup-msvc-dev-cmd/action.yml deleted file mode 100644 index 87f0fc9..0000000 --- a/.github/actions/setup-msvc-dev-cmd/action.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Setup MSVC Developer Command Prompt -description: Export MSVC developer environment variables for subsequent Windows build steps. -inputs: - arch: - description: Target architecture passed to vcvarsall.bat, for example x64 or arm64. - required: true -runs: - using: composite - steps: - - name: Export MSVC environment - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - - $arch = '${{ inputs.arch }}' - $validArchitectures = @('x86', 'x64', 'arm', 'arm64') - if ($arch -notin $validArchitectures) { - throw "Unsupported MSVC architecture: $arch" - } - - $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' - if (-not (Test-Path $vswhere)) { - throw "vswhere was not found: $vswhere" - } - - $installationPath = & $vswhere -latest -products '*' -property installationPath - if (-not $installationPath) { - throw 'Visual Studio installation was not found' - } - - $vcvarsall = Join-Path $installationPath 'VC\Auxiliary\Build\vcvarsall.bat' - if (-not (Test-Path $vcvarsall)) { - throw "vcvarsall.bat was not found: $vcvarsall" - } - - $before = @{} - Get-ChildItem Env: | ForEach-Object { - $before[$_.Name] = $_.Value - } - - $vcvarsCommand = "`"$vcvarsall`" $arch >nul && set" - $environment = & cmd.exe /s /c $vcvarsCommand - if ($LASTEXITCODE -ne 0) { - throw "vcvarsall.bat failed for architecture: $arch" - } - - $preserveVariables = @( - 'VCPKG_ROOT', - 'VCPKG_INSTALLATION_ROOT', - 'VCPKG_DEFAULT_BINARY_CACHE', - 'VCPKG_DOWNLOADS' - ) - - foreach ($line in $environment) { - if ($line -notmatch '^([^=]+)=(.*)$') { - continue - } - - $name = $Matches[1] - $value = $Matches[2] - if ($before.ContainsKey($name) -and $before[$name] -ceq $value) { - continue - } - - if ($name -in $preserveVariables) { - continue - } - - if ($name -ieq 'Path') { - # Preserve vcvarsall's PATH ordering. GITHUB_PATH prepends entries and - # can make Visual Studio's bundled vcpkg shadow the runner vcpkg. - $delimiter = "MSVC_ENV_$([guid]::NewGuid().ToString('N'))" - "$name<<$delimiter" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - $value | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - $delimiter | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - continue - } - - $delimiter = "MSVC_ENV_$([guid]::NewGuid().ToString('N'))" - "$name<<$delimiter" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - $value | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - $delimiter | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - } - - "MSVC developer environment initialized for $arch" diff --git a/.github/actions/setup-native-prereqs/action.yml b/.github/actions/setup-native-prereqs/action.yml index e68d673..6269dbb 100644 --- a/.github/actions/setup-native-prereqs/action.yml +++ b/.github/actions/setup-native-prereqs/action.yml @@ -44,59 +44,3 @@ runs: brew install "$formula" fi done - - - name: Install Windows dependencies - if: endsWith(inputs.target, '-pc-windows-msvc') - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - - if ($env:VCPKG_DEFAULT_BINARY_CACHE) { - New-Item -ItemType Directory -Force -Path $env:VCPKG_DEFAULT_BINARY_CACHE | Out-Null - } - if ($env:VCPKG_DOWNLOADS) { - New-Item -ItemType Directory -Force -Path $env:VCPKG_DOWNLOADS | Out-Null - } - - $Triplet = switch ("${{ inputs.target }}") { - "x86_64-pc-windows-msvc" { "x64-windows" } - "aarch64-pc-windows-msvc" { "arm64-windows" } - default { throw "unsupported Windows target: ${{ inputs.target }}" } - } - - $BundledVcpkg = 'C:\vcpkg\vcpkg.exe' - $ResolvedVcpkg = Get-Command vcpkg -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 - $VcpkgExe = if (Test-Path $BundledVcpkg) { - $BundledVcpkg - } elseif ($ResolvedVcpkg) { - $ResolvedVcpkg.Source - } else { - throw 'vcpkg executable was not found' - } - - $VcpkgRoot = Split-Path -Parent $VcpkgExe - $env:VCPKG_ROOT = $VcpkgRoot - $env:VCPKG_INSTALLATION_ROOT = $VcpkgRoot - Write-Host "Using vcpkg at $VcpkgExe" - - $Packages = @("glib:$Triplet", "libusb:$Triplet", "fftw3:$Triplet", "pkgconf:$Triplet") - & $VcpkgExe install $Packages - if ($LASTEXITCODE -ne 0) { - throw "vcpkg install failed with exit code $LASTEXITCODE" - } - - $PkgConfigExe = "$VcpkgRoot/installed/$Triplet/tools/pkgconf/pkgconf.exe" - $PkgConfigPath = "$VcpkgRoot/installed/$Triplet/lib/pkgconfig;$VcpkgRoot/installed/$Triplet/share/pkgconfig" - $PkgConfigBin = "$VcpkgRoot/installed/$Triplet/tools/pkgconf" - $RuntimeBin = "$VcpkgRoot/installed/$Triplet/bin" - $DebugRuntimeBin = "$VcpkgRoot/installed/$Triplet/debug/bin" - - "VCPKG_ROOT=$VcpkgRoot" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "VCPKG_INSTALLATION_ROOT=$VcpkgRoot" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "DSVIEW_VCPKG_ROOT=$VcpkgRoot" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "DSVIEW_VCPKG_TRIPLET=$Triplet" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "CMAKE_TOOLCHAIN_FILE=$VcpkgRoot/scripts/buildsystems/vcpkg.cmake" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "PKG_CONFIG=$PkgConfigExe" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "PKG_CONFIG_PATH=$PkgConfigPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "PKG_CONFIG_LIBDIR=$PkgConfigPath" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "PATH=$RuntimeBin;$DebugRuntimeBin;$PkgConfigBin;$env:PATH" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d578a82..5aec494 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,14 +29,6 @@ jobs: - target: aarch64-apple-darwin runner: macos-15 package_bundle: true - - target: x86_64-pc-windows-msvc - runner: windows-2022 - msvc_arch: x64 - package_bundle: true - - target: aarch64-pc-windows-msvc - runner: windows-11-arm - msvc_arch: arm64 - package_bundle: true steps: - name: Checkout repository @@ -44,61 +36,19 @@ jobs: with: submodules: recursive - - name: Apply DSView local patches (Unix) - if: ${{ !contains(matrix.target, 'windows') }} + - name: Apply DSView local patches shell: bash run: ./scripts/apply-dsview-patches.sh - - name: Apply DSView local patches (Windows) - if: ${{ contains(matrix.target, 'windows') }} - shell: pwsh - run: ./scripts/apply-dsview-patches.ps1 - - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - - name: Set up Python (Unix) - if: ${{ !contains(matrix.target, 'windows') }} - uses: actions/setup-python@v6 - with: - python-version: '3.13' - - - name: Set up Python (Windows) - if: contains(matrix.target, 'windows') + - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.13' - architecture: ${{ matrix.msvc_arch }} - - - name: Initialize MSVC developer command prompt - if: contains(matrix.target, 'windows') - uses: ./.github/actions/setup-msvc-dev-cmd - with: - arch: ${{ matrix.msvc_arch }} - - - name: Prepare Windows vcpkg cache directories - if: contains(matrix.target, 'windows') - shell: pwsh - run: | - $BinaryCache = Join-Path $env:GITHUB_WORKSPACE ".cache\vcpkg\binary" - $DownloadsCache = Join-Path $env:GITHUB_WORKSPACE ".cache\vcpkg\downloads" - New-Item -ItemType Directory -Force -Path $BinaryCache | Out-Null - New-Item -ItemType Directory -Force -Path $DownloadsCache | Out-Null - "VCPKG_DEFAULT_BINARY_CACHE=$BinaryCache" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "VCPKG_DOWNLOADS=$DownloadsCache" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Restore Windows vcpkg cache - if: contains(matrix.target, 'windows') - uses: actions/cache@v5 - with: - path: | - .cache/vcpkg/binary - .cache/vcpkg/downloads - key: windows-vcpkg-${{ matrix.target }}-${{ hashFiles('.github/actions/setup-native-prereqs/action.yml', 'crates/dsview-sys/native/CMakeLists.txt') }} - restore-keys: | - windows-vcpkg-${{ matrix.target }}- - name: Setup native prerequisites uses: ./.github/actions/setup-native-prereqs @@ -137,34 +87,8 @@ jobs: --test boundary \ -- --nocapture --test-threads=1 - - name: Find runtime library (Windows) - if: matrix.package_bundle && contains(matrix.target, 'windows') - id: find-runtime-windows - shell: pwsh - run: | - $runtime = Get-ChildItem "target/${{ matrix.target }}/release/build" -Recurse -Filter "dsview_runtime.dll" -ErrorAction Stop | - Sort-Object FullName | - Select-Object -First 1 -ExpandProperty FullName - if (-not $runtime) { - throw "Windows runtime library was not found under target/${{ matrix.target }}/release/build" - } - "runtime-lib=$runtime" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Find decode runtime library (Windows) - if: matrix.package_bundle && contains(matrix.target, 'windows') - id: find-decode-runtime-windows - shell: pwsh - run: | - $runtime = Get-ChildItem "target/${{ matrix.target }}/release/build" -Recurse -Filter "dsview_decode_runtime.dll" -ErrorAction Stop | - Sort-Object FullName | - Select-Object -First 1 -ExpandProperty FullName - if (-not $runtime) { - throw "Windows decode runtime library was not found under target/${{ matrix.target }}/release/build" - } - "runtime-lib=$runtime" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Find runtime library (Unix) - if: matrix.package_bundle && !contains(matrix.target, 'windows') + - name: Find runtime library + if: matrix.package_bundle id: find-runtime-unix shell: bash run: | @@ -179,8 +103,8 @@ jobs: fi echo "runtime-lib=$RUNTIME_LIB" >> "$GITHUB_OUTPUT" - - name: Find decode runtime library (Unix) - if: matrix.package_bundle && !contains(matrix.target, 'windows') + - name: Find decode runtime library + if: matrix.package_bundle id: find-decode-runtime-unix shell: bash run: | @@ -199,12 +123,7 @@ jobs: if: matrix.package_bundle id: exe-path shell: bash - run: | - if [[ "${{ matrix.target }}" == *"windows"* ]]; then - echo "exe=target/${{ matrix.target }}/release/dsview-cli.exe" >> $GITHUB_OUTPUT - else - echo "exe=target/${{ matrix.target }}/release/dsview-cli" >> $GITHUB_OUTPUT - fi + run: echo "exe=target/${{ matrix.target }}/release/dsview-cli" >> "$GITHUB_OUTPUT" - name: Package and validate bundle if: matrix.package_bundle @@ -213,6 +132,6 @@ jobs: target: ${{ matrix.target }} version: ${{ steps.version.outputs.version }} exe-path: ${{ steps.exe-path.outputs.exe }} - runtime-path: ${{ contains(matrix.target, 'windows') && steps.find-runtime-windows.outputs.runtime-lib || steps.find-runtime-unix.outputs.runtime-lib }} - decode-runtime-path: ${{ contains(matrix.target, 'windows') && steps.find-decode-runtime-windows.outputs.runtime-lib || steps.find-decode-runtime-unix.outputs.runtime-lib }} + runtime-path: ${{ steps.find-runtime-unix.outputs.runtime-lib }} + decode-runtime-path: ${{ steps.find-decode-runtime-unix.outputs.runtime-lib }} decoder-dir: DSView/libsigrokdecode4DSL/decoders diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9768c64..24579c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,15 +27,7 @@ jobs: runner: macos-15-intel package_bundle: true - target: aarch64-apple-darwin - runner: macos-14 - package_bundle: true - - target: x86_64-pc-windows-msvc - runner: windows-2022 - msvc_arch: x64 - package_bundle: true - - target: aarch64-pc-windows-msvc - runner: windows-11-arm - msvc_arch: arm64 + runner: macos-15 package_bundle: true steps: @@ -44,62 +36,20 @@ jobs: with: submodules: recursive - - name: Apply DSView local patches (Unix) - if: ${{ !contains(matrix.target, 'windows') }} + - name: Apply DSView local patches shell: bash run: ./scripts/apply-dsview-patches.sh - - name: Apply DSView local patches (Windows) - if: ${{ contains(matrix.target, 'windows') }} - shell: pwsh - run: ./scripts/apply-dsview-patches.ps1 - - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} - - name: Set up Python (Unix) - if: ${{ !contains(matrix.target, 'windows') }} + - name: Set up Python uses: actions/setup-python@v6 with: python-version: '3.13' - - name: Set up Python (Windows) - if: contains(matrix.target, 'windows') - uses: actions/setup-python@v6 - with: - python-version: '3.13' - architecture: ${{ matrix.msvc_arch }} - - - name: Initialize MSVC developer command prompt - if: contains(matrix.target, 'windows') - uses: ./.github/actions/setup-msvc-dev-cmd - with: - arch: ${{ matrix.msvc_arch }} - - - name: Prepare Windows vcpkg cache directories - if: contains(matrix.target, 'windows') - shell: pwsh - run: | - $BinaryCache = Join-Path $env:GITHUB_WORKSPACE ".cache\vcpkg\binary" - $DownloadsCache = Join-Path $env:GITHUB_WORKSPACE ".cache\vcpkg\downloads" - New-Item -ItemType Directory -Force -Path $BinaryCache | Out-Null - New-Item -ItemType Directory -Force -Path $DownloadsCache | Out-Null - "VCPKG_DEFAULT_BINARY_CACHE=$BinaryCache" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - "VCPKG_DOWNLOADS=$DownloadsCache" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - - - name: Restore Windows vcpkg cache - if: contains(matrix.target, 'windows') - uses: actions/cache@v5 - with: - path: | - .cache/vcpkg/binary - .cache/vcpkg/downloads - key: windows-vcpkg-${{ matrix.target }}-${{ hashFiles('.github/actions/setup-native-prereqs/action.yml', 'crates/dsview-sys/native/CMakeLists.txt') }} - restore-keys: | - windows-vcpkg-${{ matrix.target }}- - - name: Setup native prerequisites uses: ./.github/actions/setup-native-prereqs with: @@ -119,34 +69,8 @@ jobs: - name: Run tests run: cargo test --workspace --target ${{ matrix.target }} - - name: Find runtime library (Windows) - if: matrix.package_bundle && contains(matrix.target, 'windows') - id: find-runtime-windows - shell: pwsh - run: | - $runtime = Get-ChildItem "target/${{ matrix.target }}/release/build" -Recurse -Filter "dsview_runtime.dll" -ErrorAction Stop | - Sort-Object FullName | - Select-Object -First 1 -ExpandProperty FullName - if (-not $runtime) { - throw "Windows runtime library was not found under target/${{ matrix.target }}/release/build" - } - "runtime-lib=$runtime" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Find decode runtime library (Windows) - if: matrix.package_bundle && contains(matrix.target, 'windows') - id: find-decode-runtime-windows - shell: pwsh - run: | - $runtime = Get-ChildItem "target/${{ matrix.target }}/release/build" -Recurse -Filter "dsview_decode_runtime.dll" -ErrorAction Stop | - Sort-Object FullName | - Select-Object -First 1 -ExpandProperty FullName - if (-not $runtime) { - throw "Windows decode runtime library was not found under target/${{ matrix.target }}/release/build" - } - "runtime-lib=$runtime" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - - - name: Find runtime library (Unix) - if: matrix.package_bundle && !contains(matrix.target, 'windows') + - name: Find runtime library + if: matrix.package_bundle id: find-runtime-unix shell: bash run: | @@ -161,8 +85,8 @@ jobs: fi echo "runtime-lib=$RUNTIME_LIB" >> "$GITHUB_OUTPUT" - - name: Find decode runtime library (Unix) - if: matrix.package_bundle && !contains(matrix.target, 'windows') + - name: Find decode runtime library + if: matrix.package_bundle id: find-decode-runtime-unix shell: bash run: | @@ -181,12 +105,7 @@ jobs: if: matrix.package_bundle id: exe-path shell: bash - run: | - if [[ "${{ matrix.target }}" == *"windows"* ]]; then - echo "exe=target/${{ matrix.target }}/release/dsview-cli.exe" >> $GITHUB_OUTPUT - else - echo "exe=target/${{ matrix.target }}/release/dsview-cli" >> $GITHUB_OUTPUT - fi + run: echo "exe=target/${{ matrix.target }}/release/dsview-cli" >> "$GITHUB_OUTPUT" - name: Package and validate bundle if: matrix.package_bundle @@ -195,8 +114,8 @@ jobs: target: ${{ matrix.target }} version: ${{ steps.version.outputs.version }} exe-path: ${{ steps.exe-path.outputs.exe }} - runtime-path: ${{ contains(matrix.target, 'windows') && steps.find-runtime-windows.outputs.runtime-lib || steps.find-runtime-unix.outputs.runtime-lib }} - decode-runtime-path: ${{ contains(matrix.target, 'windows') && steps.find-decode-runtime-windows.outputs.runtime-lib || steps.find-decode-runtime-unix.outputs.runtime-lib }} + runtime-path: ${{ steps.find-runtime-unix.outputs.runtime-lib }} + decode-runtime-path: ${{ steps.find-decode-runtime-unix.outputs.runtime-lib }} decoder-dir: DSView/libsigrokdecode4DSL/decoders publish-release: name: publish-release @@ -238,7 +157,6 @@ jobs: This release provides pre-built bundles for: - Linux x86_64 and ARM64 - macOS x86_64 (Intel) and ARM64 (Apple Silicon) - - Windows x86_64 and ARM64 ### Bundle Contents