Files
kogger-transpondeur-continu/driver/kogger_protocol_driver.py
Poulpe 9a158f5c5f 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.
2026-04-27 22:08:44 +00:00

1510 lines
76 KiB
Python

#! /usr/bin/env python
import serial
import struct
import time
import threading
import queue
from loguru import logger
import sys # For logger setup in main
import json # for easy print
import math # calculate distance with angles
import datetime # For timestamping CSV logs
import os
try:
from .simulation_kogger import simu_serial as simu
except Exception as e:
print("Can't import simu:"+str(e)+", try other way")
try:
from simulation_kogger import simu_serial as simu
except Exception as e:
print("Can't import simu:"+str(e))
SAVE_CSV = True
class CsvLogger:
def __init__(self, filename):
self.filename = filename
self.filepath = "log/" + filename
self.file = None
self.lock = threading.Lock()
self._open_file()
def _open_file(self):
try:
# Get the directory from the filepath
log_dir = os.path.dirname(self.filepath)
# Create the directory if it doesn't exist
if log_dir: # Ensure log_dir is not an empty string
os.makedirs(log_dir, exist_ok=True)
self.file = open(self.filepath, 'a', buffering=1) # Line buffered
logger.info(f"CSV logging enabled to {self.filepath}")
except Exception as e:
logger.error(f"Failed to open CSV log file {self.filepath}: {e}")
self.file = None
def log(self, direction, message_bytes):
if self.file:
with self.lock:
timestamp = datetime.datetime.now().isoformat(sep=' ', timespec='microseconds')
# Escape double quotes in the message bytes representation
escaped_message = str(message_bytes).replace('"', '""')
self.file.write(f'{timestamp},{direction},"' + escaped_message + '"\n')
def __del__(self):
if self.file and not self.file.closed:
self.file.close()
def setup_logging(level="INFO", file=""):
"""
Configures the module's logger.
This function can be called from an external script to set the desired log level.
:param level: The logging level to set (e.g.,"TRACE"<"DEBUG"<"INFO"<"SUCCESS"<"WARNING"<"ERROR"<"CRITICAL").
:param file: The file path to save logs. If empty, logs are not saved to a file.
If file is not defined, it doesn't create a new file, and use the old one
If the level is the same as the current one, it does nothing
"""
current_file=""
current_level=-1
for handler_id, handler in logger._core.handlers.items():
# The '_name' attribute usually holds the string path for file sinks
if isinstance(handler._name, str) and "/" in handler._name:
current_file = handler._name
current_level= handler._levelno
if file != current_file or logger.level(level).no!=current_level:
logger.remove()
logger.add(sys.stdout, level=level, format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>")
if file != "":
logger.add(file, level=level, format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}")
logger.info(f"Kogger Protocol Driver: Loguru logging configured to level {level} and file {file}.")
else:
logger.info(f"Kogger Protocol Driver: Loguru logging configured to level {level}.")
# Protocol Constants
SYNC1 = 0xBB
SYNC2 = 0x55
KEY_CONFIRM = 0xC96B5D4A
# Command IDs from PDF KS_SBP_100 Rev 3.0.7, Page 5
# Measurement data
ID_TIMESTAMP = 0x01 # Timestamp
ID_DIST = 0x02 # Distance data
ID_CHART = 0x03 # Chart data in reflection patterns
ID_ATTITUDE = 0x04 # Attitude
ID_TEMP = 0x05 # Temperature data
# Settings data
ID_DATASET = 0x10 # Dataset management for automatic output
ID_DIST_SETUP = 0x11 # Detection Settings to Get Distance
ID_CHART_SETUP = 0x12 # Chart Settings
ID_DSP = 0x13 # DSP settings (No detailed spec in PDF beyond listing)
ID_TRANSC = 0x14 # Transceiver settings
ID_SND_SPD = 0x15 # Sound speed settings
ID_PIN = 0x16 # Pin functions settings (No detailed spec in PDF beyond listing)
ID_BUS = 0x17 # Bus settings (No detailed spec in PDF beyond listing)
ID_UART = 0x18 # UART settings
ID_I2C = 0x19 # I2C settings (No detailed spec in PDF beyond listing)
ID_CAN = 0x1A # CAN settings (No detailed spec in PDF beyond listing)
ID_IMU_SETUP = 0x1B # IMU settings (In developing)
# System
ID_VERSION = 0x20 # Software and hardware version information (In developing)
ID_MARK = 0x21 # Setting the mark of continuous work (non-reboot) device
ID_DIAG = 0x22 # Diagnostic data (In developing)
ID_FLASH = 0x23 # Work with built-in non-volatile memory
ID_BOOT = 0x24 # Boot device
ID_UPDATE = 0x25 # Firmware update
# Navigation & Signal
ID_NAV = 0x64 # Navigation data (Latitude, Longitude, Accuracy)
ID_SIGNAL_ENCODER = 0xf6 #old 0x66 # Signal Encoder Data
ID_SIGNAL_DECODER = 0xf7 #old 0x67 # Signal Decoder Data
ID_USBL_SOLUTION = 0x65 # USBL Solution Data
ID_MODEM_SOLUTION = 0x66 # Modem Solution Data
ID_USBL_CONTROL = 0x68 # USBL Acoustic Control (Pinging and Auto-Response)
ID_MODEM_CONTROL = 0x69 # USBL modem control
ID_DVL_VEL = 0x79 # DVL Velocity data (0x79 decimal 121)
# MODE field - Type (Bits 0:1)
TYPE_RESERVED = 0
TYPE_CONTENT = 1
TYPE_SETTING = 2
TYPE_GETTING = 3
# MODE field - Response (Bit 7)
RESPONSE_REQUEST_FLAG = 1
# RESP Codes (Response codes from device)
RESP_NONE = 0
RESP_OK = 1
RESP_ERR_CHECKSUM = 2
RESP_ERR_PAYLOAD = 3
RESP_ERR_ID = 4
RESP_ERR_VERSION = 5
RESP_ERR_TYPE = 6
RESP_ERR_KEY = 7
RESP_ERR_RUNTIME = 8
class KoggerSBPDevice:
"""
Python driver for the Kogger Serial Binary Protocol (SBP).
This class implements methods to interact with a Kogger device
using the SBP, including sending commands, receiving responses,
and handling unsolicited messages via a separate reader thread.
Implemented based on "Serial Binary Protocol (SBP) specification",
Document Number: KS_SBP_100, Revision: 3.0.7.
Note on commands without detailed specs in PDF (ID_DSP, ID_PIN, ID_BUS, ID_I2C, ID_CAN):
Constants for these IDs are defined, but corresponding methods are not implemented
due to lack of payload structure information in the provided PDF.
"""
def __init__(self, port, baudrate=921600, device_address=0x0, default_timeout=1.0, serial_read_timeout=0.1, timestamp=None, vehicleName=None, save_csv=SAVE_CSV, log_level="NULL", log_file=""):
"""
Initializes the Kogger SBP device interface.
:param port: Serial port (e.g., 'COM3' on Windows, '/dev/ttyUSB0' on Linux), or None if using a mock.
:param baudrate: Baud rate for serial communication (default 921600).
:param device_address: Device address (0-15, default 0x0).
:param default_timeout: Default timeout in seconds for waiting for solicited command responses.
:param serial_read_timeout: Timeout in seconds for individual serial read operations in the reader thread.
:param timestamp: timestamp used for filename log
:param vehicleName: name put in filename log
:param save_csv: True:Save log file
:param log_level: The logging level to set (e.g.,"TRACE"<"DEBUG"<"INFO"<"SUCCESS"<"WARNING"<"ERROR"<"CRITICAL" or "NULL" to disable).
"""
self.port = port
self.baudrate = baudrate
self.device_address = device_address & 0x0F
self.serial_conn = None
self.default_timeout = default_timeout
self.serial_read_timeout = serial_read_timeout
self._reader_thread = None
self._stop_event = threading.Event()
self._response_handlers = {}
self._response_lock = threading.Lock()
self._callbacks = {}
self._precallbacks = {}
self._default_callback = None
self._nmea_callback = None
self._unsolicited_queue = queue.Queue()
if timestamp == None:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
if vehicleName == None:
vehicleName = "AUV"
self._vehicleType = vehicleName[:3]
self.usbl_data = dict()
self._receive_buffer = bytearray()
self._usbl_filter_echo = -1 # Set time.time() when ping sent
self._usbl_filter_echo_enable = True # Set True = enable echo filter, False = disabled
self._usbl_filter_echo_orig_received = False # Set False = first message not received, True=Message received
# Data for synchronization slot
self._sync_slot_total = 0
self._sync_slot_index = 0
self._sync_slot_duration = 0
self._sync_enable_delay = False
# Threading control variables
self._send_lock = threading.Lock()
self._is_sending = False
self._send_last_response = True # First set to True, need the second to get the real value
# Stores the latest data to be sent
self._latest_address = 255
self._latest_timeout = 0
if log_level != "NULL":
setup_logging(level=log_level, file=log_file)
logger.info(f"KoggerSBPDevice configured for port {self.port}, baudrate {self.baudrate}, address {self.device_address}")
self._csv_logger = None
if save_csv:
CSV_LOG_FILE = timestamp + "_" + vehicleName + "_usbl.csv"
self._csv_logger = CsvLogger(CSV_LOG_FILE)
def _calculate_fletcher16(self, data_bytes):
"""
Calculates Fletcher-16 checksum for a byte array.
:param data_bytes: Bytes over which checksum is calculated (ROUTE, MODE, ID, LENGTH, PAYLOAD).
:return: Tuple (CHECK1, CHECK2).
"""
check1 = 0
check2 = 0
for byte_val in data_bytes:
check1 = (check1 + byte_val) % 256
check2 = (check2 + check1) % 256
return check1, check2
def _wait_until_modulo_slot_precise(
self,
slot_total: int,
slot_index: int,
slot_duration: float,
enable_sleep: bool = True,
spin_buffer: float = 0.002
):
"""
Waits precisely until the specific slot index is reached.
Args:
slot_total (int): The total number of slots in one full cycle.
slot_index (int): The specific slot to wait for (0 to slot_total-1).
slot_duration (float): The max delay between 2 slots (duration of 1 slot).
enable_sleep (bool): If False, uses 100% CPU busy-wait (extreme precision).
spin_buffer (float): Time in seconds to switch from sleep to busy-wait.
"""
if slot_duration == 0:
return -1
# 1. Calculate the full cycle parameters based on your inputs
# The "modulo" is now the total time of all slots combined
full_cycle_duration = slot_duration * slot_total
# The "offset" is where your specific slot begins
target_offset = slot_duration * slot_index
now = time.time()
# 2. Determine where we are in the current cycle
current_pos = now % full_cycle_duration
# 3. Calculate time remaining to reach the target offset
# The logic handles wrap-around automatically
wait_time = (target_offset - current_pos) % full_cycle_duration
target_time = now + wait_time
if enable_sleep == False:
return target_time
# 4. Hybrid Wait Strategy
# Only sleep if the wait is significant enough (longer than buffer)
if wait_time > spin_buffer:
time.sleep(wait_time - spin_buffer)
# 5. Precision Spin (Busy Wait)
# This loop burns CPU for the final milliseconds to ensure accuracy
while True:
if time.time() >= target_time:
break
return target_time
def connect(self):
"""
Establishes the serial connection and starts the dedicated reader thread.
:return: True if connection is successful, False otherwise.
"""
if self.serial_conn and hasattr(self.serial_conn, 'is_open') and self.serial_conn.is_open: # Check for mock port compatibility
logger.info("Already connected.")
return True
try:
if self.port is None and not (hasattr(self.serial_conn, 'is_open') and self.serial_conn.is_open): # For mock port assignment
logger.info("Serial port is None, connecting to simulation mock.")
self.serial_conn = simu.Serial(self.port, self.baudrate, timeout=self.serial_read_timeout)
if self.port is not None: # Only create serial.Serial if port is specified (not a mock)
self.serial_conn = serial.Serial(self.port, self.baudrate, timeout=self.serial_read_timeout)
if not (hasattr(self.serial_conn, 'is_open') and self.serial_conn.is_open): # If still not open (e.g. mock not opened)
logger.error("Connection object is not open after attempting to initialize.")
return False
self._stop_event.clear()
self._reader_thread = threading.Thread(target=self._reader_thread_loop, daemon=True)
self._reader_thread.start()
display_port = self.port if self.port is not None else getattr(self.serial_conn, 'port', 'mock port')
logger.success(f"Successfully connected to {display_port} at {self.baudrate} and started reader thread.")
return True
except serial.SerialException as e:
logger.error(f"Error connecting to {self.port}: {e}")
self.serial_conn = None
return False
except AttributeError as e:
logger.error(f"AttributeError during connect: {e}. This might happen if 'port' is None and 'serial_conn' was not pre-assigned for testing.")
return False
except Exception as e: # Catch other potential errors
logger.error(f"An unexpected error occurred during connect: {e}", exc_info=True)
self.serial_conn = None
return False
def disconnect(self):
"""
Stops the reader thread and closes the serial connection gracefully.
"""
# Determine if we are trying to disconnect a non-existent or already closed connection
was_connected = False
if self.serial_conn:
if hasattr(self.serial_conn, 'is_open') and self.serial_conn.is_open:
was_connected = True
elif not hasattr(self.serial_conn, 'is_open'): # Mock object might not have is_open initially
if self._reader_thread and self._reader_thread.is_alive(): # If thread is running, assume it was "connected"
was_connected = True
if not was_connected and not (self._reader_thread and self._reader_thread.is_alive()):
logger.warning("Not connected or already disconnected.")
return
logger.debug("Stopping reader thread...")
self._stop_event.set()
if self._reader_thread and self._reader_thread.is_alive():
self._reader_thread.join(timeout=self.default_timeout + 1)
if self._reader_thread.is_alive():
logger.warning("Reader thread did not stop in time.")
if self.serial_conn and hasattr(self.serial_conn, 'is_open') and self.serial_conn.is_open:
try:
self.serial_conn.close()
logger.info(f"Serial port {getattr(self.serial_conn, 'port', '')} closed.")
except Exception as e:
logger.error(f"Error closing serial connection: {e}")
elif self.serial_conn: # Port existed but wasn't open
logger.warning(f"Serial port {getattr(self.serial_conn, 'port', '')} was not open but cleaning up.")
self.serial_conn = None
with self._response_lock:
for handler_id, handler_info in list(self._response_handlers.items()):
if not handler_info['event'].is_set():
handler_info['response_data'] = {'error': 'disconnecting', 'id': handler_id}
handler_info['event'].set()
self._response_handlers.clear()
logger.info("Disconnected and cleaned up.")
def baudrate_update(self, new_baudrate):
self.disconnect()
self.baudrate = new_baudrate
return self.connect()
def _parse_frame_from_buffer(self):
"""
Attempts to parse one complete SBP frame from the internal _receive_buffer.
Handles finding SYNC bytes, validating length, and checksum.
:return: A dictionary representing the parsed frame if successful, None otherwise.
"""
while True:
sync_pos = self._receive_buffer.find(bytes([SYNC1, SYNC2]))
sync_sddbt = self._receive_buffer.find(bytes(list(b"$SDDBT")))
# Add delay to release the reader thread
time.sleep(0.0003)
logger.debug(f"pos={sync_pos}, sdd={sync_sddbt}, size={len(self._receive_buffer)}")
if sync_pos == -1 and sync_sddbt == -1:
if len(self._receive_buffer) > 1:
self._receive_buffer = self._receive_buffer[-1:] if self._receive_buffer[-1] == SYNC1 else bytearray()
return None
elif sync_sddbt != -1 and sync_pos == -1: # priority on sync_pos
if sync_sddbt > 0:
self._receive_buffer = self._receive_buffer[sync_sddbt:]
if self._receive_buffer[-2]==0x0d and self._receive_buffer[-1]==0x0a:
logger.debug(f"SDDBT found!!!!{self._receive_buffer}")
ret = {'nmea':self._receive_buffer}
self._receive_buffer = bytearray()
return ret
return None
if sync_pos > 0:
logger.debug(f"Discarding {sync_pos} bytes before sync: {self._receive_buffer[:sync_pos].hex().upper()}")
self._receive_buffer = self._receive_buffer[sync_pos:]
if len(self._receive_buffer) < 6:
logger.debug(f"received_buffer={self._receive_buffer}<6")
return None
s1, s2, route_recv, mode_recv, id_recv, length_recv = struct.unpack_from('<BBBBBB', self._receive_buffer, 0)
if s1 != SYNC1 or s2 != SYNC2:
logger.debug(f"Invalid sync bytes {s1:#02x} {s2:#02x} after find (expected {SYNC1:#02x} {SYNC2:#02x}), removing first byte and retrying parse.")
self._receive_buffer = self._receive_buffer[1:]
continue
expected_frame_len = 6 + length_recv + 2
if len(self._receive_buffer) < expected_frame_len:
logger.debug(f"receive_buffer ({self._receive_buffer}) < expected_frame_len ({expected_frame_len})")
return None
frame_bytes = self._receive_buffer[:expected_frame_len]
payload_bytes = frame_bytes[6 : 6 + length_recv]
check1_recv, check2_recv = struct.unpack_from('<BB', frame_bytes, 6 + length_recv)
checksum_data_recv = frame_bytes[2 : 6 + length_recv]
calc_check1, calc_check2 = self._calculate_fletcher16(checksum_data_recv)
checksum_ok = (calc_check1 == check1_recv and calc_check2 == check2_recv)
if not checksum_ok:
logger.error(f"Checksum error! Frame: {frame_bytes.hex().upper()}. Received C1={check1_recv:02X}, C2={check2_recv:02X}. Calculated C1={calc_check1:02X}, C2={calc_check2:02X}. Discarding frame.")
self._receive_buffer = self._receive_buffer[expected_frame_len:]
continue
self._receive_buffer = self._receive_buffer[expected_frame_len:]
parsed_frame = {
'route': route_recv, 'mode': mode_recv, 'id': id_recv, 'length': length_recv,
'payload': payload_bytes, 'check1_recv': check1_recv, 'check2_recv': check2_recv,
'checksum_ok': checksum_ok,
'raw_frame': frame_bytes
}
logger.debug(f"Parsed frame: ID={parsed_frame['id']:#02x}, Len={parsed_frame['length']}")
is_device_resp_payload_flag = (parsed_frame['mode'] >> 7) & 0x01
received_type = parsed_frame['mode'] & 0x03
if is_device_resp_payload_flag and received_type == TYPE_CONTENT and parsed_frame['length'] == 3: # RESP message structure
resp_code, cmd_chk1, cmd_chk2 = struct.unpack('<BBB', parsed_frame['payload'])
parsed_frame['resp_code'] = resp_code
parsed_frame['original_cmd_chk1'] = cmd_chk1
parsed_frame['original_cmd_chk2'] = cmd_chk2
return parsed_frame
return None
def _parse_message(self, message_frame):
"""
Routes a raw message frame to the correct payload parser based on its ID.
This is used for unsolicited messages to avoid re-triggering command sends.
:param message_frame: The raw message frame dictionary from _parse_frame_from_buffer.
:return: A dictionary with the parsed data, or the original frame if no parser is found.
"""
parsers = {
ID_TIMESTAMP: self._parse_timestamp_payload,
ID_DIST: self._parse_distance_payload,
ID_CHART: self._parse_chart_data_payload,
ID_ATTITUDE: self._parse_attitude_payload,
ID_TEMP: self._parse_temperature_payload,
ID_DATASET: self._parse_dataset_config_payload,
ID_DIST_SETUP: self._parse_distance_setup_payload,
ID_CHART_SETUP: self._parse_chart_setup_payload,
ID_DSP: self._parse_dsp_payload,
ID_TRANSC: self._parse_transceiver_settings_payload,
ID_SND_SPD: self._parse_sound_speed_payload,
ID_UART: self._parse_uart_config_payload,
ID_VERSION: self._parse_version_info_payload,
ID_MARK: self._parse_mark_status_payload,
ID_DIAG: self._parse_diagnostics_payload,
ID_NAV: self._parse_navigation_data_payload,
ID_DVL_VEL: self._parse_dvl_velocity_data_payload,
ID_SIGNAL_ENCODER: self._parse_signal_encoder_data_payload,
ID_SIGNAL_DECODER: self._parse_signal_decoder_data_payload,
ID_USBL_SOLUTION: self._parse_usbl_solution_payload,
ID_MODEM_SOLUTION: self._parse_modem_solution_payload,
}
parser = parsers.get(message_frame['id'])
if parser:
# Call the appropriate parser with the frame
return parser(message_frame)
logger.debug(f"No specific parser for unsolicited message ID {message_frame['id']:#02x}. Returning raw frame.")
return message_frame
def _reader_thread_loop(self):
"""Target function for the reader thread."""
logger.info("Reader thread started.")
while not self._stop_event.is_set():
try:
# Check if serial_conn is valid and open before reading
if not (self.serial_conn and hasattr(self.serial_conn, 'is_open') and self.serial_conn.is_open):
logger.debug("Reader thread: serial connection not available or not open. Pausing.")
time.sleep(0.1)
if self._stop_event.is_set(): break
continue # Re-check connection status in next iteration
bytes_to_read = self.serial_conn.in_waiting or 1
data = self.serial_conn.read(bytes_to_read)
if data:
self._receive_buffer.extend(data)
logger.debug(f"Reader thread received: {data.hex()}, buffer size: {len(self._receive_buffer)}")
logger.debug(f"FULL MESSAGE: {self._receive_buffer}")
if self._csv_logger: self._csv_logger.log("RECEIVED", data)
while True:
parsed_frame = self._parse_frame_from_buffer()
if parsed_frame is None:
break
if 'nmea' in parsed_frame:
logger.debug("nmea frame received")
if self._nmea_callback:
try: self._nmea_callback(parsed_frame)
except Exception as e: logger.error(f"Error in nmea callback: {e}", exc_info=True)
break
logger.debug(f"Reader dispatching frame ID: {parsed_frame['id']:#02x}")
handled_as_solicited = False
with self._response_lock:
if parsed_frame['id'] in self._response_handlers:
handler = self._response_handlers[parsed_frame['id']]
if handler['response_data'] is None:
handler['response_data'] = parsed_frame
handler['event'].set()
handled_as_solicited = True
logger.debug(f"Reader: Dispatched solicited response for ID {parsed_frame['id']:#02x}")
else:
logger.warning(f"Reader: Response handler for ID {parsed_frame['id']:#02x} already had data. New frame ignored for this handler.")
if not handled_as_solicited:
logger.debug(f"Reader: Frame ID {parsed_frame['id']:#02x} is unsolicited.")
# Parse the message payload without sending a new command
parsed_message = self._parse_message(parsed_frame)
if parsed_frame['id'] in self._precallbacks or parsed_frame['id'] in self._callbacks:
ret = 1 # return value of precallback, 1=send callback, 0=No
if parsed_frame['id'] in self._precallbacks:
try:
ret = self._precallbacks[parsed_frame['id']](parsed_message)
except Exception as e: logger.error(f"Error in precallback for ID {parsed_frame['id']:#02x}: {e}", exc_info=True)
if ret==1 and parsed_frame['id'] in self._callbacks:
try:
self._callbacks[parsed_frame['id']](parsed_message)
except Exception as e: logger.error(f"Error in callback for ID {parsed_frame['id']:#02x}: {e}", exc_info=True)
elif self._default_callback:
try: self._default_callback(parsed_message)
except Exception as e: logger.error(f"Error in default callback: {e}", exc_info=True)
else:
try: self._unsolicited_queue.put_nowait(parsed_message)
except queue.Full: logger.warning(f"Unsolicited message queue full. Discarding message ID {parsed_frame['id']:#02x}.")
except serial.SerialException as se:
logger.error(f"Serial error in reader thread: {se}. Stopping thread.")
self._stop_event.set()
with self._response_lock:
for handler_id, handler_info in list(self._response_handlers.items()):
if not handler_info['event'].is_set():
handler_info['response_data'] = {'error': 'serial_exception_in_reader', 'id': handler_id, 'details': str(se)}
handler_info['event'].set()
break
except Exception as e:
logger.error(f"Unexpected error in reader thread: {e}", exc_info=True)
time.sleep(0.01)
logger.info("Reader thread finished.")
def _build_route_field(self, dev_addr=None):
"""Builds the ROUTE field (U1). """
if dev_addr is None: dev_addr = self.device_address
return dev_addr & 0x0F
def _build_mode_field(self, type_val, version_val=0, request_resp=False, mark_val=False):
"""Builds the MODE field (U1). """
mode = (type_val & 0x03)
mode |= ((version_val & 0x07) << 3)
if mark_val: mode |= (1 << 6)
if request_resp: mode |= (1 << 7)
return mode
def _build_frame(self, cmd_id, payload=b'', type_val=TYPE_GETTING, version_val=0, request_resp_flag=True, target_dev_addr=None):
"""Constructs a complete SBP frame ready for sending. """
if len(payload) > 128:
raise ValueError("Payload length cannot exceed 128 bytes as per protocol. ")
route = self._build_route_field(dev_addr=target_dev_addr)
mode = self._build_mode_field(type_val, version_val, request_resp_flag)
length = len(payload)
checksum_data = bytes([route, mode, cmd_id, length]) + payload
check1, check2 = self._calculate_fletcher16(checksum_data)
frame = bytes([SYNC1, SYNC2, route, mode, cmd_id, length]) + payload + bytes([check1, check2])
return frame
def _send_frame(self, frame):
"""Sends a pre-built frame over the serial connection."""
if not (self.serial_conn and hasattr(self.serial_conn, 'is_open') and self.serial_conn.is_open):
logger.error("Serial connection is not open for sending.")
return False
try:
self.serial_conn.write(frame)
logger.debug(f"Sent: {frame.hex().upper()}")
if self._csv_logger: self._csv_logger.log("SENT", frame)
return True
except serial.SerialException as e: # Catch pyserial specific errors
logger.error(f"SerialException during send_frame: {e}")
self._stop_event.set()
return False
except Exception as e: # Catch other errors like if serial_conn is a mock without write
logger.error(f"General Exception during send_frame: {e}")
self._stop_event.set()
return False
def _execute_command(self, cmd_id, payload=b'', type_val=TYPE_GETTING, version_val=0, request_resp_flag=True, target_dev_addr=None, expect_content=True):
"""Core internal function to send a command and wait for its specific response."""
if not (self.serial_conn and hasattr(self.serial_conn, 'is_open') and self.serial_conn.is_open):
logger.error("Not connected. Cannot execute command.")
return None
frame_to_send = self._build_frame(cmd_id, payload, type_val, version_val, request_resp_flag, target_dev_addr)
event = threading.Event()
handler_key = cmd_id
with self._response_lock:
if handler_key in self._response_handlers:
logger.warning(f"Overwriting existing response handler for command ID {cmd_id:#02x}.")
self._response_handlers[handler_key] = {'event': event, 'response_data': None}
if not self._send_frame(frame_to_send):
with self._response_lock:
if handler_key in self._response_handlers:
del self._response_handlers[handler_key]
return None
response_data = None
try:
if not event.wait(timeout=self.default_timeout):
logger.warning(f"Timeout waiting for response for command ID {cmd_id:#02x}")
return None
with self._response_lock:
if handler_key in self._response_handlers:
response_data = self._response_handlers[handler_key]['response_data']
else:
logger.warning(f"Response handler for {cmd_id:#02x} missing after event was set.")
return None
if response_data is None:
logger.error(f"Critical: Event set for command {cmd_id:#02x} but no response data found.")
return None
if 'error' in response_data:
logger.error(f"Command {cmd_id:#02x} failed: {response_data['error']}{'; Details: ' + response_data['details'] if 'details' in response_data else ''}")
return None
if 'resp_code' in response_data and response_data['id'] == cmd_id and response_data['resp_code'] != RESP_OK:
logger.warning(f"Command {cmd_id:#02x} received error RESP: {self.get_resp_code_meaning(response_data['resp_code'])}")
return response_data
if expect_content:
if response_data['id'] == cmd_id and (response_data['mode'] & 0x03) == TYPE_CONTENT and 'resp_code' not in response_data:
return response_data
else:
logger.warning(f"Command {cmd_id:#02x} expected content but received: ID={response_data.get('id', 'N/A'):#02x}, Mode={response_data.get('mode', 'N/A'):#02x}, RespCode={response_data.get('resp_code', 'N/A')}")
return response_data
else:
if 'resp_code' in response_data and response_data['id'] == cmd_id and response_data['resp_code'] == RESP_OK:
return response_data
else:
logger.warning(f"Command {cmd_id:#02x} expected RESP_OK, received: ID {response_data.get('id', 'N/A'):#02x} with resp_code {response_data.get('resp_code', 'N/A')}")
return response_data
finally:
with self._response_lock:
if handler_key in self._response_handlers:
del self._response_handlers[handler_key]
return response_data
def get_resp_code_meaning(self, code):
"""Returns a human-readable string for a device RESP code. """
return {
RESP_NONE: "RESP_NONE", RESP_OK: "RESP_OK", RESP_ERR_CHECKSUM: "RESP_ERR_CHECKSUM",
RESP_ERR_PAYLOAD: "RESP_ERR_PAYLOAD", RESP_ERR_ID: "RESP_ERR_ID",
RESP_ERR_VERSION: "RESP_ERR_VERSION", RESP_ERR_TYPE: "RESP_ERR_TYPE",
RESP_ERR_KEY: "RESP_ERR_KEY", RESP_ERR_RUNTIME: "RESP_ERR_RUNTIME"
}.get(code, f"Unknown RESP code: {code}")
def register_precallback(self, message_id, callback_function):
"""Registers a pre-callback function for a specific unsolicited message ID."""
self._precallbacks[message_id] = callback_function
logger.debug(f"pre-Callback registered for unsolicited message ID {message_id:#02x}")
def register_callback(self, message_id, callback_function):
"""Registers a callback function for a specific unsolicited message ID."""
self._callbacks[message_id] = callback_function
logger.debug(f"Callback registered for unsolicited message ID {message_id:#02x}")
def unregister_callback(self, message_id):
"""Unregisters a callback for a specific unsolicited message ID."""
if message_id in self._callbacks: del self._callbacks[message_id]
if message_id in self._precallbacks: del self._precallbacks[message_id]
logger.debug(f"Callback unregistered for unsolicited message ID {message_id:#02x}")
def register_default_callback(self, callback_function):
"""Registers a default callback for any unhandled unsolicited messages."""
self._default_callback = callback_function
logger.debug("Default callback registered for unsolicited messages.")
def unregister_default_callback(self):
"""Unregisters the default callback."""
self._default_callback = None
logger.debug("Default callback unregistered.")
def register_nmea_callback(self, callback_nmea):
"""Registers a nmea callback for any nmea messages."""
self._nmea_callback = callback_nmea
logger.debug("NMEA callback registered for NMEA messages.")
def unregister_nmea_callback(self):
"""Unregisters the nmea callback."""
self._nmea_callback = None
logger.debug("NMEA callback unregistered.")
def get_unsolicited_message(self, block=True, timeout=None):
"""Retrieves a message from the unsolicited message queue."""
try: return self._unsolicited_queue.get(block=block, timeout=timeout)
except queue.Empty: return None
# --- Pre-defined callback ---
def callback_usbl_solution(self, message):
# Skip message received for the time after a valid message
__DELAY_ECHO_REMOVER = 1
if self._vehicleType=="AUV":
# Add filter to be sure to receive the same ID for successive message
__NB_SAME_ID_SUCC = 3
# Or get the ID if the last ok was before this delay in seconds
__DELAY_RESET_SAME_ID = 9999999
else:
__NB_SAME_ID_SUCC = 0
__DELAY_RESET_SAME_ID = 9999999
if not hasattr(self.callback_usbl_solution.__func__, '_tick_last_ok'):
# Local variable
self.callback_usbl_solution.__func__._tick_last_ok = time.time()
self.callback_usbl_solution.__func__._last_snr = 0
self.callback_usbl_solution.__func__._id_prev = 0
self.callback_usbl_solution.__func__._id_set = 255
self.callback_usbl_solution.__func__._cnt_same_id = 0
parsed = json.loads(str(message).replace("nan", "'nan'").replace("'",'"'))
logger.debug("callback_usbl_solution:"+str(json.dumps(parsed, indent=2)))
# Low SNR = skip
_delay_previous_ok = time.time() - self.callback_usbl_solution.__func__._tick_last_ok
self.callback_usbl_solution.__func__._tick_last = time.time()
if message.get('id', 255)==255 or message.get("snr", 0.0)<self.callback_usbl_solution.__func__._last_snr-2-_delay_previous_ok*0.5:
return 0
# Skip message if too successive
if "timestamp_pi" in self.usbl_data and time.time() - self.usbl_data["timestamp_pi"] < __DELAY_ECHO_REMOVER:
return 0
# Check echo, filter if ping arrives too late
logger.debug(f"self._usbl_filter_echo_enable={self._usbl_filter_echo_enable}, echo_orig={self._usbl_filter_echo_orig_received}")
if self._usbl_filter_echo_enable==True and self._usbl_filter_echo_orig_received==True:
logger.debug(f"diff={time.time()-self._usbl_filter_echo}, {(self._sync_slot_duration*self._sync_slot_total)-0.2}")
if (self._sync_slot_duration == 0 and (time.time()-self._usbl_filter_echo <= 0.7)) or \
(time.time()-self._usbl_filter_echo <= (self._sync_slot_duration*self._sync_slot_total)-0.2):
return 0
self._usbl_filter_echo_orig_received = True
# 255 or wrong previous ID, set the previous correct ID
if self.callback_usbl_solution.__func__._id_prev!=255 and self.callback_usbl_solution.__func__._id_prev == message.get('id', 255):
self.callback_usbl_solution.__func__._cnt_same_id += 1
else:
self.callback_usbl_solution.__func__._cnt_same_id = 0
if self.callback_usbl_solution.__func__._cnt_same_id >= __NB_SAME_ID_SUCC-1 or _delay_previous_ok > __DELAY_RESET_SAME_ID:
# Set ID if valid
self.callback_usbl_solution.__func__._id_set = message.get('id', 255)
self.callback_usbl_solution.__func__._id_prev = message.get('id', 255)
self.usbl_data = message
self.usbl_data["id"] = self.callback_usbl_solution.__func__._id_set
self.usbl_data["timestamp_pi"] = time.time()
self.usbl_data["slot_index"] = self.get_slot_index_from_time(self.usbl_data["timestamp_pi"])
self.callback_usbl_solution.__func__._last_snr = message.get("snr", 0)
self.callback_usbl_solution.__func__._tick_last_ok = time.time()
return 1
# --- Payload Parsers ---
def _parse_timestamp_payload(self, frame):
if frame['length'] == 4:
timestamp = struct.unpack('<I', frame['payload'])[0]
return {'timestamp_ms': timestamp}
logger.warning(f"Invalid length for timestamp payload: {frame['length']}")
return None
def _parse_distance_payload(self, frame):
payload = frame['payload']
resp_version = (frame['mode'] >> 3) & 0x07
if resp_version == 0 and frame['length'] == 4:
distance = struct.unpack('<I', payload)[0]
return {'distance_mm': distance, 'version': 0}
elif resp_version == 1 and frame['length'] == 8:
number, strong, distance, width = struct.unpack('<BBIH', payload)
return {'number': number, 'strong': strong, 'distance_mm': distance, 'width_mm': width, 'version': 1}
logger.warning(f"Invalid length/version for distance payload: L={frame['length']}, V={resp_version}")
return None
def _parse_chart_data_payload(self, frame):
if frame['length'] >= 6:
try:
seq_offset, res_mm, abs_offset = struct.unpack_from('<HHH', frame['payload'], 0)
chart_data = frame['payload'][6:]
return {'seq_offset': seq_offset, 'sample_resolution_mm': res_mm, 'abs_offset_samples': abs_offset, 'chart_data': chart_data}
except struct.error as e:
logger.error(f"Error unpacking chart data: {e}")
return None
def _parse_attitude_payload(self, frame):
payload = frame['payload']
resp_version = (frame['mode'] >> 3) & 0x07
if resp_version == 0 and frame['length'] == 6:
yaw, pitch, roll = struct.unpack('<hhh', payload)
return {'yaw_deg': yaw / 100.0, 'pitch_deg': pitch / 100.0, 'roll_deg': roll / 100.0, 'version': 0}
elif resp_version == 1 and frame['length'] == 16:
w0, w1, w2, w3 = struct.unpack('<ffff', payload)
return {'q_w0': w0, 'q_w1': w1, 'q_w2': w2, 'q_w3': w3, 'version': 1}
logger.warning(f"Invalid length/version for attitude payload: L={frame['length']}, V={resp_version}")
return None
def _parse_temperature_payload(self, frame):
if frame['length'] == 2:
temp = struct.unpack('<h', frame['payload'])[0]
return {'temperature_celsius': temp / 100.0}
return None
def _parse_dataset_config_payload(self, frame):
if frame['length'] == 9:
channel_id, period, mask = struct.unpack('<BII', frame['payload'])
return {'channel_id': channel_id, 'channel_period_ms': period, 'channel_mask': mask}
return None
def _parse_distance_setup_payload(self, frame):
resp_version = (frame['mode'] >> 3) & 0x07
if resp_version == 1 and frame['length'] == 8:
start_offset, max_dist = struct.unpack('<II', frame['payload'])
return {'start_offset_mm': start_offset, 'max_distance_mm': max_dist}
elif resp_version == 2 and frame['length'] == 1:
confidence_threshold_perc = struct.unpack('<B', frame['payload'])[0]
return {'confidence_threshold_perc': confidence_threshold_perc}
return None
def _parse_chart_setup_payload(self, frame):
if frame['length'] == 6:
count, resolution, offset = struct.unpack('<HHH', frame['payload'])
return {'sample_count': count, 'sample_resolution_mm': resolution, 'sample_offset_num': offset}
return None
def _parse_dsp_payload(self, frame):
if frame['length'] == 1:
horizontal_smoothing_factor = struct.unpack('<B', frame['payload'])[0]
return {'horizontal_smoothing_factor': horizontal_smoothing_factor}
return None
def _parse_transceiver_settings_payload(self, frame):
if frame['length'] == 4:
freq, pulse, boost_val = struct.unpack('<HBB', frame['payload'])
return {'frequency_khz': freq, 'pulse_count': pulse, 'boost_enabled': bool(boost_val)}
return None
def _parse_sound_speed_payload(self, frame):
if frame['length'] == 4:
speed = struct.unpack('<I', frame['payload'])[0]
return {'sound_speed_mm_s': speed}
return None
def _parse_uart_config_payload(self, frame):
resp_version = (frame['mode'] >> 3) & 0x07
payload = frame['payload']
if resp_version == 0 and frame['length'] == 9:
key, uart_id, baud = struct.unpack('<IBI', payload)
if key == KEY_CONFIRM: return {'uart_id': uart_id, 'baudrate': baud, 'version': 0}
elif resp_version == 1 and frame['length'] == 6:
key, uart_id, dev_addr = struct.unpack('<IBB', payload)
if key == KEY_CONFIRM: return {'uart_id': uart_id, 'device_address': dev_addr, 'version': 1}
elif resp_version == 2 and frame['length'] == 5:
key, dev_def_address = struct.unpack('<IB', payload)
if key == KEY_CONFIRM: return {'dev_def_address': dev_def_address}
return None
def _parse_version_info_payload(self, frame):
resp_version = (frame['mode'] >> 3) & 0x07
if resp_version==0 and frame['length'] == 34:
try:
data = struct.unpack('<ssHHHIssI16s', frame['payload'])
return {'hw_ver_minor': int.from_bytes(data[0], "big"), 'hw_ver_major': int.from_bytes(data[1], "big"), 'hw_ver_ext': data[2],
'reserved1': data[3], 'reserved2': data[4], 'reserved3': data[5],
'boot_ver_minor': int.from_bytes(data[6], "big"), 'boot_ver_major': int.from_bytes(data[7], "big"),
'serial_number': data[8], 'part_nbr': data[9]
}
except (struct.error, UnicodeDecodeError) as e:
logger.error(f"Error parsing version info: {e}")
elif resp_version==2 and frame['length'] == 9:
try:
data = struct.unpack('<sssssHss', frame['payload'])
return {'run_mode': int.from_bytes(data[0], "big"), 'hw_ver_minor': int.from_bytes(data[1], "big"), 'hw_ver_major': int.from_bytes(data[2], "big"),
'boot_ver_minor': int.from_bytes(data[3], "big"), 'boot_ver_major': int.from_bytes(data[4], "big"), 'reserved1': data[5],
'fw_ver_minor': int.from_bytes(data[6], "big"), 'fw_ver_major': int.from_bytes(data[7], "big")
}
except (struct.error, UnicodeDecodeError) as e:
logger.error(f"Error parsing version all: {e}")
return None
def _parse_mark_status_payload(self, frame):
if frame['length'] == 1:
mark_status = struct.unpack('<B', frame['payload'])[0]
return {'mark_active': bool(mark_status)}
return None
def _parse_diagnostics_payload(self, frame):
if frame['length'] == 24:
try:
data = struct.unpack('<IhhhhHHHHH', frame['payload'])
return {'uptime_ms': data[0], 'temp_imu_c': data[1] / 100.0, 'temp_cpu_c': data[2] / 100.0,
'temp_min_c': data[3] / 100.0, 'temp_max_c': data[4] / 100.0, 'sys_voltage_mv': data[5],
'boost_voltage_mv': data[6], 'detector_voltage_mv': data[7], 'detector_noise_mv': data[8],
'agc_gate_voltage_mv': data[9]}
except struct.error as e:
logger.error(f"Error parsing diagnostics: {e}")
return None
def _parse_navigation_data_payload(self, frame):
if frame['length'] == 20:
try:
lat, lon, acc = struct.unpack('<ddf', frame['payload'])
return {'latitude_deg': lat, 'longitude_deg': lon, 'accuracy_m': acc}
except struct.error as e:
logger.error(f"Error parsing nav data: {e}")
return None
def _parse_dvl_velocity_data_payload(self, frame):
if frame['length'] == 68 and ((frame['mode'] >> 3) & 0x07) == 2:
try:
data = struct.unpack('<IIffffffffffcccccfff', frame['payload'])
return {'flags': data[0], 'timestamp_ms': data[1], 'delta_time_s': data[2], 'latency_s': data[3],
'velocity_x_mps': data[4], 'velocity_y_mps': data[5], 'velocity_z_mps': data[6],
'velocity_z1_mps': data[7], 'velocity_z2_mps': data[8], 'uncertainty_x_mps': data[9],
'uncertainty_y_mps': data[10], 'uncertainty_z_mps': data[11], 'uncertainty_z1_mps': data[12],
'uncertainty_z2_mps': data[13], 'distance_z_m': data[14], 'distance_z1_m': data[15],
'distance_z2_m': data[16]}
except struct.error as e:
logger.error(f"Error parsing DVL data: {e}")
return None
def _parse_signal_encoder_data_payload(self, frame):
if frame['length'] == 7:
reserved1, bit_length, data = struct.unpack('<IHB', frame['payload'])
return {'reserved1': reserved1, 'bit_length': bit_length, 'data': data}
return None
def _parse_signal_decoder_data_payload(self, frame):
if frame['length'] == 47:
try:
data = struct.unpack('<IqqffffIIHB', frame['payload'])
return {'timestamp_ms': data[0], 'carrier_us': data[1], 'carrier_count': data[2],
'source_level_db': data[3], 'source_snr_db': data[4], 'azimuth_deg': data[5],
'elevation_deg': data[6], 'reserved1': data[7], 'reserved2': data[8],
'bit_length': data[9], 'data': data[10]}
except struct.error as e:
logger.error(f"Error parsing signal decoder data: {e}")
return None
def _parse_usbl_solution_payload(self, frame):
if frame['length'] == 152:
try:
data = struct.unpack('<BBHqIqfffffffffddffffddIff8f', frame['payload'])
return {'id': data[0], 'role': data[1], 'reserved': data[2], 'timestamp_us': data[3],
'ping_counter': data[4], 'carrier_counter': data[5], 'distance_m': data[6],
'distance_unc': data[7], 'azimuth_deg': data[8], 'azimuth_unc': data[9],
'elevation_deg': data[10], 'elevation_unc': data[11], 'snr': data[12],
'beacon_x_m': data[13], 'beacon_y_m': data[14], 'beacon_latitude': data[15],
'beacon_longitude': data[16], 'beacon_depth': data[17], 'usbl_yaw': data[18],
'usbl_pitch': data[19], 'usbl_roll': data[20], 'usbl_latitude': data[21],
'usbl_longitude': data[22], 'last_iTOW': data[23], 'beacon_n_m': data[24],
'beacon_e_m': data[25], 'code_snr': list(data[26:])}
except struct.error as e:
logger.error(f"Error parsing USBL solution: {e}")
elif frame['length'] == 52:
# New v3.0 from Kogger when navigation fusion is disabled
try:
data = struct.unpack('<BBBBqIqfffffff', frame['payload'])
return {'id': data[0], 'role': data[1], 'cmd_id': data[2], 'reserved': data[3],
'timestamp_us': data[4], 'ping_counter': data[5], 'carrier_counter': data[6],
'distance_m': data[7], 'distance_unc': data[8],
'azimuth_deg': data[9], 'azimuth_unc': data[10], 'elevation_deg': data[11], 'elevation_unc': data[12],
'snr': data[13]}
except struct.error as e:
logger.error(f"Error parsing USBL solution: {e}")
return None
def _parse_modem_solution_payload(self, frame):
if frame['length'] == 38:
try:
data = struct.unpack('<qqqQBBBBH', frame['payload'])
return {'timestamp_us': data[0], 'carrier_us': data[1], 'carrier_counter': data[2], 'reserved1': data[3],
'event_on_request_response': data[4], 'address_from': data[5], 'address_to': data[6],
'slot_id': data[7], 'payload_bit_length': data[8]}
except struct.error as e:
logger.error(f"Error parsing modem solution: {e}")
return None
def _parse_usbl_control_get_timeout_payload(self, frame):
if frame['length'] == 4:
timeout = struct.unpack('<I', frame['payload'])[0]
return {'auto_response_timeout_us': timeout}
logger.warning(f"Invalid length for USBL control get timeout payload: {frame['length']}")
return None
def _parse_usbl_control_get_filter_payload(self, frame):
if frame['length'] == 1:
address = struct.unpack('<B', frame['payload'])[0]
return {'auto_response_filter_address': address}
logger.warning(f"Invalid length for USBL control get filter payload: {frame['length']}")
return None
def _parse_usbl_control_get_payload_payload(self, frame):
if frame['length'] == 1:
address = struct.unpack('<B', frame['payload'])[0]
return {'auto_response_payload_address': address}
logger.warning(f"Invalid length for USBL control get payload payload: {frame['length']}")
return None
# --- Command Implementations ---
def get_timestamp(self):
response = self._execute_command(ID_TIMESTAMP, request_resp_flag=False)
return self._parse_timestamp_payload(response) if response else None
def get_distance(self, version=0):
response = self._execute_command(ID_DIST, version_val=version, request_resp_flag=False)
if response:
resp_version = (response['mode'] >> 3) & 0x07
if resp_version != version:
logger.warning(f"Requested distance v{version}, received v{resp_version}")
return self._parse_distance_payload(response)
return None
# TODO : This takes ~160ms to respond (heavy message) in several messages
# so it's not working…
def get_chart_data(self):
response = self._execute_command(ID_CHART, request_resp_flag=False)
return self._parse_chart_data_payload(response) if response else None
def get_attitude(self, version=0):
response = self._execute_command(ID_ATTITUDE, version_val=version, request_resp_flag=False)
if response:
resp_version = (response['mode'] >> 3) & 0x07
if resp_version != version:
logger.warning(f"Requested attitude v{version}, received v{resp_version}")
return self._parse_attitude_payload(response)
return None
def get_temperature(self):
response = self._execute_command(ID_TEMP, request_resp_flag=False)
return self._parse_temperature_payload(response) if response else None
def set_dataset_config(self, channel_id, channel_period_ms, channel_mask):
if not (0 <= channel_id <= 2):
raise ValueError("Channel ID must be between 0 and 2.")
payload = struct.pack('<BII', channel_id, channel_period_ms, channel_mask)
response = self._execute_command(ID_DATASET, payload, TYPE_SETTING, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_dataset_config(self, channel_id_to_request=0):
payload = struct.pack('<B', channel_id_to_request)
response = self._execute_command(ID_DATASET, payload, TYPE_GETTING, version_val=0, request_resp_flag=False)
return self._parse_dataset_config_payload(response) if response else None
def set_distance_setup(self, start_offset_mm, max_distance_mm):
payload = struct.pack('<II', start_offset_mm, max_distance_mm)
response = self._execute_command(ID_DIST_SETUP, payload, TYPE_SETTING, version_val=1, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_distance_setup(self):
response = self._execute_command(ID_DIST_SETUP, version_val=1, request_resp_flag=False)
return self._parse_distance_setup_payload(response) if response else None
def set_confidence_threshold(self, confidence_threshold_perc):
payload = struct.pack('<B', confidence_threshold_perc)
response = self._execute_command(ID_DIST_SETUP, payload, TYPE_SETTING, version_val=2, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_confidence_threshold(self):
response = self._execute_command(ID_DIST_SETUP, version_val=2, request_resp_flag=False)
return self._parse_distance_setup_payload(response) if response else None
def set_chart_setup(self, sample_count, sample_resolution_mm, sample_offset_num):
payload = struct.pack('<HHH', sample_count, sample_resolution_mm, sample_offset_num)
response = self._execute_command(ID_CHART_SETUP, payload, TYPE_SETTING, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_chart_setup(self):
response = self._execute_command(ID_CHART_SETUP, request_resp_flag=False)
return self._parse_chart_setup_payload(response) if response else None
def set_horizontal_smoothing_factor(self, horizontal_smoothing_factor):
payload = struct.pack('<B', horizontal_smoothing_factor)
response = self._execute_command(ID_DSP, payload, TYPE_SETTING, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_horizontal_smoothing_factor(self):
response = self._execute_command(ID_DSP, request_resp_flag=False)
return self._parse_dsp_payload(response) if response else None
def set_transceiver_settings(self, frequency_khz, pulse_count, boost_enabled):
payload = struct.pack('<HBB', frequency_khz, pulse_count, 1 if boost_enabled else 0)
response = self._execute_command(ID_TRANSC, payload, TYPE_SETTING, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_transceiver_settings(self):
response = self._execute_command(ID_TRANSC, request_resp_flag=False)
return self._parse_transceiver_settings_payload(response) if response else None
def set_sound_speed(self, sound_speed_mm_s):
payload = struct.pack('<I', sound_speed_mm_s)
response = self._execute_command(ID_SND_SPD, payload, TYPE_SETTING, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_sound_speed(self):
response = self._execute_command(ID_SND_SPD, request_resp_flag=False)
return self._parse_sound_speed_payload(response) if response else None
def set_uart_config(self, uart_id, baudrate=None, new_dev_address=None):
if new_dev_address is not None and baudrate is None:
if not (0 <= new_dev_address <= 15): raise ValueError("Device address must be 0-15.")
payload = struct.pack('<IBB', KEY_CONFIRM, uart_id, new_dev_address)
version = 1
elif baudrate is not None and new_dev_address is None:
payload = struct.pack('<IBI', KEY_CONFIRM, uart_id, baudrate)
version = 0
else:
raise ValueError("Only baudrate or new_dev_address must be provided, not both.")
response = self._execute_command(ID_UART, payload, TYPE_SETTING, version, expect_content=False)
if response and response.get('resp_code') == RESP_OK:
if new_dev_address is not None: self.device_address = new_dev_address
return True
return False
def get_uart_config(self, uart_id, version=0):
if version not in [0, 1]: raise ValueError("Version must be 0 or 1.")
payload = struct.pack('<IB', KEY_CONFIRM, uart_id)
response = self._execute_command(ID_UART, payload, TYPE_GETTING, version, request_resp_flag=False)
return self._parse_uart_config_payload(response) if response else None
def set_uart_dev_def_address(self, dev_def_address):
if not (0 <= dev_def_address <=14): raise ValueError("dev_def_address must be 0-14.")
payload = struct.pack('<IB', KEY_CONFIRM, dev_def_address)
response = self._execute_command(ID_UART, payload, TYPE_SETTING, version_val=2, expect_content=False)
return response and response.get('resp_code') == RESP_OK
# !!! Not working !!!
def get_uart_dev_def_address(self):
#return None
payload = struct.pack('<I', KEY_CONFIRM)
response = self._execute_command(ID_UART, payload, TYPE_GETTING, version_val=2, request_resp_flag=False)
return self._parse_uart_config_payload(response) if response else None
def set_imu_calibration(self, calibrate_gyro=False, calibrate_accelerometer=False):
logger.warning("IMU_SETUP is marked 'In developing' in protocol PDF.")
results = {}
payload = struct.pack('<I', KEY_CONFIRM)
if calibrate_gyro:
resp_g = self._execute_command(ID_IMU_SETUP, payload, TYPE_SETTING, 0, expect_content=False)
results['gyro_cal_ok'] = resp_g and resp_g.get('resp_code') == RESP_OK
if calibrate_accelerometer:
resp_a = self._execute_command(ID_IMU_SETUP, payload, TYPE_SETTING, 1, expect_content=False)
results['accel_cal_ok'] = resp_a and resp_a.get('resp_code') == RESP_OK
return results
def get_version_info(self):
response = self._execute_command(ID_VERSION, request_resp_flag=False)
return self._parse_version_info_payload(response) if response else None
def get_version_all(self):
response = self._execute_command(ID_VERSION, version_val=2, request_resp_flag=False)
return self._parse_version_info_payload(response) if response else None
def set_mark(self):
payload = struct.pack('<I', KEY_CONFIRM)
response = self._execute_command(ID_MARK, payload, TYPE_SETTING, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_mark_status(self):
response = self._execute_command(ID_MARK, request_resp_flag=False)
return self._parse_mark_status_payload(response) if response else None
def get_diagnostics(self):
logger.warning("ID_DIAG is marked 'In developing' in protocol PDF.")
response = self._execute_command(ID_DIAG, request_resp_flag=False)
return self._parse_diagnostics_payload(response) if response else None
def flash_operation(self, operation_type):
if operation_type not in [0, 1, 2]: raise ValueError("Operation must be 0, 1, or 2.")
payload = struct.pack('<I', KEY_CONFIRM)
response = self._execute_command(ID_FLASH, payload, TYPE_SETTING, operation_type, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def save_settings_to_flash(self): return self.flash_operation(0)
def restore_settings_from_flash(self): return self.flash_operation(1)
def erase_flash_settings(self): return self.flash_operation(2)
def boot_operation(self, operation_type):
if operation_type not in [0, 1]: raise ValueError("Operation must be 0 or 1.")
payload = struct.pack('<I', KEY_CONFIRM)
if operation_type == 0: # Reboot
frame = self._build_frame(ID_BOOT, payload, TYPE_SETTING, 0)
if self._send_frame(frame):
logger.info("Reboot command sent. Disconnecting.")
time.sleep(0.2)
self.disconnect()
return True
return False
else: # Run FW
response = self._execute_command(ID_BOOT, payload, TYPE_SETTING, 1, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def reboot_device(self): return self.boot_operation(0)
def run_firmware_from_bootloader(self): return self.boot_operation(1)
def upload_firmware_update_chunk(self, packet_number, chunk):
if len(chunk) > 126: raise ValueError("Chunk size exceeds 126 bytes.")
if packet_number < 1: raise ValueError("Packet number must be >= 1.")
payload = struct.pack('<H', packet_number) + chunk
response = self._execute_command(ID_UPDATE, payload, TYPE_SETTING, 0, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_navigation_data(self):
response = self._execute_command(ID_NAV, request_resp_flag=False)
return self._parse_navigation_data_payload(response) if response else None
def get_dvl_velocity_data(self):
response = self._execute_command(ID_DVL_VEL, version_val=2, request_resp_flag=False)
return self._parse_dvl_velocity_data_payload(response) if response else None
def set_signal_encoder_data(self, bit_length, data_byte, reserved1=0):
payload = struct.pack('<IHB', reserved1, bit_length, data_byte)
response = self._execute_command(ID_SIGNAL_ENCODER, payload, TYPE_SETTING, 0, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def get_signal_encoder_data(self):
response = self._execute_command(ID_SIGNAL_ENCODER, request_resp_flag=False)
return self._parse_signal_encoder_data_payload(response) if response else None
def get_signal_decoder_data(self):
response = self._execute_command(ID_SIGNAL_DECODER, request_resp_flag=False)
return self._parse_signal_decoder_data_payload(response) if response else None
def get_usbl_solution(self):
response = self._execute_command(ID_USBL_SOLUTION, request_resp_flag=False)
return self._parse_usbl_solution_payload(response) if response else None
def get_modem_solution(self):
response = self._execute_command(ID_MODEM_SOLUTION, request_resp_flag=False)
return self._parse_modem_solution_payload(response) if response else None
def send_acoustic_ping(self, address=255, timeout_us=0):
"""
Non-blocking call. Updates the address to be sent.
If a wait is in progress, it updates the payload and returns immediately.
If idle, it starts the background wait-and-send process.
"""
with self._send_lock:
# 1. ALWAYS update the data to the latest request
self._latest_address = address
self._latest_timeout = timeout_us
# 2. If we are already waiting/sending, exit immediately
if self._is_sending:
return self._send_last_response
# 3. If idle, mark as busy and start the background thread
self._is_sending = True
# Create a daemon thread (won't prevent app from exiting)
t = threading.Thread(target=self._threaded_send_worker)
t.daemon = True
t.start()
# Return True to indicate "New send sequence started"
return self._send_last_response
def _threaded_send_worker(self):
"""
Background worker that waits for the slot, THEN grabs the latest data,
and executes the command.
"""
try:
# A. Perform the precise wait (This blocks this thread only)
if self._sync_enable_delay == True:
self._wait_until_modulo_slot_precise(
slot_total=self._sync_slot_total,
slot_index=self._sync_slot_index,
slot_duration=self._sync_slot_duration
)
# B. JUST BEFORE SENDING: Grab the latest address
# This ensures we send the "last address received" during the wait
with self._send_lock:
address = self._latest_address
timeout = self._latest_timeout
# C. Execute Command
payload = struct.pack('<IB', timeout, address)
# Assuming _execute_command exists in your class
response = self._execute_command(
ID_USBL_CONTROL,
payload,
TYPE_SETTING,
1,
expect_content=False
)
self._usbl_filter_echo = time.time()
self._usbl_filter_echo_orig_received = False
self._send_last_response = (response and response.get('resp_code') == RESP_OK)
# Optional: Log response here since we can't return it to the main thread
# if response and response.get('resp_code') == RESP_OK:
# print("Ping sent successfully")
except Exception as e:
print(f"Error in acoustic sender: {e}")
finally:
# D. Release the flag so new calls can trigger a new cycle
with self._send_lock:
self._is_sending = False
def get_slot_index_from_time(self, timestamp: float) -> int:
"""
Calculates which slot index is active at a specific timestamp.
Args:
timestamp (float): The time to check (e.g., time.time()).
Returns:
int: The index of the slot (0 to total_slots - 1).
"""
# 1. Calculate the full cycle duration
full_cycle_duration = self._sync_slot_duration * self._sync_slot_total
# 2. Find the position of the timestamp within the cycle (0 to cycle_duration)
position_in_cycle = (timestamp+0.2*self._sync_slot_duration) % full_cycle_duration
# 3. Determine which slot this position falls into
# We use int() to floor the result, as slots are sequential "buckets"
slot_index = int(position_in_cycle / self._sync_slot_duration)
return slot_index
def set_auto_response_timeout(self, timeout_us):
payload = struct.pack('<I', timeout_us)
response = self._execute_command(ID_USBL_CONTROL, payload, TYPE_SETTING, 3, expect_content=False)
return response and response.get('resp_code') == RESP_OK
# 1-8 are addresses, 0: promisc address, 0xFFFFFFFF: disabled address slot
def set_auto_response_filter(self, address):
payload = struct.pack('<I', address)
print("payload="+str(payload))
response = self._execute_command(ID_USBL_CONTROL, payload, TYPE_SETTING, 4, expect_content=False)
return response and response.get('resp_code') == RESP_OK
def set_auto_response_payload(self, address):
if (not (0 <= address <= 8)) and address != 255:
logger.warning(f"Auto response payload address should typically be 0-8. Got {address}.")
payload = struct.pack('<B', address)
response = self._execute_command(ID_USBL_CONTROL, payload, TYPE_SETTING, 5, expect_content=False)
return response and response.get('resp_code') == RESP_OK
### NEW VERSION ###
# Send request now or enable request trigger window.
# In slot rotation, this is the key command used by active initiator AUV.
# address : request address 0 to 7
# cmd_id : logical command slot 0 to 7
# reply_dist : response wait window based on max range
# function : 0: default, 1:bitArray, 2:LLGeoAzimuth
# payload_len : bit of modem payload
def set_usbl_ping_request_direct(self, address, cmd_id, reply_dist=200000, function=0, payload_len=0):
if (not (0 <= address <= 7)):
logger.warning(f"Auto response payload address should typically be 0-7. Got {address}.")
if (not (0 <= cmd_id <= 7)):
logger.warning(f"Auto response payload cmd_id should typically be 0-7. Got {cmd_id}.")
payload = struct.pack('<IBBIBH', 0, address, cmd_id, reply_dist, function, payload_len)
response = self._execute_command(ID_USBL_CONTROL, payload, TYPE_SETTING, 1, expect_content=False)
return response and response.get('resp_code') == RESP_OK
# Enable/disable transponder response window.
# False:silent, True:always transponder
def set_usbl_transponder(self, enable):
timeout_us = 0
if enable == False:
timeout_us = 0
else:
timeout_us = 0xFFFFFFFF
payload = struct.pack('<I', timeout_us)
response = self._execute_command(ID_USBL_CONTROL, payload, TYPE_SETTING, 3, expect_content=False)
return response and response.get('resp_code') == RESP_OK
# List for incoming request addresses
# 0 to 7 and 0xff=unused
# Should be an array of 8 bytes : [0,1,2,3,0xff,5,6,7]
def set_usbl_request_address_filter(self, addresses):
if len(addresses)!=8:
logger.error(f"usbl request address filter should be an array of 8 bytes:{address}")
return RESP_ERR_PAYLOAD
for address in addresses:
if address > 7 and address!=0xff:
logger.error(f"usbl request address filter should be between [0;7] or 0xff:{address}")
return RESP_ERR_PAYLOAD
payload = struct.pack('<8B', addresses)
response = self._execute_command(ID_USBL_CONTROL, payload, TYPE_SETTING, 4, expect_content=False)
return response and response.get('resp_code') == RESP_OK
# Commands for each request/response events and modem payload handling
# Use cmd_id one by one
# cmd_id : slot index 0 to 7
# event_on_receive_req_resp : True for request, False for response
# cmd_id_replacement : New cmd_id to put on reception, -1 to disable replacement
# address_replacement : New address_replacement to put on reception, -1 to disable replacement
# send_back_ev_swap : True:Swap event direction, False:keep the same
# receiver_function : 0:Default, 1:BitArray, 2:LLGeoAzimuth
# receive_bit_length : Number of bits to wait during reception
# sender_function : 0:Default, 1:BitArray, 2:LLGeoAzimuth
# send_bit_length : Number of bits to send
def set_usbl_cmd_config(self, cmd_id, event_on_receive_req_resp=False,
cmd_id_replacement=-1,
address_replacement=-1,
send_back_ev_swap=False,
receiver_function=0, receive_bit_length=0,
sender_function=0, send_bit_length=0):
if (not (0 <= cmd_id <= 7)):
logger.warning(f"USBL cmd config cmd_id should typically be 0-7. Got {cmd_id}.")
payload = struct.pack('<IBBIBH',
cmd_id,
event_on_request_response==1,
function, payload_len)
response = self._execute_command(ID_USBL_CONTROL, payload, TYPE_SETTING, 6, expect_content=False)
return response and response.get('resp_code') == RESP_OK
# Runtime receive/suppress tuning.
# enable = True enable monitor, False:disable
def set_usbl_monitor_config(self, enable, echo_filter_response_us=400000, echo_filter_request_us=400000):
payload = struct.pack('<II?', echo_filter_response_us, echo_filter_request_us, enable)
response = self._execute_command(ID_USBL_CONTROL, payload, TYPE_SETTING, 7, expect_content=False)
return response and response.get('resp_code') == RESP_OK
# Set the sync mode, external to USBL
def set_sync_mode(self, slot_total, slot_index, slot_duration, enable_delay):
# enable_delay : use to wait slot when send_acoustic. Only for master!
self._sync_slot_total = slot_total
self._sync_slot_index = slot_index
self._sync_slot_duration = slot_duration
self._sync_enable_delay = enable_delay
return True
def set_echo_filter(self, enable):
if enable == True:
self._usbl_filter_echo_enable = True
else:
self._usbl_filter_echo_enable = False
return True
def get_auto_response_timeout(self):
response = self._execute_command(ID_USBL_CONTROL, type_val=TYPE_GETTING, version_val=3, request_resp_flag=False)
logger.warning("Toto="+str(response))
return self._parse_usbl_control_get_timeout_payload(response) if response else None
def get_auto_response_filter(self):
response = self._execute_command(ID_USBL_CONTROL, type_val=TYPE_GETTING, version_val=4, request_resp_flag=False)
return self._parse_usbl_control_get_filter_payload(response) if response else None
def get_auto_response_payload(self):
response = self._execute_command(ID_USBL_CONTROL, type_val=TYPE_GETTING, version_val=5, request_resp_flag=False)
return self._parse_usbl_control_get_payload_payload(response) if response else None
# --- Get variables ---
def get_usbl_data(self):
return self.usbl_data
def get_usbl_distance_calc(self, depth=0):
if depth==0:
logger.warning("depth="+str(depth)+", can't calculate distance")
return -1
if "elevation_deg" not in self.usbl_data:
logger.warning("No usbl receive, can't calculate distance")
return -1
else:
# Formula is depth/cos((90-elevation_deg)*pi/380)
return depth/math.cos((90-30)*3.14159265/180)
if __name__ == '__main__':
# This block is for basic testing or direct execution of this file.
# For more comprehensive testing, use a separate test script.
# Setup logging to DEBUG level when running file directly
setup_logging(level="DEBUG")
logger.info("Kogger SBP Driver module loaded. This can be imported by other scripts.")
logger.info(f"Key Confirm Constant: {KEY_CONFIRM:#0X}")
# Example: Test checksum calculation
# dev = KoggerSBPDevice(port=None) # For internal method access
# chk_data = bytes([0x00, 0x01, 0x01, 0x04, 0x39, 0x30, 0x00, 0x00])
# c1, c2 = dev._calculate_fletcher16(chk_data)
# logger.debug(f"Checksum for {chk_data.hex()}: C1={c1:#02x}, C2={c2:#02x} (Expected 0x49, 0x95 for frame1 example)")