diff --git a/README.md b/README.md index 7eb05eff0..d42c1a544 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ See the **[Parts List](docs/get-started/parts.md)** for everything you need with ### 2. Wire it up -Follow the **[Sound Trigger Wiring Guide](docs/build/sound-trigger.md)** to connect the SEN-14262 to the OPS243-A. The (deprecated) K-LD7 modules connect via USB — no wiring needed. +Follow the **[Sound Trigger Wiring Guide](docs/build/sound-trigger.md)** to connect the SEN-14262 to the OPS243-A. The (deprecated) K-LD7 modules connect via USB — no wiring needed. The OPS243 also has an opt-in internal speed-trigger path, which requires OPS243-A firmware v1.3.2 or newer in the 1.3 release train; see the **[Internal Hardware Trigger Guide](docs/hardware-trigger.md)**. **Adding the IWR6843 angle radar?** The Pi cannot power both radars over USB, so the OPS243 moves to the Pi's GPIO UART header while the TI board takes the USB diff --git a/docs/changelog.md b/docs/changelog.md index 23e912e2c..983199e3d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -63,6 +63,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and a trigger-frame impact marker. MP4 conversion starts only after a manual Replay selection, caches the result beside the raw capture, and reports retryable preparation or playback failures without affecting shot results. +- **Opt-in OPS243 internal hardware trigger.** `--trigger hardware` configures + the radar's internal speed trigger with 25 mph, magnitude 25, and the tested + `S#6` split at 30 ksps. Captures are parsed and the radar is re-armed with + `GC` after each dump. The existing sound-trigger default and + `--sound-pre-trigger` behavior are unchanged. Hardware mode requires and + validates OPS243-A firmware v1.3.2 or newer in the 1.3 release train; + firmware v1.3.1 is rejected because of a vendor data-sequence bug. See + [OPS243 Internal Hardware Trigger](hardware-trigger.md). - **Battery and external-power status for Raspberry Pi UPS boards.** OpenFlight can now display charging state and battery percentage, issue dismissible 20% and 10% warnings while discharging, and record throttled power telemetry in diff --git a/docs/get-started/parts.md b/docs/get-started/parts.md index 505fc2085..aa3b05f7c 100644 --- a/docs/get-started/parts.md +++ b/docs/get-started/parts.md @@ -23,7 +23,7 @@ Hardware components for building the OpenFlight golf launch monitor. The sound trigger detects club impact to precisely time radar captures. Essential for spin detection via rolling buffer mode. -> **Optional path, not merged yet:** [PR #221](https://github.com/open-flight/openflight/pull/221) adds an opt-in `--trigger hardware` mode in which the OPS243 fires the rolling-buffer dump from its own internal speed trigger, with no SEN-14262 in the loop. It needs OPS243-A firmware 1.3.2, which every OPS243-A can run; whether getting there costs you anything depends on the firmware your radar arrived with, see [Internal Trigger Instead](#internal-trigger-instead-pr-221) below. Until the PR lands the sound trigger is the supported trigger and stays in the totals. +> **Optional internal-trigger path:** Hardware mode lets the OPS243 fire the rolling-buffer dump from its own internal speed trigger, with no SEN-14262 in the loop. It requires OPS243-A firmware 1.3.2 or newer in the 1.3 release train; firmware 1.3.1 is rejected because of a vendor data-sequence bug. See [Internal Hardware Trigger](#internal-hardware-trigger) below. | Part | Description | Link | ~Price | |------|-------------|------|--------| @@ -49,11 +49,11 @@ SEN-14262 Raspberry Pi OPS243 See [sound-trigger-wiring.md](../build/sound-trigger.md) for detailed instructions and troubleshooting. -### Internal Trigger Instead (PR #221) +### Internal Hardware Trigger -[PR #221](https://github.com/open-flight/openflight/pull/221) lets the OPS243-A start the rolling-buffer capture from its own speed trigger, so the sound detector, its resistor and its wiring are not needed. The firmware that adds that trigger is OPS243-A 1.3.2, and any OPS243-A can be brought to it. What that costs you depends on what your radar arrived with, so check before buying anything: plug the radar into USB, open a serial terminal, send `?V`, and read the version it prints back. +Hardware mode lets the OPS243-A start the rolling-buffer capture from its own speed trigger, so the sound detector, its resistor and its wiring are not needed. The supported firmware is OPS243-A 1.3.2 or newer in the 1.3 release train. Check before buying anything: plug the radar into USB, open a serial terminal, send `?V`, and read the version it prints back. -- **It reports 1.3.2 or later.** Nothing to buy. OmniPreSense [told the project on 2026-09-10](https://github.com/open-flight/openflight/pull/221#issuecomment-5619646576) that 1.3.2 went onto the sensors shipping from that build on (1.3.1 had gone to some earlier customers with a late bug), so a new order should arrive like this. Once the PR lands, skip the Sound Trigger table above. +- **It reports 1.3.2 or later in the 1.3 train.** Nothing to buy; use `scripts/start-kiosk.sh --trigger hardware`. OmniPreSense [told the project on 2026-09-10](https://github.com/open-flight/openflight/pull/221#issuecomment-5619646576) that 1.3.2 went onto the sensors shipping from that build on (1.3.1 had gone to some earlier customers with a late bug), so a new order should arrive like this. Skip the Sound Trigger table above if you choose hardware mode. - **It reports 1.3.1 or older.** You flash it yourself, which is where the debugger cost comes in. OmniPreSense's [AN-013 code-update note](https://omnipresense.com/wp-content/uploads/2019/06/AN-013-D_OPS241-Code-Update.pdf) is the procedure: a SEGGER J-Link on the radar's keyed `J2` JTAG header (a 10-pin 1.27 mm Cortex debug header, not the `J3` UART header OpenFlight wires to), Infineon's free XMCFlasher in Serial Wire Debug mode with the XMC4500-1024 target selected, and the 1.3.2 hex file, which is not a public download: email customerservice@omnipresense.com for it, and they will also confirm which J-Link model to get. Send `?P` first and pick the XMC4700 in XMCFlasher instead if the board reports that part ([note on the PR](https://github.com/open-flight/openflight/pull/221#issuecomment-5463503457)). Do not press Erase in XMCFlasher: it clears the factory settings some sensors carry and anything you saved to persistent memory. On Windows run the J-Link driver installer as administrator and tick the legacy J-Link USB driver, or XMCFlasher will not find the probe ([upgrade report](https://github.com/open-flight/openflight/pull/221#issuecomment-5756563718)). | Part | Description | Link | ~Price | diff --git a/docs/hardware-trigger.md b/docs/hardware-trigger.md new file mode 100644 index 000000000..f09648400 --- /dev/null +++ b/docs/hardware-trigger.md @@ -0,0 +1,82 @@ +# OPS243 Internal Hardware Trigger + +OpenFlight’s `hardware` trigger mode lets the OPS243-A decide when a rolling-buffer capture starts. It is opt-in; the kiosk default remains the existing SEN-14262 sound trigger. + +## OPS243 firmware prerequisite + +Hardware-trigger mode requires **OPS243-A firmware v1.3.2 or newer in the 1.3 +release train**. Firmware v1.3.1 has a vendor data-sequence bug and is rejected. +The driver queries `?V` before sending the internal trigger setup commands and +fails fast if the reported version is from an older or different release train, +malformed, or unavailable. Update the physical OPS243 using the manufacturer’s +firmware procedure before running `--trigger hardware`; this application does +not flash the radar. + +## Methodology + +The host configures the radar once and then waits for the radar’s completed rolling-buffer dump. The host does not poll speed reports or send `S!` for each shot: + +1. Put the radar in idle mode with `PI`. +2. Set a temporary outbound `ST-90` guard before entering rolling-buffer mode. + `GC` can restore the detector units to m/s before `US` is sent; 90 is below + the 30 ksps ceiling of 93.2 m/s and is still above ordinary startup motion + if the board retained mph units. +3. Enter rolling-buffer mode with `GC`, then resend the guard because `GC` + resets detector settings. +4. Restore the detector settings that `GC` resets: 30 ksps and MPH units first, + then raise the temporary guard to `ST-200` while the remaining settings are + written: 128 samples, `X=2`, outbound filtering, JSON plus magnitude output, + and the configured `S#n` split. +5. Restore the magnitude gate `SMn`, then restore the requested `ST-n` last and allow the 4,096-sample history to fill. +6. Wait for the board-triggered dump, parse the I/Q payload, and reject it if it has no outbound ball-speed reading at or above 35 mph. +7. Re-arm with the same temporary guard around `GC`, restore the cached settings, and wait for the buffer to fill again. A serial write timeout keeps the capture and reports a retryable re-arm failure instead of discarding the shot. + +The implementation is intentionally limited to the OPS243 trigger path; the +experimental analysis and UI work remain outside this focused change. + +## Defaults and command + +| Setting | Hardware-mode default | +|---|---:| +| Trigger threshold | 25 mph | +| Trigger magnitude | 25 (`SM25`) | +| Pre-trigger split | 6 segments (`S#6`) | +| Sample rate | 30 ksps (required) | +| Minimum accepted outbound ball speed | 35 mph | + +Run the mode directly with: + +```bash +openflight-server \ + --trigger hardware \ + --trigger-threshold 25 \ + --trigger-magnitude 25 \ + --pre-trigger-segments 6 \ + --sample-rate 30 +``` + +The kiosk script forwards the same settings: + +```bash +scripts/start-kiosk.sh --trigger hardware +``` + +Use `--trigger-threshold`, `--trigger-magnitude`, and `--pre-trigger-segments` to override the hardware path. `S#6` applies only to this new mode. The established sound path continues to use `--sound-pre-trigger` and keeps its existing default and behavior: + +```bash +scripts/start-kiosk.sh --trigger sound --sound-pre-trigger 16 +``` + +The internal trigger does not depend on the SEN-14262 sound edge. Existing sound-trigger wiring can remain installed, but selecting `hardware` is the software choice that activates the OPS243 internal trigger. + +## Raspberry Pi retest checklist + +Before treating a PR as ready for merge, run the hardware path on the target Pi and record the observations in the PR body: + +Also run the unchanged sound path with the same representative shots: + +```bash +scripts/start-kiosk.sh --trigger sound +``` + +I was unable to regression test the sound trigger. diff --git a/docs/how-it-works/rolling-buffer.md b/docs/how-it-works/rolling-buffer.md index 1b45e10ec..9b5032ef1 100644 --- a/docs/how-it-works/rolling-buffer.md +++ b/docs/how-it-works/rolling-buffer.md @@ -27,6 +27,13 @@ The runtime then starts in the persisted `GC` rolling-buffer mode without re-entering it. See [Sound trigger wiring](../build/sound-trigger.md) for the recommended direct hardware trigger. +The OPS243 internal speed trigger is available as a separate, opt-in capture +strategy. It requires OPS243-A firmware v1.3.2 or newer in the 1.3 release +train, uses the tested 30 ksps configuration, and defaults to `S#6`; see +the [Internal Hardware Trigger guide](../hardware-trigger.md) for its command +ordering, re-arm behavior, and Raspberry Pi validation checklist. The sound +path and its defaults remain unchanged. + ## Current defaults | Setting | Value | @@ -70,4 +77,5 @@ in the session JSONL logs described in the - [Raspberry Pi Setup](../setup/raspberry-pi.md) - [Sound Trigger Wiring](../build/sound-trigger.md) +- [OPS243 Internal Hardware Trigger](../hardware-trigger.md) - [Spin Replay and Diagnostics](../development/spin-replay.md) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index cb45e628f..3511ac894 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -50,7 +50,10 @@ How a capture is initiated and framed. | Flag | Type / default | Description | | --- | --- | --- | -| `--trigger` | choices: `sound`, `speed`; default `sound` | Trigger strategy | +| `--trigger` | choices: `sound`, `speed`, `hardware`; default `sound` | Trigger strategy | +| `--trigger-threshold`, `--speed-trigger-threshold`, `--trigger-speed` | float | Internal or host speed-trigger threshold in mph; hardware default `25` | +| `--trigger-magnitude` | int; default `25` | OPS243 internal trigger magnitude (`SMn`) | +| `--pre-trigger-segments` | int; default `6` | Hardware-trigger pre-trigger segments (`S#n`) | | `--sound-pre-trigger` | int; default `16` | Pre-trigger segments S#n, 0-32 (default: 16 = 50/50 split, each segment ~4.27ms at 30ksps) | ## IWR6843 angle radar diff --git a/src/openflight/ops243.py b/src/openflight/ops243.py index 8303691d5..ca645a61b 100644 --- a/src/openflight/ops243.py +++ b/src/openflight/ops243.py @@ -35,6 +35,7 @@ import json import logging +import math import re import threading import time @@ -91,6 +92,7 @@ def set_show_raw_readings(enabled: bool): _CLOCK_RE = re.compile(r'"?Clock"?\s*:\s*"?(-?\d+(?:\.\d+)?)"?') +_OPS243_FIRMWARE_RE = re.compile(r"(?P\d+)\.(?P\d+)\.(?P\d+)") def _parse_ops_clock(response: str) -> Optional[float]: @@ -161,6 +163,15 @@ class OPS243Radar: # Default serial settings per datasheet DEFAULT_BAUD = 57600 DEFAULT_TIMEOUT = 1.0 + INTERNAL_TRIGGER_FIRMWARE_MINIMUM = (1, 3, 2) + REQUIRED_INTERNAL_TRIGGER_FIRMWARE = ".".join( + str(part) for part in INTERNAL_TRIGGER_FIRMWARE_MINIMUM + ) + # GC can restore the detector units to m/s before US is sent. 90 is below + # the 30 ksps ceiling of 93.2 m/s, and is still above ordinary startup + # motion if the board retained mph units. + INTERNAL_TRIGGER_GUARD_THRESHOLD = 90.0 + INTERNAL_TRIGGER_MPH_GUARD_THRESHOLD = 200.0 # Target rate on the J3 UART. At 230,400 a dump moves in ~1.8s; the # 19,200 factory default would take 21s and miss every shot. @@ -175,6 +186,12 @@ class OPS243Radar: # 4096 I + 4096 Q samples, plus margin for whitespace and timing lines. DUMP_BYTES = 45000 + # USB CDC can pause for more than one half-second while a rolling-buffer + # dump is still in flight. Wait through that gap, but cap startup drain so + # a continuously streaming or wedged board cannot hang connection. + SERIAL_DRAIN_QUIET_S = 1.0 + SERIAL_DRAIN_TIMEOUT_S = 8.0 + # Bound every serial write. A radar that is mid-dump (e.g. HOST_INT # re-asserted by the ball hitting the net) stops servicing commands; # without a write timeout, serial.write() blocks the capture thread @@ -222,6 +239,8 @@ def __init__( self._json_mode = False self._magnitude_enabled = False self._speed_read_buffer = "" + self._internal_speed_trigger_config = None + self._hardware_trigger_recovery_required = False self.last_hardware_trigger_first_byte_timestamp: Optional[float] = None # Most recent OPS-clock -> host-epoch sync (see read_clock_sync). self.last_clock_sync: Optional[dict] = None @@ -450,7 +469,11 @@ def disconnect(self): self.serial.close() self.serial = None - def _drain_serial(self, quiet_period: float = 0.5, max_wait: Optional[float] = None): + def _drain_serial( + self, + quiet_period: float = SERIAL_DRAIN_QUIET_S, + max_wait: Optional[float] = None, + ): """ Drain serial port until no data arrives for quiet_period seconds. @@ -461,23 +484,41 @@ def _drain_serial(self, quiet_period: float = 0.5, max_wait: Optional[float] = N Args: quiet_period: Seconds of silence before considering drain complete max_wait: Maximum total seconds to wait before giving up. None - derives it from baud (floor 5s) so a slow UART link gets + derives it from baud (with an 8s floor) so a slow UART link gets long enough to finish a straggling dump. """ if max_wait is None: - max_wait = self.transfer_budget_s(floor=5.0) - start = time.monotonic() + max_wait = self.transfer_budget_s(floor=self.SERIAL_DRAIN_TIMEOUT_S) + deadline = time.monotonic() + max_wait drained = 0 old_timeout = self.serial.timeout - self.serial.timeout = quiet_period + deadline_exceeded = False + + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + deadline_exceeded = True + break + + self.serial.timeout = min(quiet_period, remaining) + chunk = self.serial.read(4096) + if not chunk: + # No data for quiet_period means the dump ended. If the + # read itself consumed the remaining budget, it did not. + deadline_exceeded = time.monotonic() >= deadline + break + drained += len(chunk) + finally: + self.serial.timeout = old_timeout - while time.monotonic() - start < max_wait: - chunk = self.serial.read(4096) - if not chunk: - break # No data for quiet_period — drain complete - drained += len(chunk) + if deadline_exceeded: + raise ConnectionError( + "OPS243 serial stream did not quiesce within " + f"{max_wait:.1f}s ({drained} bytes drained); " + "the radar may still be finishing a rolling-buffer dump" + ) - self.serial.timeout = old_timeout self.serial.reset_input_buffer() if drained > 0: @@ -781,6 +822,24 @@ def get_firmware_version(self) -> str: except json.JSONDecodeError: return response + def validate_internal_trigger_firmware(self) -> str: + """Require a compatible OPS243-A firmware release for internal triggering.""" + version = self._probe_firmware_version() + required = self.REQUIRED_INTERNAL_TRIGGER_FIRMWARE + match = _OPS243_FIRMWARE_RE.fullmatch(version or "") + parsed = ( + tuple(int(match.group(part)) for part in ("major", "minor", "patch")) if match else None + ) + minimum = self.INTERNAL_TRIGGER_FIRMWARE_MINIMUM + if parsed is None or parsed[:2] != minimum[:2] or parsed < minimum: + detected = f"v{version}" if version else "no response" + raise RuntimeError( + f"Internal hardware trigger requires OPS243-A firmware v{required} " + "or newer in the 1.3 release train; " + f"detected {detected}. Update the OPS243 before using --trigger hardware." + ) + return version + def set_units(self, unit: SpeedUnit): """ Set speed output units. @@ -1512,13 +1571,19 @@ def wait_for_hardware_trigger( last_data_time = None bytes_received = 0 self.last_hardware_trigger_first_byte_timestamp = None + recovery_required = bool(getattr(self, "_hardware_trigger_recovery_required", False)) + capture_started = False + if recovery_required: + logger.info( + "[OPS] Re-arm recovery: discarding trailing output until a fresh capture starts" + ) while time.time() < deadline: waiting = self.serial.in_waiting if waiting: chunk = self.serial.read(waiting) first_byte_timestamp = None - if last_data_time is None: + if not capture_started: idle_bytes.extend(chunk) marker_offsets = [idle_bytes.find(marker) for marker in capture_markers] marker_offsets = [offset for offset in marker_offsets if offset >= 0] @@ -1533,6 +1598,10 @@ def wait_for_hardware_trigger( capture_start = min(marker_offsets) chunk = bytes(idle_bytes[capture_start:]) idle_bytes.clear() + capture_started = True + if recovery_required: + self._hardware_trigger_recovery_required = False + logger.info("[OPS] Re-arm recovery: fresh capture boundary found") first_byte_timestamp = time.time() response_lines.append(chunk.decode("ascii", errors="ignore")) @@ -1578,7 +1647,7 @@ def wait_for_hardware_trigger( break time.sleep(0.02) - full_response = "".join(response_lines) if response_lines else "" + full_response = "".join(response_lines) if capture_started else "" if not full_response: logger.info("[OPS] Hardware trigger: no data received within %.0fs", timeout) @@ -1680,6 +1749,183 @@ def rearm_rolling_buffer(self, pre_trigger_segments: int = 16): self.serial.reset_input_buffer() logger.info("[OPS] Rolling buffer re-armed (S#%d)", pre_trigger_segments) + def _drain_rearm_serial(self, quiet_period: float = 0.2): + """Drain any tail bytes before changing the board mode. + + The internal trigger returns a complete I/Q dump without an ``S!`` + command. A few firmware versions can still have a short tail in the + UART buffer when the Q array is complete, so GC must wait for a quiet + gap before it is sent. The wait is bounded by the same dump budget + used by the ordinary re-arm path. + """ + if not self.serial or not self.serial.is_open: + raise ConnectionError("Not connected to radar") + + budget = self.transfer_budget_s(floor=self.REARM_DRAIN_TIMEOUT_S) + started = time.monotonic() + last_data = started + drained = 0 + + while time.monotonic() - started < budget: + waiting = self.serial.in_waiting + if waiting: + drained += len(self.serial.read(waiting)) + last_data = time.monotonic() + elif time.monotonic() - last_data >= quiet_period: + break + time.sleep(0.02) + + if drained: + logger.info("[OPS] Internal-trigger re-arm drained %d trailing bytes", drained) + + @staticmethod + def _format_internal_trigger_threshold(threshold_mph: float) -> str: + """Format the outbound (negative radar velocity) ``ST`` threshold.""" + return f"-{abs(float(threshold_mph)):g}" + + def _send_internal_trigger_threshold(self, threshold_mph: float): + """Set an internal trigger threshold as one complete serial command.""" + signed_threshold = self._format_internal_trigger_threshold(threshold_mph) + self.serial.write(f"ST{signed_threshold}\r".encode("ascii")) + self.serial.flush() + + def configure_for_internal_speed_trigger( + self, + trigger_threshold_mph: float = 25.0, + pre_trigger_segments: int = 6, + trigger_magnitude: int = 25, + sample_rate_ksps: int = 30, + ): + """Configure the OPS243's internal speed-triggered rolling buffer. + + The board owns the trigger edge in this mode. ``ST`` arms the + internal speed threshold, ``GC`` enters rolling-buffer mode, and the + detector settings are restored after GC because the firmware resets + them when changing modes. This path intentionally does not enable + the optional SZ board-processing stream. + """ + if not self.serial or not self.serial.is_open: + raise ConnectionError("Not connected to radar") + + threshold = float(trigger_threshold_mph) + if not math.isfinite(threshold) or threshold < 0: + raise ValueError("Trigger threshold must be non-negative") + if not isinstance(pre_trigger_segments, int): + raise ValueError("Pre-trigger segments must be an integer") + if not isinstance(trigger_magnitude, int) or not 1 <= trigger_magnitude <= 2000: + raise ValueError("Trigger magnitude must be between 1 and 2000") + if sample_rate_ksps != 30: + raise ValueError("Internal speed trigger requires a 30 ksps sample rate") + + self.validate_internal_trigger_firmware() + + pre_trigger_segments = max(0, min(32, pre_trigger_segments)) + signed_threshold = self._format_internal_trigger_threshold(threshold) + guard_threshold = self.INTERNAL_TRIGGER_GUARD_THRESHOLD + self._speed_read_buffer = "" + self.serial.reset_input_buffer() + self._hardware_trigger_recovery_required = False + + # Keep the board from starting a dump while GC and the detector + # settings are being restored. GC resets the trigger settings, so the + # guard is sent on both sides of the mode transition. + self._send_command("PI") + self._send_internal_trigger_threshold(guard_threshold) + time.sleep(0.1) + self._send_command("GC") + self._send_internal_trigger_threshold(guard_threshold) + + self._internal_speed_trigger_config = ( + threshold, + pre_trigger_segments, + trigger_magnitude, + sample_rate_ksps, + ) + self._restore_internal_speed_trigger_settings() + self.serial.reset_input_buffer() + + # A 4,096-sample buffer takes about 136.5 ms at 30 ksps. + time.sleep(0.3) + logger.info( + "[OPS] Internal speed trigger armed (ST%s, S#%d, S=%d, SM%d)", + signed_threshold, + pre_trigger_segments, + sample_rate_ksps, + trigger_magnitude, + ) + + def _restore_internal_speed_trigger_settings(self): + """Restore detector settings cleared by the last GC command.""" + if self._internal_speed_trigger_config is None: + raise RuntimeError("Internal speed trigger has not been configured") + + threshold, pre_trigger_segments, trigger_magnitude, sample_rate_ksps = ( + self._internal_speed_trigger_config + ) + threshold_text = f"{threshold:g}" + signed_threshold = self._format_internal_trigger_threshold(threshold) + + self.set_sample_rate(sample_rate_ksps * 1000) + self.set_units(SpeedUnit.MPH) + # The first guard is valid in either unit system. Once MPH is explicit, + # raise it near the 30 ksps ceiling while the remaining writes run. + self._send_internal_trigger_threshold(self.INTERNAL_TRIGGER_MPH_GUARD_THRESHOLD) + self.set_transmit_power(0) + self.set_buffer_size(128) + self.set_fft_size(2) + self._send_command("R-") + self._send_command(f"R>{threshold_text}") + self.enable_json_output(True) + self.enable_magnitude_report(True) + self._send_command("W0") + self._send_command(f"S#{pre_trigger_segments}") + + # Keep the threshold guarded until every other setting is restored. + # ST/SM require a terminating carriage return and are deliberately + # sent after MPH is restored so the threshold is interpreted in mph. + self.serial.write(f"SM{trigger_magnitude}\r".encode("ascii")) + self.serial.write(f"ST{signed_threshold}\r".encode("ascii")) + self.serial.flush() + time.sleep(0.1) + + def rearm_internal_speed_trigger(self, sample_rate_ksps: int = 30) -> bool: + """Re-arm an internal hardware trigger after a completed dump. + + GC restarts the rolling buffer but also restores firmware defaults, so + the cached detector settings are applied again. A serial write timeout + is recoverable: the capture is valid and the next cycle can retry the + GC sequence. + """ + if not self.serial or not self.serial.is_open: + raise ConnectionError("Not connected to radar") + if sample_rate_ksps != 30: + raise ValueError("Internal speed trigger requires a 30 ksps sample rate") + if self._internal_speed_trigger_config is None: + raise RuntimeError("Internal speed trigger has not been configured") + + try: + self._drain_rearm_serial() + self.serial.reset_input_buffer() + self._send_internal_trigger_threshold(self.INTERNAL_TRIGGER_GUARD_THRESHOLD) + self.serial.write(b"GC") + self.serial.flush() + time.sleep(0.15) + self._send_internal_trigger_threshold(self.INTERNAL_TRIGGER_GUARD_THRESHOLD) + self._restore_internal_speed_trigger_settings() + self.serial.reset_input_buffer() + except serial.SerialTimeoutException as error: + self._hardware_trigger_recovery_required = True + logger.warning( + "[OPS] Internal trigger re-arm timed out; capture retained and " + "re-arm will be retried: %s", + error, + ) + return False + + time.sleep((4096 / (sample_rate_ksps * 1000)) + 0.05) + logger.info("[OPS] Internal speed trigger re-armed (GC)") + return True + def configure_for_rolling_buffer( self, pre_trigger_segments: int = 16, sample_rate_ksps: int = 30 ): diff --git a/src/openflight/rolling_buffer/__init__.py b/src/openflight/rolling_buffer/__init__.py index 5a265ee4c..fbb2dfa21 100644 --- a/src/openflight/rolling_buffer/__init__.py +++ b/src/openflight/rolling_buffer/__init__.py @@ -27,6 +27,7 @@ ) from .processor import RollingBufferProcessor from .trigger import ( + HardwareTriggeredCapture, SoundTrigger, SpeedTriggeredCapture, TriggerStrategy, @@ -55,6 +56,7 @@ "RollingBufferProcessor", # Triggers "TriggerStrategy", + "HardwareTriggeredCapture", "SoundTrigger", "SpeedTriggeredCapture", "create_trigger", diff --git a/src/openflight/rolling_buffer/monitor.py b/src/openflight/rolling_buffer/monitor.py index c61b6722d..2045415d5 100644 --- a/src/openflight/rolling_buffer/monitor.py +++ b/src/openflight/rolling_buffer/monitor.py @@ -225,16 +225,30 @@ def connect(self) -> bool: """ Connect to radar and configure based on trigger type. - Sound uses the persisted rolling-buffer configuration. Speed handles - its own mode transition when a qualifying speed is detected. + Sound uses the persisted rolling-buffer configuration. The opt-in + hardware trigger configures the OPS243 internal speed trigger. Speed + handles its own mode transition when a qualifying speed is detected. Returns: True if successful """ self.radar.connect() + if self.trigger_type == "hardware": + self.radar.configure_for_internal_speed_trigger( + trigger_threshold_mph=self.trigger.trigger_threshold_mph, + pre_trigger_segments=self.trigger.pre_trigger_segments, + trigger_magnitude=self.trigger.trigger_magnitude, + sample_rate_ksps=self.sample_rate_ksps, + ) + logger.info( + "[MONITOR] Internal hardware trigger configured (threshold %.1f, S#%d, SM%d)", + self.trigger.trigger_threshold_mph, + self.trigger.pre_trigger_segments, + self.trigger.trigger_magnitude, + ) # Speed trigger handles its own configuration (starts in speed mode). - if self.trigger_type != "speed": + elif self.trigger_type != "speed": pre_trigger_segments = getattr(self.trigger, "pre_trigger_segments", 12) self.radar.prepare_persisted_rolling_buffer( pre_trigger_segments=pre_trigger_segments, diff --git a/src/openflight/rolling_buffer/trigger.py b/src/openflight/rolling_buffer/trigger.py index 3ea9ccefe..eb56b5920 100644 --- a/src/openflight/rolling_buffer/trigger.py +++ b/src/openflight/rolling_buffer/trigger.py @@ -5,6 +5,7 @@ """ import logging +import math import threading import time from abc import ABC, abstractmethod @@ -684,12 +685,124 @@ def reset(self): pass # No state to reset +class HardwareTriggeredCapture(TriggerStrategy): + """Capture using the OPS243's internal speed trigger.""" + + def __init__( + self, + trigger_threshold_mph: float = 25.0, + min_ball_speed_mph: float = 35.0, + pre_trigger_segments: int = 6, + trigger_magnitude: int = 25, + sample_rate_ksps: int = 30, + ): + super().__init__(pre_trigger_segments=pre_trigger_segments) + + threshold = float(trigger_threshold_mph) + if not math.isfinite(threshold) or threshold < 0: + raise ValueError("Trigger threshold must be non-negative") + if not isinstance(pre_trigger_segments, int) or not 0 <= pre_trigger_segments <= 32: + raise ValueError("Pre-trigger segments must be an integer from 0 to 32") + if not isinstance(trigger_magnitude, int) or not 1 <= trigger_magnitude <= 2000: + raise ValueError("Trigger magnitude must be between 1 and 2000") + if sample_rate_ksps != 30: + raise ValueError("Internal speed trigger requires a 30 ksps sample rate") + + self.trigger_threshold_mph = threshold + self.min_ball_speed_mph = float(min_ball_speed_mph) + self.trigger_magnitude = trigger_magnitude + self.sample_rate_ksps = sample_rate_ksps + + def wait_for_trigger( + self, + radar: "OPS243Radar", + processor: RollingBufferProcessor, + timeout: float = 30.0, + ) -> Optional[IQCapture]: + """Wait for one internal-trigger dump and return a valid capture.""" + logger.info( + "[TRIGGER] Waiting for OPS hardware trigger >= %.1f mph (timeout=%.0fs)...", + self.trigger_threshold_mph, + timeout, + ) + + response = radar.wait_for_hardware_trigger(timeout=timeout) + if not response: + logger.info("[TRIGGER] OPS hardware trigger timeout — no dump received") + return None + + response_bytes = len(response) + first_byte_timestamp = getattr( + radar, + "last_hardware_trigger_first_byte_timestamp", + None, + ) + capture = None + parse_error = None + started_at = time.time() + try: + capture = processor.parse_capture( + response, + first_byte_timestamp=first_byte_timestamp, + ) + except Exception as error: # pylint: disable=broad-exception-caught + parse_error = error + logger.warning("[TRIGGER] Hardware capture parse failed: %s", error, exc_info=True) + finally: + try: + rearmed = radar.rearm_internal_speed_trigger(self.sample_rate_ksps) + except Exception as error: # pylint: disable=broad-exception-caught + rearmed = False + logger.warning("[TRIGGER] Internal trigger re-arm failed: %s", error, exc_info=True) + + trigger_latency_ms = (time.time() - started_at) * 1000.0 + if not rearmed: + logger.warning("[TRIGGER] Hardware capture retained while radar re-arm is pending") + + if capture is None or parse_error is not None: + self._append_diagnostic( + accepted=False, + reason="parse_failed", + response_bytes=response_bytes, + trigger_latency_ms=trigger_latency_ms, + ) + return None + + summary = self._summarize_capture_activity(processor, capture) + valid_outbound = [ + speed for speed in summary["all_outbound_speeds"] if speed >= self.min_ball_speed_mph + ] + if not valid_outbound: + self._append_activity_diagnostic( + summary, + accepted=False, + reason="no_ball_speed", + response_bytes=response_bytes, + trigger_latency_ms=trigger_latency_ms, + ) + logger.info( + "[TRIGGER] OPS hardware capture rejected — no outbound speed >= %.1f mph", + self.min_ball_speed_mph, + ) + return None + + logger.info( + "[TRIGGER] OPS hardware capture accepted — peak %.1f mph", + max(valid_outbound), + ) + return capture + + def reset(self): + """Reset trigger state; the radar owns the armed state.""" + pass + + def create_trigger(trigger_type: str = "sound", **kwargs) -> TriggerStrategy: """ Factory function to create trigger strategy. Args: - trigger_type: "sound" (production) or "speed" (fallback) + trigger_type: "sound" (production), "hardware", or "speed" (fallback) **kwargs: Arguments passed to trigger constructor Returns: @@ -700,8 +813,10 @@ def create_trigger(trigger_type: str = "sound", **kwargs) -> TriggerStrategy: Requires GATE voltage to reach 3.3V threshold. - "speed": Fast speed detection triggers rolling buffer capture. Recommended fallback by OmniPreSense. ~5-6ms response time. + - "hardware": OPS243 internal speed trigger with rolling-buffer capture. """ triggers = { + "hardware": HardwareTriggeredCapture, "speed": SpeedTriggeredCapture, "sound": SoundTrigger, } diff --git a/src/openflight/server.py b/src/openflight/server.py index 5ee4fc265..7396f14b8 100644 --- a/src/openflight/server.py +++ b/src/openflight/server.py @@ -3692,12 +3692,15 @@ def start_monitor( Args: port: Serial port for radar mock: Run in mock mode without radar - trigger_type: Trigger strategy (sound or speed) + trigger_type: Trigger strategy (hardware, sound, speed, or polling) debug: Enable verbose debug output ops_baud: Target UART baud when the OPS243 is on the GPIO header """ global monitor, mock_mode, mock_swing_speed_mode, debug_mode, radar_config + if trigger_type == "hardware" and sample_rate_ksps != 30: + raise ValueError("Hardware trigger mode requires a 30 ksps sample rate") + # Stop any existing monitor first if monitor is not None: print("[MONITOR] Stopping existing monitor before starting new one") @@ -4379,10 +4382,31 @@ def main(): _add_ballistics_arguments(parser) parser.add_argument( "--trigger", - choices=["sound", "speed"], + choices=["hardware", "sound", "speed"], default="sound", help="Trigger strategy (default: sound)", ) + parser.add_argument( + "--trigger-threshold", + "--speed-trigger-threshold", + "--trigger-speed", + dest="trigger_threshold", + type=float, + default=None, + help="Internal or host speed-trigger threshold in mph (hardware default: 25)", + ) + parser.add_argument( + "--trigger-magnitude", + type=int, + default=25, + help="OPS243 internal trigger magnitude SMn, 1-2000 (default: 25)", + ) + parser.add_argument( + "--pre-trigger-segments", + type=int, + default=6, + help="Internal hardware-trigger pre-trigger segments S#n, 0-32 (default: 6)", + ) parser.add_argument( "--swing-speed", action="store_true", @@ -4655,6 +4679,15 @@ def main(): args = parser.parse_args() _apply_kld7_device_defaults(args) + if args.trigger_threshold is not None and args.trigger_threshold < 0: + parser.error("--trigger-threshold must be non-negative") + if args.trigger == "hardware" and not 1 <= args.trigger_magnitude <= 2000: + parser.error("--trigger-magnitude must be between 1 and 2000") + if args.trigger == "hardware" and args.sample_rate != 30: + parser.error("--trigger hardware requires --sample-rate 30") + if args.trigger == "hardware" and not 0 <= args.pre_trigger_segments <= 32: + parser.error("--pre-trigger-segments must be between 0 and 32") + # Mount tilt cannot be defaulted safely (a wrong value silently biases the # launch angle), so require it whenever the K-LD7 radars are enabled. if args.kld7 and args.kld7_mount_tilt is None: @@ -4783,9 +4816,23 @@ def main(): set_show_raw_readings(True) print("Raw radar readings display ENABLED - signed speed values will be shown") - # Start the monitor - # Build trigger-specific kwargs (pre_trigger_segments always passed) - trigger_kwargs = {"pre_trigger_segments": args.sound_pre_trigger} + # Start the monitor. Keep sound-only settings out of the other strategies. + if args.trigger == "sound": + trigger_kwargs = {"pre_trigger_segments": args.sound_pre_trigger} + elif args.trigger == "hardware": + trigger_kwargs = { + "trigger_threshold_mph": ( + args.trigger_threshold if args.trigger_threshold is not None else 25.0 + ), + "trigger_magnitude": args.trigger_magnitude, + "pre_trigger_segments": args.pre_trigger_segments, + } + elif args.trigger == "speed": + trigger_kwargs = {} + if args.trigger_threshold is not None: + trigger_kwargs["min_trigger_speed_mph"] = args.trigger_threshold + else: + trigger_kwargs = {} swing_speed_kwargs = { "trigger_threshold_mph": args.swing_speed_threshold, "max_speed_mph": None if args.swing_speed_max <= 0 else args.swing_speed_max, diff --git a/tests/test_ops243.py b/tests/test_ops243.py index 9fea1b9f0..431a58d31 100644 --- a/tests/test_ops243.py +++ b/tests/test_ops243.py @@ -5,6 +5,7 @@ import time import pytest +import serial from openflight.ops243 import Direction, OPS243Radar, SpeedReading @@ -541,6 +542,62 @@ def read(self, n): return chunk +class _StaggeredDrainSerial: + """Serial stand-in whose active dump pauses longer than the old quiet gap.""" + + is_open = True + + def __init__(self, chunks): + self.timeout = 1.0 + self._chunks = iter(sorted(chunks, key=lambda item: item[0])) + self._next_time, self._next_chunk = next(self._chunks) + self._started = time.monotonic() + self.read_bytes = bytearray() + + def read(self, _size): + remaining = self._next_time - (time.monotonic() - self._started) + if remaining > 0: + time.sleep(min(remaining, self.timeout)) + if time.monotonic() - self._started < self._next_time: + return b"" + chunk = self._next_chunk + self.read_bytes.extend(chunk) + try: + self._next_time, self._next_chunk = next(self._chunks) + except StopIteration: + self._next_time, self._next_chunk = float("inf"), b"" + return chunk + + def reset_input_buffer(self): + pass + + +class TestSerialDrain: + """Startup must wait through a short USB pause inside an active dump.""" + + def test_drain_waits_for_late_dump_tail(self): + radar = OPS243Radar.__new__(OPS243Radar) + radar.port = "/dev/ttyACM0" + radar.baud = OPS243Radar.DEFAULT_BAUD + radar.serial = _StaggeredDrainSerial([(0.0, b"head"), (0.6, b"tail")]) + + radar._drain_serial(max_wait=2.0) + + assert bytes(radar.serial.read_bytes) == b"headtail" + + def test_drain_rejects_a_stream_that_misses_the_deadline(self): + radar = OPS243Radar.__new__(OPS243Radar) + radar.port = "/dev/ttyACM0" + radar.baud = OPS243Radar.DEFAULT_BAUD + radar.serial = _StaggeredDrainSerial([(0.0, b"head"), (1.2, b"tail")]) + original_timeout = radar.serial.timeout + + with pytest.raises(ConnectionError, match=r"did not quiesce.*4 bytes drained"): + radar._drain_serial(max_wait=1.0) + + assert radar.serial.timeout == original_timeout + + class TestWaitForHardwareTrigger: """Tests for the hardware-trigger read loop (sound trigger path).""" @@ -671,3 +728,234 @@ def test_first_byte_callback_fires_when_capture_starts(self): assert response == b"".join(self._DUMP).decode("ascii") assert events == ["first-byte"] + + +class _InternalTriggerSerial: + """Minimal serial stand-in for internal-trigger command tests.""" + + is_open = True + + def __init__(self, fail_write=False): + self.writes = [] + self.fail_write = fail_write + + @property + def in_waiting(self): + return 0 + + def reset_input_buffer(self): + pass + + def write(self, data): + if self.fail_write: + raise serial.SerialTimeoutException("radar busy") + self.writes.append(data) + return len(data) + + def flush(self): + pass + + +class _RestoreRaceSerial(_InternalTriggerSerial): + """Model a rolling-buffer dump starting while settings are restored.""" + + def __init__(self, trigger_threshold=25.0): + super().__init__() + self.trigger_threshold = trigger_threshold + self.active_threshold = None + self.gc_started = False + self.dump_started = False + self.thresholds = [] + + def write(self, data): + text = data.decode("ascii") + if text.startswith("ST"): + self.active_threshold = abs(float(text[2:].rstrip("\r"))) + self.thresholds.append(self.active_threshold) + if ( + self.gc_started + and not text.startswith("ST") + and self.active_threshold <= self.trigger_threshold + ): + self.dump_started = True + raise serial.SerialTimeoutException("radar entered rolling-buffer dump") + return super().write(data) + + +class TestInternalSpeedTrigger: + """Focused tests for the OPS243 board-managed speed trigger.""" + + @staticmethod + def _radar(serial_obj): + radar = OPS243Radar.__new__(OPS243Radar) + radar.serial = serial_obj + return radar + + def test_configuration_uses_gc_trigger_order_and_six_pre_segments(self, monkeypatch): + """Internal trigger setup must restore GC-reset settings in order.""" + radar = self._radar(_InternalTriggerSerial()) + commands = [] + monkeypatch.setattr(time, "sleep", lambda _seconds: None) + + def send_command(command): + commands.append(command) + return '{"Version":"1.3.2"}' if command == "?V" else "" + + monkeypatch.setattr( + radar, + "_send_command", + send_command, + ) + + radar.configure_for_internal_speed_trigger( + trigger_threshold_mph=25, + pre_trigger_segments=6, + trigger_magnitude=40, + sample_rate_ksps=30, + ) + + assert commands == [ + "?V", + "PI", + "GC", + "S=30", + "US", + "P0", + "S(", + "X=2", + "R-", + "R>25", + "OJ", + "OM", + "W0", + "S#6", + ] + assert radar.serial.writes == [ + b"ST-90\r", + b"ST-90\r", + b"ST-200\r", + b"SM40\r", + b"ST-25\r", + ] + assert not {"GS", "PA", "S#0"} & set(commands) + + def test_configuration_blocks_trigger_while_restoring_settings(self, monkeypatch): + """A stale armed board must not dump while GC settings are restored.""" + radar = self._radar(_RestoreRaceSerial()) + commands = [] + monkeypatch.setattr(time, "sleep", lambda _seconds: None) + + def send_command(command): + if command == "GC": + radar.serial.gc_started = True + commands.append(command) + return '{"Version":"1.3.2"}' if command == "?V" else "" + + monkeypatch.setattr(radar, "_send_command", send_command) + + radar.configure_for_internal_speed_trigger(trigger_threshold_mph=25) + + assert radar.serial.dump_started is False + assert radar.serial.thresholds[-1] == 25 + assert all(value > 25 for value in radar.serial.thresholds[:-1]) + + @pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"trigger_threshold_mph": -1}, "non-negative"), + ({"trigger_magnitude": 0}, "between 1 and 2000"), + ({"trigger_magnitude": 2001}, "between 1 and 2000"), + ({"sample_rate_ksps": 25}, "30 ksps"), + ], + ) + def test_configuration_validates_hardware_requirements(self, kwargs, message): + """Unsafe threshold, magnitude, and sample-rate values fail early.""" + radar = self._radar(_InternalTriggerSerial()) + + with pytest.raises(ValueError, match=message): + radar.configure_for_internal_speed_trigger(**kwargs) + + @pytest.mark.parametrize( + "version", + ["1.3.1", "1.3.0", "1.2.9", "1.4.0", "unknown", "1.3", "v1.3.2", "1.3.2-beta", None], + ) + def test_configuration_rejects_unsupported_ops243_firmware(self, monkeypatch, version): + """Internal triggering must reject old, unknown, and other firmware trains.""" + radar = self._radar(_InternalTriggerSerial()) + monkeypatch.setattr(radar, "_probe_firmware_version", lambda: version) + + with pytest.raises(RuntimeError, match="requires OPS243-A firmware v1.3.2"): + radar.configure_for_internal_speed_trigger() + + @pytest.mark.parametrize("version", ["1.3.2", "1.3.3", "1.3.99"]) + def test_configuration_accepts_compatible_ops243_firmware(self, monkeypatch, version): + """The current and newer patch releases in the 1.3 train are accepted.""" + radar = self._radar(_InternalTriggerSerial()) + monkeypatch.setattr(radar, "_probe_firmware_version", lambda: version) + + assert radar.validate_internal_trigger_firmware() == version + + def test_rearm_uses_gc_and_restores_cached_settings(self, monkeypatch): + """A completed dump is re-armed with GC without PA or S#0.""" + radar = self._radar(_InternalTriggerSerial()) + radar._internal_speed_trigger_config = (25.0, 6, 40, 30) + commands = [] + monkeypatch.setattr(time, "sleep", lambda _seconds: None) + monkeypatch.setattr(radar, "_drain_rearm_serial", lambda: None) + monkeypatch.setattr( + radar, + "_send_command", + lambda command: commands.append(command) or "", + ) + + assert radar.rearm_internal_speed_trigger() is True + assert radar.serial.writes == [ + b"ST-90\r", + b"GC", + b"ST-90\r", + b"ST-200\r", + b"SM40\r", + b"ST-25\r", + ] + assert commands == [ + "S=30", + "US", + "P0", + "S(", + "X=2", + "R-", + "R>25", + "OJ", + "OM", + "W0", + "S#6", + ] + assert not {"PI", "GS", "PA", "S#0"} & set(commands) + + def test_rearm_recovers_from_serial_timeout_without_discarding_capture(self, monkeypatch): + """A busy radar reports a retryable re-arm failure instead of raising.""" + radar = self._radar(_InternalTriggerSerial(fail_write=True)) + radar._internal_speed_trigger_config = (25.0, 6, 40, 30) + monkeypatch.setattr(time, "sleep", lambda _seconds: None) + monkeypatch.setattr(radar, "_drain_rearm_serial", lambda: None) + + assert radar.rearm_internal_speed_trigger() is False + assert radar._hardware_trigger_recovery_required is True + + def test_failed_rearm_discards_stale_output_before_next_dump(self): + """Recovery must ignore trailing UART records before the next I/Q dump.""" + stale = b'{"speed":-12.0}\r\n' + radar = self._radar( + _ScheduledSerial( + [ + (0.0, stale), + (0.05, b"".join(TestWaitForHardwareTrigger._DUMP)), + ] + ) + ) + radar._hardware_trigger_recovery_required = True + + response = radar.wait_for_hardware_trigger(timeout=1.0) + + assert response == b"".join(TestWaitForHardwareTrigger._DUMP).decode("ascii") + assert radar._hardware_trigger_recovery_required is False diff --git a/tests/test_rolling_buffer.py b/tests/test_rolling_buffer.py index b4b49725c..108ecc17c 100644 --- a/tests/test_rolling_buffer.py +++ b/tests/test_rolling_buffer.py @@ -13,6 +13,7 @@ from openflight.clubs import ClubType from openflight.launch_monitor import Shot from openflight.rolling_buffer import ( + HardwareTriggeredCapture, ImpactEstimate, IQCapture, ProcessedCapture, @@ -536,6 +537,121 @@ def test_invalid_trigger_type(self): with pytest.raises(ValueError): create_trigger("invalid_type") + def test_create_hardware_trigger_with_opt_in_defaults(self): + """The new hardware strategy is registered without changing the factory default.""" + trigger = create_trigger("hardware") + + assert isinstance(trigger, HardwareTriggeredCapture) + assert trigger.trigger_threshold_mph == 25.0 + assert trigger.trigger_magnitude == 25 + assert trigger.pre_trigger_segments == 6 + assert trigger.sample_rate_ksps == 30 + assert isinstance(create_trigger(), SoundTrigger) + + def test_monitor_configures_hardware_trigger_on_connect(self): + """Hardware mode delegates its board setup to the OPS243 driver.""" + from openflight.rolling_buffer.monitor import RollingBufferMonitor + + monitor = RollingBufferMonitor( + trigger_type="hardware", + trigger_threshold_mph=31, + trigger_magnitude=55, + pre_trigger_segments=20, + ) + monitor.radar = MagicMock() + + assert monitor.connect() is True + + monitor.radar.configure_for_internal_speed_trigger.assert_called_once_with( + trigger_threshold_mph=31, + pre_trigger_segments=20, + trigger_magnitude=55, + sample_rate_ksps=30, + ) + + def test_hardware_trigger_requires_30_ksps(self): + """The tested internal-trigger waveform is only valid at 30 ksps.""" + with pytest.raises(ValueError, match="30 ksps"): + create_trigger("hardware", sample_rate_ksps=25) + + +class TestHardwareTriggeredCapture: + """Acceptance and false-trigger behavior for board-triggered captures.""" + + @staticmethod + def _capture(): + return IQCapture( + sample_time=0.0, + trigger_time=0.1, + i_samples=[2048], + q_samples=[2048], + ) + + def test_accepts_ball_capture_and_rearms_after_parsing(self): + """A valid board dump is parsed before GC re-arm and returned.""" + radar = MagicMock() + radar.wait_for_hardware_trigger.return_value = '{"Q": [1]}' + radar.last_hardware_trigger_first_byte_timestamp = 12345.678 + processor = MagicMock() + capture = self._capture() + processor.parse_capture.return_value = capture + processor.process_standard.return_value = SpeedTimeline( + readings=[SpeedReading(100.0, 900.0, 68.0, "outbound")], + sample_rate_hz=937.5, + ) + + trigger = HardwareTriggeredCapture() + result = trigger.wait_for_trigger(radar, processor, timeout=1.0) + + assert result is capture + processor.parse_capture.assert_called_once_with( + '{"Q": [1]}', + first_byte_timestamp=12345.678, + ) + radar.rearm_internal_speed_trigger.assert_called_once_with(30) + + def test_rejects_false_trigger_but_still_rearms(self): + """A board trigger with no qualifying outbound ball speed is discarded.""" + radar = MagicMock() + radar.wait_for_hardware_trigger.return_value = '{"Q": [1]}' + processor = MagicMock() + processor.parse_capture.return_value = self._capture() + processor.process_standard.return_value = SpeedTimeline([], 937.5) + + trigger = HardwareTriggeredCapture() + + assert trigger.wait_for_trigger(radar, processor, timeout=1.0) is None + radar.rearm_internal_speed_trigger.assert_called_once_with(30) + assert trigger.drain_diagnostics()[0]["reason"] == "no_ball_speed" + + def test_rearms_after_malformed_dump(self): + """Malformed board output cannot leave the internal trigger idle.""" + radar = MagicMock() + radar.wait_for_hardware_trigger.return_value = "not-json" + processor = MagicMock() + processor.parse_capture.return_value = None + + trigger = HardwareTriggeredCapture() + + assert trigger.wait_for_trigger(radar, processor, timeout=1.0) is None + radar.rearm_internal_speed_trigger.assert_called_once_with(30) + assert trigger.drain_diagnostics()[0]["reason"] == "parse_failed" + + def test_retains_valid_capture_when_rearm_reports_busy_radar(self): + """A re-arm failure is recoverable and does not discard the received shot.""" + radar = MagicMock() + radar.wait_for_hardware_trigger.return_value = '{"Q": [1]}' + radar.rearm_internal_speed_trigger.return_value = False + processor = MagicMock() + capture = self._capture() + processor.parse_capture.return_value = capture + processor.process_standard.return_value = SpeedTimeline( + readings=[SpeedReading(100.0, 900.0, 68.0, "outbound")], + sample_rate_hz=937.5, + ) + + assert HardwareTriggeredCapture().wait_for_trigger(radar, processor) is capture + class TestSoundTriggerTimestampPropagation: """Tests for hardware trigger timestamp propagation.""" diff --git a/tests/test_server.py b/tests/test_server.py index 47412e059..4434f4eb7 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -4482,3 +4482,189 @@ def test_every_api_supported_baud_is_accepted(self, good): a stricter check would reject a legitimate fallback to 115200, which the flag's own help text tells operators to use.""" assert good in UART_BAUD_COMMANDS + + +class TestHardwareTriggerPlumbing: + """Server-side forwarding and validation for the opt-in trigger mode.""" + + def test_server_cli_accepts_trigger_speed_alias(self, monkeypatch): + """The Pi's --trigger-speed spelling reaches hardware trigger setup.""" + captured = {} + monkeypatch.setattr( + sys, + "argv", + [ + "openflight-server", + "--mock", + "--no-logging", + "--trigger", + "hardware", + "--trigger-speed", + "10", + ], + ) + monkeypatch.setattr( + server_module, + "start_monitor", + lambda **kwargs: captured.update(kwargs), + ) + monkeypatch.setattr(server_module, "load_sim_config", lambda: []) + monkeypatch.setattr(server_module, "build_connectors", lambda *args, **kwargs: []) + monkeypatch.setattr(server_module, "init_session_logger", lambda **kwargs: None) + monkeypatch.setattr(server_module, "_cleanup_hardware_for_shutdown", lambda: None) + monkeypatch.setattr(server_module.socketio, "run", lambda *args, **kwargs: None) + + server_module.main() + + assert captured["trigger_type"] == "hardware" + assert captured["trigger_kwargs"]["trigger_threshold_mph"] == 10.0 + + def test_server_cli_forwards_hardware_flags_and_preserves_sound_alias(self, monkeypatch): + """Argparse selects the new kwargs only for hardware mode.""" + captured = {} + monkeypatch.setattr( + sys, + "argv", + [ + "openflight-server", + "--mock", + "--no-logging", + "--trigger", + "hardware", + "--trigger-threshold", + "31", + "--trigger-magnitude", + "52", + "--pre-trigger-segments", + "20", + ], + ) + monkeypatch.setattr( + server_module, + "start_monitor", + lambda **kwargs: captured.update(kwargs), + ) + monkeypatch.setattr(server_module, "load_sim_config", lambda: []) + monkeypatch.setattr(server_module, "build_connectors", lambda *args, **kwargs: []) + monkeypatch.setattr(server_module, "init_session_logger", lambda **kwargs: None) + monkeypatch.setattr(server_module, "_cleanup_hardware_for_shutdown", lambda: None) + monkeypatch.setattr(server_module.socketio, "run", lambda *args, **kwargs: None) + + server_module.main() + + assert captured["trigger_type"] == "hardware" + assert captured["sample_rate_ksps"] == 30 + assert captured["trigger_kwargs"] == { + "trigger_threshold_mph": 31.0, + "trigger_magnitude": 52, + "pre_trigger_segments": 20, + } + + captured.clear() + monkeypatch.setattr( + sys, + "argv", + [ + "openflight-server", + "--mock", + "--no-logging", + "--trigger", + "sound", + "--sound-pre-trigger", + "18", + ], + ) + server_module.main() + + assert captured["trigger_type"] == "sound" + assert captured["trigger_kwargs"] == {"pre_trigger_segments": 18} + + @pytest.mark.parametrize("trigger", ["speed"]) + def test_server_cli_does_not_reuse_sound_pre_trigger_for_other_triggers( + self, monkeypatch, trigger + ): + """Non-sound strategies keep their constructor pre-trigger defaults.""" + captured = {} + monkeypatch.setattr( + sys, + "argv", + [ + "openflight-server", + "--mock", + "--no-logging", + "--trigger", + trigger, + "--sound-pre-trigger", + "16", + ], + ) + monkeypatch.setattr( + server_module, + "start_monitor", + lambda **kwargs: captured.update(kwargs), + ) + monkeypatch.setattr(server_module, "load_sim_config", lambda: []) + monkeypatch.setattr(server_module, "build_connectors", lambda *args, **kwargs: []) + monkeypatch.setattr(server_module, "init_session_logger", lambda **kwargs: None) + monkeypatch.setattr(server_module, "_cleanup_hardware_for_shutdown", lambda: None) + monkeypatch.setattr(server_module.socketio, "run", lambda *args, **kwargs: None) + + server_module.main() + + assert captured["trigger_type"] == trigger + assert captured["trigger_kwargs"] == {} + + def test_start_monitor_forwards_hardware_trigger_kwargs(self, monkeypatch): + """The server passes threshold, magnitude, split, and sample rate through.""" + captured = {} + + class FakeMonitor: + def __init__(self, **kwargs): + captured.update(kwargs) + + def connect(self): + captured["connected"] = True + + def start(self, **kwargs): + captured["started"] = kwargs + + def stop(self): + captured["stopped"] = True + + def disconnect(self): + captured["disconnected"] = True + + monkeypatch.setattr("openflight.rolling_buffer.RollingBufferMonitor", FakeMonitor) + monkeypatch.setattr(server_module, "monitor", None) + monkeypatch.setattr(server_module, "get_session_logger", lambda: None) + + server_module.start_monitor( + port="/dev/ops", + trigger_type="hardware", + sample_rate_ksps=30, + trigger_kwargs={ + "trigger_threshold_mph": 25.0, + "trigger_magnitude": 40, + "pre_trigger_segments": 6, + }, + ) + + assert captured["port"] == "/dev/ops" + assert captured["trigger_type"] == "hardware" + assert captured["sample_rate_ksps"] == 30 + assert captured["trigger_threshold_mph"] == 25.0 + assert captured["trigger_magnitude"] == 40 + assert captured["pre_trigger_segments"] == 6 + assert captured["connected"] is True + + server_module.stop_monitor() + + def test_start_monitor_rejects_non_30_ksps_hardware_mode(self, monkeypatch): + """Hardware mode must not start with an untested sample rate.""" + monkeypatch.setattr(server_module, "monitor", None) + + with pytest.raises(ValueError, match="30 ksps"): + server_module.start_monitor( + trigger_type="hardware", + sample_rate_ksps=25, + ) diff --git a/tests/test_start_kiosk.py b/tests/test_start_kiosk.py index 73f8266e7..a1267a9ef 100644 --- a/tests/test_start_kiosk.py +++ b/tests/test_start_kiosk.py @@ -101,6 +101,13 @@ def test_server_arguments_pass_through_unchanged(): assert _dry_run(*arguments) == ["openflight-server", "--web-port", "8080", *arguments] +def test_hardware_trigger_speed_alias_passes_through_to_server(): + """The thin wrapper forwards the documented threshold alias unchanged.""" + arguments = ["--trigger", "hardware", "--trigger-speed", "10"] + + assert _dry_run(*arguments) == ["openflight-server", "--web-port", "8080", *arguments] + + @pytest.mark.parametrize("alias", ["--radar-port", "--ops-port"]) def test_radar_alias_is_distinct_from_web_port(alias): assert _dry_run(alias, "/dev/serial0", "--port", "9090") == [