Skip to content

Commit 0fd7730

Browse files
committed
[cr] add support for sound trigger in rolling buffer mode
1 parent 3d99d78 commit 0fd7730

File tree

5 files changed

+182
-4
lines changed

5 files changed

+182
-4
lines changed

scripts/OpenFlightRollingBuffer.desktop

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[Desktop Entry]
22
Name=OpenFlight Rolling Mode
33
Comment=Golf Launch Monitor Kiosk
4-
Exec=lxterminal -e /home/coleman/openflight/scripts/start-kiosk.sh --mode rolling-buffer
4+
Exec=lxterminal -e /home/coleman/openflight/scripts/start-kiosk.sh --mode rolling-buffer --trigger sound --sound-pre-trigger 12
55
Icon=applications-games
66
Terminal=false
77
Type=Application

scripts/start-kiosk.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ CAMERA_IMGSZ=256
1919
ROBOFLOW_MODEL=""
2020
ROBOFLOW_API_KEY=""
2121
MODE=""
22+
TRIGGER=""
23+
SOUND_PRE_TRIGGER=""
2224

2325
# Parse arguments
2426
while [[ $# -gt 0 ]]; do
@@ -63,6 +65,14 @@ while [[ $# -gt 0 ]]; do
6365
MODE="$2"
6466
shift 2
6567
;;
68+
--trigger)
69+
TRIGGER="$2"
70+
shift 2
71+
;;
72+
--sound-pre-trigger)
73+
SOUND_PRE_TRIGGER="$2"
74+
shift 2
75+
;;
6676
--port|-p)
6777
PORT="$2"
6878
shift 2
@@ -155,6 +165,14 @@ if [ -n "$MODE" ]; then
155165
SERVER_CMD="$SERVER_CMD --mode $MODE"
156166
fi
157167

168+
if [ -n "$TRIGGER" ]; then
169+
SERVER_CMD="$SERVER_CMD --trigger $TRIGGER"
170+
fi
171+
172+
if [ -n "$SOUND_PRE_TRIGGER" ]; then
173+
SERVER_CMD="$SERVER_CMD --sound-pre-trigger $SOUND_PRE_TRIGGER"
174+
fi
175+
158176
# Start the server
159177
if [ "$MOCK_MODE" = true ]; then
160178
log "Starting OpenFlight server on port $PORT (MOCK MODE)..."

src/openflight/ops243.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,67 @@ def trigger_capture(self, timeout: float = 10.0) -> str:
942942

943943
return full_response
944944

945+
def wait_for_hardware_trigger(self, timeout: float = 30.0) -> str:
946+
"""
947+
Wait for hardware trigger to fire and read the buffer dump.
948+
949+
Unlike trigger_capture() which sends S!, this method just waits
950+
for data to appear on serial — triggered externally via J3 pin 3
951+
(HOST_INT). Used with SoundTrigger (SparkFun SEN-14262).
952+
953+
Args:
954+
timeout: Maximum time to wait for trigger data
955+
956+
Returns:
957+
Raw response string containing JSON lines, or empty string on timeout
958+
"""
959+
if not self.serial or not self.serial.is_open:
960+
raise ConnectionError("Not connected to radar")
961+
962+
# Clear any stale data
963+
self.serial.reset_input_buffer()
964+
965+
response_lines = []
966+
start_time = time.time()
967+
last_data_time = None
968+
bytes_received = 0
969+
970+
while (time.time() - start_time) < timeout:
971+
if self.serial.in_waiting:
972+
chunk = self.serial.read(self.serial.in_waiting)
973+
response_lines.append(chunk.decode('ascii', errors='ignore'))
974+
bytes_received += len(chunk)
975+
last_data_time = time.time()
976+
977+
# Check if we have complete I/Q data
978+
full_response = ''.join(response_lines)
979+
if '"Q"' in full_response:
980+
q_idx = full_response.rfind('"Q"')
981+
remaining = full_response[q_idx:]
982+
if ']}' in remaining or (
983+
remaining.rstrip().endswith(']')
984+
and remaining.count('[') == remaining.count(']')
985+
):
986+
break
987+
988+
time.sleep(0.01)
989+
else:
990+
# If we've started receiving data, use shorter timeout
991+
if last_data_time and (time.time() - last_data_time) > 0.5:
992+
full_response = ''.join(response_lines)
993+
if '"Q"' in full_response:
994+
break
995+
time.sleep(0.02)
996+
997+
full_response = ''.join(response_lines) if response_lines else ""
998+
999+
if not full_response:
1000+
logger.info("Hardware trigger: no data received within %ss", timeout)
1001+
else:
1002+
logger.info("Hardware trigger: received %d bytes", len(full_response))
1003+
1004+
return full_response
1005+
9451006
def rearm_rolling_buffer(self):
9461007
"""
9471008
Re-arm rolling buffer for next capture.

src/openflight/rolling_buffer/trigger.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,12 +409,97 @@ def last_trigger_speed(self) -> float:
409409
return self._last_trigger_speed
410410

411411

412+
class SoundTrigger(TriggerStrategy):
413+
"""
414+
Hardware sound trigger using SparkFun SEN-14262.
415+
416+
Wiring: SEN-14262 GATE → OPS243-A J3 Pin 3 (HOST_INT)
417+
The GATE output goes HIGH on loud sound (club impact).
418+
OPS243-A uses rising edge detection on HOST_INT as trigger.
419+
420+
No software trigger (S!) needed — the radar triggers itself
421+
via hardware. We just need to wait for data to appear on serial.
422+
"""
423+
424+
def __init__(
425+
self,
426+
pre_trigger_segments: int = 12,
427+
):
428+
"""
429+
Initialize sound trigger.
430+
431+
Args:
432+
pre_trigger_segments: Number of pre-trigger segments for S# command.
433+
Each segment = 128 samples = ~4.27ms at 30ksps.
434+
Default 12 gives ~51ms pre-trigger, ~85ms post-trigger.
435+
Tune based on mic-to-impact distance.
436+
"""
437+
self.pre_trigger_segments = pre_trigger_segments
438+
self._split_configured = False
439+
440+
def wait_for_trigger(
441+
self,
442+
radar: "OPS243Radar",
443+
processor: RollingBufferProcessor,
444+
timeout: float = 30.0,
445+
) -> Optional[IQCapture]:
446+
"""
447+
Wait for hardware sound trigger and capture buffer.
448+
449+
Unlike other triggers, no S! command is sent. The radar's
450+
HOST_INT pin receives the trigger from the SEN-14262 GATE
451+
output, causing the radar to dump its rolling buffer automatically.
452+
We just block on serial read waiting for the I/Q data to arrive.
453+
"""
454+
# Set pre-trigger split once (persists across captures)
455+
if not self._split_configured:
456+
radar.set_trigger_split(self.pre_trigger_segments)
457+
self._split_configured = True
458+
459+
logger.info(
460+
"Waiting for hardware sound trigger (timeout=%ss, S#%s)...",
461+
timeout, self.pre_trigger_segments
462+
)
463+
464+
response = radar.wait_for_hardware_trigger(timeout=timeout)
465+
466+
if not response:
467+
logger.info("Sound trigger timeout - no hardware trigger received")
468+
return None
469+
470+
logger.info("Hardware trigger fired, received %d bytes", len(response))
471+
472+
# Re-arm for next capture
473+
radar.rearm_rolling_buffer()
474+
475+
capture = processor.parse_capture(response)
476+
477+
if capture:
478+
timeline = processor.process_standard(capture)
479+
outbound = [
480+
r for r in timeline.readings
481+
if r.is_outbound and r.speed_mph >= 15.0
482+
]
483+
if outbound:
484+
peak = max(r.speed_mph for r in outbound)
485+
logger.info("Sound trigger capture: %d readings, peak %.1f mph",
486+
len(outbound), peak)
487+
else:
488+
logger.info("Sound trigger capture: no significant outbound speed")
489+
490+
return capture
491+
492+
def reset(self):
493+
"""Reset trigger state."""
494+
self._split_configured = False
495+
496+
412497
def create_trigger(trigger_type: str = "speed", **kwargs) -> TriggerStrategy:
413498
"""
414499
Factory function to create trigger strategy.
415500
416501
Args:
417-
trigger_type: "speed" (recommended), "polling", "threshold", or "manual"
502+
trigger_type: "speed" (recommended), "polling", "threshold", "manual", or "sound"
418503
**kwargs: Arguments passed to trigger constructor
419504
420505
Returns:
@@ -426,12 +511,14 @@ def create_trigger(trigger_type: str = "speed", **kwargs) -> TriggerStrategy:
426511
- "polling": Continuously capture and check for activity. Simple but slow.
427512
- "threshold": Speed threshold triggers capture. Less efficient than "speed".
428513
- "manual": External trigger for testing.
514+
- "sound": Hardware sound trigger via SparkFun SEN-14262. <1ms response.
429515
"""
430516
triggers = {
431517
"speed": SpeedTriggeredCapture,
432518
"polling": PollingTrigger,
433519
"threshold": ThresholdTrigger,
434520
"manual": ManualTrigger,
521+
"sound": SoundTrigger,
435522
}
436523

437524
if trigger_type not in triggers:

src/openflight/server.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,7 @@ def start_monitor(
659659
mode: str = "streaming",
660660
trigger_type: str = "polling",
661661
debug: bool = False,
662+
trigger_kwargs: Optional[dict] = None,
662663
):
663664
"""
664665
Start the launch monitor.
@@ -684,7 +685,7 @@ def start_monitor(
684685
elif mode == "rolling-buffer":
685686
# Rolling buffer mode for spin detection
686687
from .rolling_buffer import RollingBufferMonitor
687-
monitor = RollingBufferMonitor(port=port, trigger_type=trigger_type)
688+
monitor = RollingBufferMonitor(port=port, trigger_type=trigger_type, **(trigger_kwargs or {}))
688689
print(f"[MODE] Rolling buffer mode enabled (trigger: {trigger_type})")
689690
else:
690691
# Default streaming mode
@@ -903,10 +904,15 @@ def main():
903904
)
904905
parser.add_argument(
905906
"--trigger",
906-
choices=["polling", "threshold"],
907+
choices=["polling", "threshold", "speed", "sound"],
907908
default="polling",
908909
help="Trigger strategy for rolling-buffer mode (default: polling)"
909910
)
911+
parser.add_argument(
912+
"--sound-pre-trigger",
913+
type=int, default=12,
914+
help="Pre-trigger segments for sound trigger (default: 12, each ~4.27ms at 30ksps)"
915+
)
910916
args = parser.parse_args()
911917

912918
print("=" * 50)
@@ -947,12 +953,18 @@ def main():
947953
print("Raw radar readings display ENABLED - signed speed values will be shown")
948954

949955
# Start the monitor
956+
# Build trigger-specific kwargs
957+
trigger_kwargs = {}
958+
if args.trigger == "sound":
959+
trigger_kwargs["pre_trigger_segments"] = args.sound_pre_trigger
960+
950961
start_monitor(
951962
port=args.port,
952963
mock=args.mock,
953964
mode=args.mode,
954965
trigger_type=args.trigger,
955966
debug=args.debug,
967+
trigger_kwargs=trigger_kwargs,
956968
)
957969

958970
if args.mock:

0 commit comments

Comments
 (0)