#! /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="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}")
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('> 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(' 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)= __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('> 3) & 0x07
if resp_version == 0 and frame['length'] == 4:
distance = struct.unpack('= 6:
try:
seq_offset, res_mm, abs_offset = struct.unpack_from('> 3) & 0x07
if resp_version == 0 and frame['length'] == 6:
yaw, pitch, roll = struct.unpack('> 3) & 0x07
if resp_version == 1 and frame['length'] == 8:
start_offset, max_dist = struct.unpack('> 3) & 0x07
payload = frame['payload']
if resp_version == 0 and frame['length'] == 9:
key, uart_id, baud = struct.unpack('> 3) & 0x07
if resp_version==0 and frame['length'] == 34:
try:
data = struct.unpack('> 3) & 0x07) == 2:
try:
data = struct.unpack('> 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(' 126: raise ValueError("Chunk size exceeds 126 bytes.")
if packet_number < 1: raise ValueError("Packet number must be >= 1.")
payload = struct.pack(' 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(' 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('