#! /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('