Initial: ContinuousTransponder wrapper for Kogger USBL

High-level Python wrapper around the upstream cosma-tech/kogger_acousticAntenna
driver. Configures a Kogger acoustic antenna as a permanent slave transponder
in a single start() call: address filter, echo filter, optional TDMA sync slot,
permanent response window, and Python callbacks for each ping received.

No modification to the upstream driver — only composes existing public methods
in the right order. Snapshot of upstream driver included read-only under driver/
for reference.

Includes:
- transponder_continu.py (302 lines): the wrapper class + CLI
- examples/auv_slave.py (79 lines): usage example with logging
- README.md: design rationale, usage, multi-AUV TDMA, watchdog, hardware wiring
- driver/: snapshot of cosma-tech/kogger_acousticAntenna at commit 1b539f9
  ('Add index slot for multi pinger', 2025-03-11)

Built for Cosma context (USV master + N AUVs slaves) following the design
conversation in Discord #ping-pong-ping (2026-04-27). See poulpe/ping-pong-ping
on Gitea for the interactive demo of the protocol.
This commit is contained in:
2026-04-27 22:08:44 +00:00
commit 9a158f5c5f
53 changed files with 7894 additions and 0 deletions

252
driver/test/test_kogger_driver.py Executable file
View File

@@ -0,0 +1,252 @@
#! /usr/bin/env python
import os
import sys
import time
import io # For BytesIO to simulate file reading for the mock
from loguru import logger
import threading
# Ensure the kogger_protocol_driver.py is in the Python path
# If it's in the same directory, this is usually fine.
# Otherwise, you might need to adjust sys.path:
# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) # If driver is one dir up
try:
from kogger_protocol_driver import KoggerSBPDevice, ID_TIMESTAMP, ID_ATTITUDE, ID_UART, ID_FLASH, RESP_OK, RESP_ERR_KEY
except ImportError:
logger.critical("Failed to import KoggerSBPDevice. Make sure kogger_protocol_driver.py is in the same directory or Python path.")
sys.exit(1)
# --- Loguru Setup for the test script ---
logger.remove()
logger.add(sys.stderr, level="DEBUG", format="<white>{time:YYYY-MM-DD HH:mm:ss.SSS}</white> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>")
# --- Content for test_messages.bin ---
# Frame 1: Timestamp response (12345 ms)
frame1_ts_resp = b'\xBB\x55\x00\x01\x01\x04\x39\x30\x00\x00\x49\x95'
# Frame 2: Unsolicited Attitude data
frame2_att_unsol = b'\xBB\x55\x00\x01\x04\x06\xE8\x03\xF4\x01\x38\xFF\xC8\x28'
# Frame 3: RESP_OK to a hypothetical SET_UART_CONFIG command (ID 0x18), orig_chk A0B0
frame3_uart_resp_ok = b'\xBB\x55\x00\x81\x18\x03\x01\xA0\xB0\x2D\x24'
# Frame 4: RESP_ERR_KEY to a hypothetical SAVE_SETTINGS_TO_FLASH command (ID 0x23), orig_chk C1D1
frame4_flash_resp_err = b'\xBB\x55\x00\x81\x23\x03\x07\xC1\xD1\xB0\x0D'
# Frame 5: Another unsolicited Timestamp (67890 ms)
frame5_ts_unsol = b'\xBB\x55\x00\x01\x01\x04\x32\x09\x01\x00\x42\xCD'
# Concatenate frames to simulate the binary file content
TEST_BINARY_DATA = frame1_ts_resp + frame2_att_unsol + frame3_uart_resp_ok + frame4_flash_resp_err + frame5_ts_unsol
TEST_BINARY_FILE_PATH = "test_messages.bin"
class MockSerial:
"""
A mock serial port that reads from a predefined byte stream (simulating a file)
and logs data written to it.
"""
def __init__(self, port, baudrate, timeout=0.1, binary_data=b""):
self.port = port
self.baudrate = baudrate
self.timeout = timeout # Read timeout
self._is_open = False
self.binary_stream = io.BytesIO(binary_data) # Use BytesIO to simulate file reading
self.written_data = bytearray() # To capture data written by the driver
# For KoggerSBPDevice, serial_read_timeout is passed to its __init__
# and used for its internal serial.Serial object.
# Our mock uses its own 'timeout' for its 'read' method.
# The driver's serial_read_timeout on KoggerSBPDevice will configure the *real* serial port
# if it were used. Here, it's less critical for the mock itself, but good to have.
logger.info(f"MockSerial initialized for port '{port}' at {baudrate} baud with {len(binary_data)} bytes of test data.")
def open(self):
self._is_open = True
logger.info(f"MockSerial port {self.port} opened.")
def close(self):
self._is_open = False
logger.info(f"MockSerial port {self.port} closed.")
self.binary_stream.close()
@property
def is_open(self):
return self._is_open
@property
def in_waiting(self):
if not self._is_open:
return 0
current_pos = self.binary_stream.tell()
# Get the total number of bytes in the stream buffer
# For BytesIO, getbuffer().nbytes gives total size
total_size = self.binary_stream.getbuffer().nbytes
return total_size - current_pos
def read(self, size=1):
if not self._is_open:
raise serial.SerialException("Port not open")
# Simulate timeout behavior: if no data, return empty bytes after timeout
# For this mock, if we reach EOF, read() returns b''.
# The driver's reader thread has its own loop and small reads.
data = self.binary_stream.read(size)
if data:
logger.debug(f"MockSerial: read {len(data)} bytes: {data.hex().upper()}")
# else:
# logger.debug(f"MockSerial: read attempt, no data (EOF or simulated timeout)")
return data
def write(self, data):
if not self.is_open:
raise serial.SerialException("Port not open")
logger.info(f"MockSerial: driver wrote {len(data)} bytes: {data.hex().upper()}")
self.written_data.extend(data)
return len(data)
def reset_input_buffer(self):
logger.info("MockSerial: reset_input_buffer called (clearing mock written_data for this example).")
# In a real scenario, this clears hardware buffers. Here, it's less meaningful
# unless the driver specifically expects some state change.
# The driver's internal _receive_buffer will still exist.
def reset_output_buffer(self):
logger.info("MockSerial: reset_output_buffer called.")
# Clears OS transmit buffer. No direct equivalent for simple mock.
# --- Test Callbacks ---
received_unsolicited_attitude = None
received_unsolicited_timestamps = []
def attitude_callback(frame):
global received_unsolicited_attitude
logger.success(f"[ATTITUDE CALLBACK] Received frame ID: {frame['id']:#02x}")
if frame['id'] == ID_ATTITUDE and frame.get('checksum_ok'):
payload = frame['payload']
yaw, pitch, roll = struct.unpack('<hhh', payload)
received_unsolicited_attitude = {'yaw_deg': yaw / 100.0, 'pitch_deg': pitch / 100.0, 'roll_deg': roll / 100.0}
logger.info(f" Attitude data: {received_unsolicited_attitude}")
def timestamp_callback(frame):
global received_unsolicited_timestamps
logger.success(f"[TIMESTAMP CALLBACK] Received frame ID: {frame['id']:#02x}")
if frame['id'] == ID_TIMESTAMP and frame.get('checksum_ok'):
payload = frame['payload']
timestamp = struct.unpack('<I', payload)[0]
received_unsolicited_timestamps.append(timestamp)
logger.info(f" Timestamp data: {timestamp} ms")
def run_tests():
global received_unsolicited_attitude, received_unsolicited_timestamps
received_unsolicited_attitude = None # Reset for test
received_unsolicited_timestamps = [] # Reset for test
# Create the test binary file (optional, can use BytesIO directly)
# For this test, we'll pass the bytes directly to MockSerial
# with open(TEST_BINARY_FILE_PATH, "wb") as f:
# f.write(TEST_BINARY_DATA)
# logger.info(f"Created test data file: {TEST_BINARY_FILE_PATH}")
# Instantiate the mock serial port with our predefined binary data
mock_port = MockSerial(port='loop://test', baudrate=115200, binary_data=TEST_BINARY_DATA)
# Instantiate the driver with the mock port
# The 'serial_read_timeout' in KoggerSBPDevice is for its internal serial.Serial object.
# Our MockSerial uses its own timeout if we were to implement blocking reads with timeout.
# For this stream-based mock, the driver's reader thread will consume data as it becomes available.
driver = KoggerSBPDevice(port=None, baudrate=115200, default_timeout=1.0) # Pass None for port
driver.serial_conn = mock_port # Manually assign the mock serial connection
driver.serial_conn.open() # Open the mock port
# Start the driver's reader thread (normally connect() does this)
driver._stop_event.clear()
driver._reader_thread = threading.Thread(target=driver._reader_thread_loop, daemon=True)
driver._reader_thread.start()
logger.info("Manually started driver's reader thread with mock port.")
# Register callbacks
driver.register_callback(ID_ATTITUDE, attitude_callback)
driver.register_callback(ID_TIMESTAMP, timestamp_callback) # Will catch solicited and unsolicited if not handled by _execute_command first
# --- Test Case 1: Get Timestamp (expects frame1_ts_resp) ---
logger.info("\n--- Test 1: driver.get_timestamp() ---")
ts_data = driver.get_timestamp()
if ts_data and ts_data['timestamp_ms'] == 12345:
logger.success(f" PASS: get_timestamp() returned {ts_data}")
else:
logger.error(f" FAIL: get_timestamp() returned {ts_data}, expected 12345 ms.")
time.sleep(0.2) # Allow reader thread to process next items if any
# --- Test Case 2: Check for Unsolicited Attitude (expects frame2_att_unsol via callback) ---
logger.info("\n--- Test 2: Check for unsolicited Attitude message ---")
# The attitude message should have been processed by the reader thread and callback by now.
time.sleep(0.5) # Give callbacks time to fire from reader thread
if received_unsolicited_attitude:
logger.success(f" PASS: Unsolicited attitude received: {received_unsolicited_attitude}")
# Expected: {'yaw_deg': 10.0, 'pitch_deg': 5.0, 'roll_deg': -2.0}
if abs(received_unsolicited_attitude['yaw_deg'] - 10.0) < 0.01 and \
abs(received_unsolicited_attitude['pitch_deg'] - 5.0) < 0.01 and \
abs(received_unsolicited_attitude['roll_deg'] - (-2.0)) < 0.01:
logger.success(" Attitude data is correct.")
else:
logger.error(f" Attitude data INCORRECT: {received_unsolicited_attitude}")
else:
logger.error(" FAIL: Unsolicited attitude NOT received via callback.")
# --- Test Case 3: Set UART Config (expects frame3_uart_resp_ok) ---
# This is a SET command, it expects a RESP_OK.
# The mock doesn't check the "sent" command's checksum, but a real device would.
# Our frame3_uart_resp_ok assumes the original command had checksum A0B0.
logger.info("\n--- Test 3: driver.set_uart_config() ---")
# We are not actually changing baudrate here, just testing the command flow
# For the mock, uart_id, baudrate values don't affect the response source
success_uart = driver.set_uart_config(uart_id=1, baudrate=9600)
if success_uart:
logger.success(" PASS: set_uart_config reported success (RESP_OK received).")
else:
logger.error(" FAIL: set_uart_config reported failure.")
time.sleep(0.2)
# --- Test Case 4: Save Settings (expects frame4_flash_resp_err) ---
# This command will receive a RESP_ERR_KEY from our test data
logger.info("\n--- Test 4: driver.save_settings_to_flash() - expecting error ---")
success_flash = driver.save_settings_to_flash()
if not success_flash: # Expecting False due to RESP_ERR_KEY
# The driver's _execute_command should log the warning about the RESP_ERR_KEY.
# We can also check the specific response if _execute_command returned the error frame.
# The current save_settings_to_flash returns True only on RESP_OK.
logger.success(" PASS: save_settings_to_flash correctly reported failure (as expected due to RESP_ERR_KEY in test data).")
else:
logger.error(" FAIL: save_settings_to_flash reported success, but an error was expected.")
time.sleep(0.2)
# --- Test Case 5: Check for second Unsolicited Timestamp (expects frame5_ts_unsol via callback) ---
logger.info("\n--- Test 5: Check for second unsolicited Timestamp message ---")
time.sleep(0.5) # Give callbacks time to fire
if len(received_unsolicited_timestamps) > 0 and 67890 in received_unsolicited_timestamps:
logger.success(f" PASS: Second unsolicited timestamp (67890ms) received: {received_unsolicited_timestamps}")
elif len(received_unsolicited_timestamps) > 0 :
logger.warning(f" WARN: Unsolicited timestamp(s) received, but 67890ms not found. Got: {received_unsolicited_timestamps}")
else:
logger.error(" FAIL: Second unsolicited timestamp (67890ms) NOT received via callback.")
# --- Check remaining data in mock port ---
remaining_bytes = mock_port.in_waiting
if remaining_bytes == 0:
logger.success(f"\n--- All test data consumed from mock port. ---")
else:
logger.warning(f"\n--- {remaining_bytes} bytes remaining in mock port's data stream. ---")
logger.debug(f"Remaining data: {mock_port.read(remaining_bytes).hex().upper()}")
# Cleanup
logger.info("Stopping driver and closing mock port...")
driver.disconnect() # This will stop the thread and close the mock port
# if os.path.exists(TEST_BINARY_FILE_PATH):
# os.remove(TEST_BINARY_FILE_PATH)
# logger.info(f"Removed test data file: {TEST_BINARY_FILE_PATH}")
if __name__ == "__main__":
run_tests()
logger.info("Test script finished.")