# core/connection.py import time import os import struct import binascii PROTOCOL_ERROR_MESSAGES = { 0x01: "Ungültiger Befehl.", 0x02: "Ungültige Parameter.", 0x03: "Befehl oder Parameter sind zu lang.", 0x10: "Datei oder Verzeichnis wurde nicht gefunden.", 0x11: "Ziel existiert bereits.", 0x12: "Pfad ist kein Verzeichnis.", 0x13: "Pfad ist ein Verzeichnis.", 0x14: "Zugriff verweigert.", 0x15: "Kein freier Speicher mehr vorhanden.", 0x16: "Datei ist zu groß.", 0x20: "Allgemeiner Ein-/Ausgabefehler auf dem Gerät.", 0x21: "Zeitüberschreitung auf dem Gerät.", 0x22: "CRC-Prüfung fehlgeschlagen (Daten beschädigt).", 0x23: "Übertragung wurde vom Gerät abgebrochen.", 0x30: "Befehl wird vom Gerät nicht unterstützt.", 0x31: "Gerät ist beschäftigt.", 0x32: "Interner Gerätefehler.", } SYNC = b"BUZZ" HEADER_SIZE = 14 DEFAULT_MAX_PATH_LEN = 32 FRAME_REQ = 0x01 FRAME_RESP_ACK = 0x10 FRAME_RESP_DATA = 0x11 FRAME_RESP_STREAM_START = 0x12 FRAME_RESP_STREAM_CHUNK = 0x13 FRAME_RESP_STREAM_END = 0x14 FRAME_RESP_ERROR = 0x7F POLL_SLEEP_SECONDS = 0.002 CMD_GET_PROTOCOL_VERSION = 0x00 CMD_GET_FIRMWARE_STATUS = 0x01 CMD_GET_FLASH_STATUS = 0x02 CMD_CONFIRM_FIRMWARE = 0x03 CMD_REBOOT = 0x04 CMD_LIST_DIR = 0x10 CMD_CHECK_FILE_CRC = 0x11 CMD_MKDIR = 0x12 CMD_RM = 0x13 CMD_PUT_FILE_START = 0x14 CMD_PUT_FILE_CHUNK = 0x15 CMD_PUT_FILE_END = 0x16 CMD_PUT_FW_START = 0x17 CMD_STAT = 0x18 CMD_RENAME = 0x19 CMD_RM_R = 0x1A CMD_GET_FILE = 0x1B CMD_GET_TAG_BLOB = 0x20 CMD_SET_TAG_BLOB_START = 0x21 CMD_SET_TAG_BLOB_CHUNK = 0x22 CMD_SET_TAG_BLOB_END = 0x23 class BuzzerError(Exception): pass class BuzzerConnection: def __init__(self, config): self.port = config.get("port") self.baudrate = config.get("baudrate", 115200) self.timeout = config.get("timeout", 5.0) self.crc_timeout_min_seconds = float(config.get("crc_timeout_min_seconds", 2.0)) self.crc_timeout_ms_per_100kb = float(config.get("crc_timeout_ms_per_100kb", 1.5)) self.serial = None self._sequence = 0 self._max_path_len = DEFAULT_MAX_PATH_LEN def __enter__(self): if not self.port: raise ValueError("Kein serieller Port konfiguriert.") try: import serial except ImportError as e: raise BuzzerError("PySerial ist nicht installiert. Bitte 'pip install -r requirements.txt' ausführen.") from e # write_timeout verhindert endloses Blockieren auf inaktiven Ports self.serial = serial.Serial( port=self.port, baudrate=self.baudrate, timeout=self.timeout, write_timeout=self.timeout ) self.serial.reset_input_buffer() return self def __exit__(self, exc_type, exc_val, exc_tb): if self.serial and self.serial.is_open: self.serial.close() def _parse_controller_error(self, line: str) -> str: code_str = line.split(" ", 1)[1].strip() if " " in line else "" try: code = int(code_str, 10) except ValueError: return f"Controller meldet einen unbekannten Fehler: '{line}'" message = PROTOCOL_ERROR_MESSAGES.get(code, "Unbekannter Fehlercode vom Gerät.") return f"Controller-Fehler {code} (0x{code:02X}): {message}" def _parse_controller_error_code(self, code: int) -> str: message = PROTOCOL_ERROR_MESSAGES.get(code, "Unbekannter Fehlercode vom Gerät.") return f"Controller-Fehler {code} (0x{code:02X}): {message}" def _raise_error_from_payload(self, payload: bytes) -> None: error_code = payload[0] if len(payload) >= 1 else 0x32 detail = "" if len(payload) >= 2: detail_len = payload[1] if detail_len > 0 and len(payload) >= 2 + detail_len: detail = payload[2:2 + detail_len].decode("utf-8", errors="replace") msg = self._parse_controller_error_code(error_code) if detail: msg = f"{msg} Detail: {detail}" raise BuzzerError(msg) def _next_sequence(self) -> int: seq = self._sequence self._sequence = (self._sequence + 1) & 0xFFFF return seq def _crc16_ccitt_false(self, data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 0x0001: crc = ((crc >> 1) ^ 0x8408) & 0xFFFF else: crc = (crc >> 1) & 0xFFFF return crc def _read_exact(self, size: int, timeout: float) -> bytes: deadline = time.monotonic() + timeout chunks = bytearray() while len(chunks) < size: remaining_time = deadline - time.monotonic() if remaining_time <= 0: raise TimeoutError(f"Lese-Timeout beim Warten auf {size} Bytes.") old_timeout = self.serial.timeout self.serial.timeout = min(remaining_time, 0.25) try: chunk = self.serial.read(size - len(chunks)) finally: self.serial.timeout = old_timeout if chunk: chunks.extend(chunk) return bytes(chunks) def _build_frame(self, frame_type: int, command_id: int, sequence: int, payload: bytes) -> bytes: payload = payload or b"" payload_len = len(payload) header_no_sync_crc = struct.pack(" None: try: self.serial.write(frame) self.serial.flush() except Exception as e: if e.__class__.__name__ == "SerialTimeoutException": raise TimeoutError(f"Schreib-Timeout am Port {self.port}. Ist das Gerät blockiert?") from e raise def _send_binary_frame_no_wait(self, command_id: int, payload: bytes = b"") -> int: if self.serial is None: raise BuzzerError("Serielle Verbindung ist nicht geöffnet.") sequence = self._next_sequence() frame = self._build_frame(FRAME_REQ, command_id, sequence, payload) self._write_frame(frame) return sequence def _read_frame(self, timeout: float = None) -> dict: eff_timeout = timeout if timeout is not None else self.timeout deadline = time.monotonic() + eff_timeout sync_idx = 0 while sync_idx < len(SYNC): remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("Timeout beim Warten auf Sync 'BUZZ'.") b = self._read_exact(1, remaining) if b[0] == SYNC[sync_idx]: sync_idx += 1 else: sync_idx = 1 if b[0] == SYNC[0] else 0 remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("Timeout beim Lesen des Frame-Headers.") rest_header = self._read_exact(HEADER_SIZE - len(SYNC), remaining) frame_type, command_id, sequence, payload_len, rx_header_crc = struct.unpack(" bytes: if self.serial is None: raise BuzzerError("Serielle Verbindung ist nicht geöffnet.") eff_timeout = timeout if timeout is not None else self.timeout self.serial.reset_input_buffer() sequence = self._next_sequence() frame = self._build_frame(FRAME_REQ, command_id, sequence, payload) self._write_frame(frame) response = self._read_frame(timeout=eff_timeout) if response["sequence"] != sequence: raise BuzzerError( f"Antwort-Sequenz passt nicht: erwartet {sequence}, erhalten {response['sequence']}" ) if response["command_id"] != command_id: raise BuzzerError( f"Antwort-Kommando passt nicht: erwartet 0x{command_id:02X}, erhalten 0x{response['command_id']:02X}" ) if response["frame_type"] == FRAME_RESP_ERROR: self._raise_error_from_payload(response["payload"]) if response["frame_type"] not in (FRAME_RESP_ACK, FRAME_RESP_DATA): raise BuzzerError(f"Unerwarteter Response-Typ: 0x{response['frame_type']:02X}") return response["payload"] def get_protocol_version(self, timeout: float = None) -> int: payload = self.send_binary_command(CMD_GET_PROTOCOL_VERSION, b"", timeout=timeout) if len(payload) != 2: raise BuzzerError(f"Ungültige Antwortlänge für GET_PROTOCOL_VERSION: {len(payload)}") return struct.unpack(" tuple[int, str]: payload = self.send_binary_command(CMD_GET_FIRMWARE_STATUS, b"", timeout=timeout) if len(payload) < 2: raise BuzzerError("Ungültige Antwort für GET_FIRMWARE_STATUS: zu kurz") status = payload[0] version_len = payload[1] if len(payload) != 2 + version_len: raise BuzzerError( f"Ungültige Antwort für GET_FIRMWARE_STATUS: erwartete Länge {2 + version_len}, erhalten {len(payload)}" ) version = payload[2:2 + version_len].decode("utf-8", errors="replace") return status, version def get_flash_status(self, timeout: float = None) -> dict: payload = self.send_binary_command(CMD_GET_FLASH_STATUS, b"", timeout=timeout) if len(payload) != 16: raise BuzzerError(f"Ungültige Antwortlänge für GET_FLASH_STATUS: {len(payload)}") block_size, total_blocks, free_blocks, path_max_len = struct.unpack(" 0: self._max_path_len = int(path_max_len) return { "block_size": block_size, "total_blocks": total_blocks, "free_blocks": free_blocks, "path_max_len": path_max_len, } def confirm_firmware(self, timeout: float = None) -> None: payload = self.send_binary_command(CMD_CONFIRM_FIRMWARE, b"", timeout=timeout) if len(payload) != 0: raise BuzzerError(f"Unerwartete Payload für CONFIRM_FIRMWARE: {len(payload)} Bytes") def reboot_device(self, timeout: float = None) -> None: payload = self.send_binary_command(CMD_REBOOT, b"", timeout=timeout) if len(payload) != 0: raise BuzzerError(f"Unerwartete Payload für REBOOT: {len(payload)} Bytes") def _encode_path_payload(self, path: str) -> bytes: path_bytes = path.encode("utf-8") if len(path_bytes) == 0: raise BuzzerError("Pfad darf nicht leer sein.") max_path_len = min(self._max_path_len, 255) if len(path_bytes) > max_path_len: raise BuzzerError(f"Pfad ist zu lang (max. {max_path_len} Bytes).") return bytes([len(path_bytes)]) + path_bytes def list_directory(self, path: str, timeout: float = None) -> list[str]: if self.serial is None: raise BuzzerError("Serielle Verbindung ist nicht geöffnet.") eff_timeout = timeout if timeout is not None else self.timeout self.serial.reset_input_buffer() sequence = self._next_sequence() frame = self._build_frame(FRAME_REQ, CMD_LIST_DIR, sequence, self._encode_path_payload(path)) try: self.serial.write(frame) self.serial.flush() except Exception as e: if e.__class__.__name__ == "SerialTimeoutException": raise TimeoutError(f"Schreib-Timeout am Port {self.port}. Ist das Gerät blockiert?") from e raise lines = [] stream_started = False while True: response = self._read_frame(timeout=eff_timeout) if response["sequence"] != sequence: raise BuzzerError( f"Antwort-Sequenz passt nicht: erwartet {sequence}, erhalten {response['sequence']}" ) if response["command_id"] != CMD_LIST_DIR: raise BuzzerError( f"Antwort-Kommando passt nicht: erwartet 0x{CMD_LIST_DIR:02X}, erhalten 0x{response['command_id']:02X}" ) frame_type = response["frame_type"] payload = response["payload"] if frame_type == FRAME_RESP_ERROR: error_code = payload[0] if len(payload) >= 1 else 0x32 detail = "" if len(payload) >= 2: detail_len = payload[1] if detail_len > 0 and len(payload) >= 2 + detail_len: detail = payload[2:2 + detail_len].decode("utf-8", errors="replace") msg = self._parse_controller_error_code(error_code) if detail: msg = f"{msg} Detail: {detail}" raise BuzzerError(msg) if frame_type == FRAME_RESP_STREAM_START: stream_started = True continue if frame_type == FRAME_RESP_STREAM_CHUNK: if len(payload) < 6: raise BuzzerError("Ungültiger LIST_DIR Chunk: zu kurz") entry_type = payload[0] name_len = payload[1] if len(payload) != 6 + name_len: raise BuzzerError("Ungültiger LIST_DIR Chunk: inkonsistente Namenslänge") size = struct.unpack(" int: payload = self.send_binary_command(CMD_CHECK_FILE_CRC, self._encode_path_payload(path), timeout=timeout) if len(payload) != 4: raise BuzzerError(f"Ungültige Antwortlänge für CHECK_FILE_CRC: {len(payload)}") return struct.unpack(" None: payload = self.send_binary_command(CMD_MKDIR, self._encode_path_payload(path), timeout=timeout) if len(payload) != 0: raise BuzzerError(f"Unerwartete Payload für MKDIR: {len(payload)} Bytes") def rm(self, path: str, timeout: float = None) -> None: payload = self.send_binary_command(CMD_RM, self._encode_path_payload(path), timeout=timeout) if len(payload) != 0: raise BuzzerError(f"Unerwartete Payload für RM: {len(payload)} Bytes") def stat(self, path: str, timeout: float = None) -> dict: payload = self.send_binary_command(CMD_STAT, self._encode_path_payload(path), timeout=timeout) if len(payload) != 5: raise BuzzerError(f"Ungültige Antwortlänge für STAT: {len(payload)}") entry_type = payload[0] if entry_type == 0: type_char = "F" elif entry_type == 1: type_char = "D" else: raise BuzzerError(f"Ungültiger STAT entry_type: {entry_type}") size = struct.unpack(" None: old_payload = self._encode_path_payload(old_path) new_payload = self._encode_path_payload(new_path) payload = old_payload + new_payload response = self.send_binary_command(CMD_RENAME, payload, timeout=timeout) if len(response) != 0: raise BuzzerError(f"Unerwartete Payload für RENAME: {len(response)} Bytes") def rm_recursive(self, path: str, timeout: float = None) -> None: payload = self.send_binary_command(CMD_RM_R, self._encode_path_payload(path), timeout=timeout) if len(payload) != 0: raise BuzzerError(f"Unerwartete Payload für RM_R: {len(payload)} Bytes") def get_file_data(self, path: str, timeout: float = None, progress_callback=None) -> bytes: if self.serial is None: raise BuzzerError("Serielle Verbindung ist nicht geöffnet.") eff_timeout = timeout if timeout is not None else self.timeout self.serial.reset_input_buffer() sequence = self._next_sequence() frame = self._build_frame(FRAME_REQ, CMD_GET_FILE, sequence, self._encode_path_payload(path)) self._write_frame(frame) start_response = self._read_frame(timeout=eff_timeout) if start_response["sequence"] != sequence: raise BuzzerError( f"Antwort-Sequenz passt nicht: erwartet {sequence}, erhalten {start_response['sequence']}" ) if start_response["command_id"] != CMD_GET_FILE: raise BuzzerError( f"Antwort-Kommando passt nicht: erwartet 0x{CMD_GET_FILE:02X}, erhalten 0x{start_response['command_id']:02X}" ) if start_response["frame_type"] == FRAME_RESP_ERROR: self._raise_error_from_payload(start_response["payload"]) if start_response["frame_type"] != FRAME_RESP_DATA or len(start_response["payload"]) != 4: raise BuzzerError("Ungültige GET_FILE-Startantwort (erwartet DATA mit 4 Byte Länge)") expected_len = struct.unpack(" None: if total_len < 0: raise BuzzerError("Dateigröße darf nicht negativ sein.") payload = self._encode_path_payload(path) + struct.pack(" None: self._send_binary_frame_no_wait(CMD_PUT_FILE_CHUNK, chunk) def put_file_end(self, timeout: float = None) -> None: eff_timeout = timeout if timeout is not None else self.timeout end_sequence = self._send_binary_frame_no_wait(CMD_PUT_FILE_END, b"") deadline = time.monotonic() + eff_timeout while True: remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("Lese-Timeout beim Warten auf PUT_FILE_END Antwort.") response = self._read_frame(timeout=remaining) if response["frame_type"] == FRAME_RESP_ERROR and response["command_id"] in ( CMD_PUT_FILE_START, CMD_PUT_FILE_CHUNK, CMD_PUT_FILE_END, ): self._raise_error_from_payload(response["payload"]) if response["command_id"] != CMD_PUT_FILE_END or response["sequence"] != end_sequence: continue if response["frame_type"] not in (FRAME_RESP_ACK, FRAME_RESP_DATA): raise BuzzerError(f"Unerwarteter Response-Typ für PUT_FILE_END: 0x{response['frame_type']:02X}") if len(response["payload"]) != 0: raise BuzzerError(f"Unerwartete Payload für PUT_FILE_END: {len(response['payload'])} Bytes") return def put_file_data( self, path: str, data: bytes, timeout: float = None, chunk_size: int = 4096, progress_callback=None, ) -> int: if data is None: data = b"" if chunk_size <= 0: raise BuzzerError("chunk_size muss größer als 0 sein.") expected_crc32 = binascii.crc32(data) & 0xFFFFFFFF if self.serial is None: raise BuzzerError("Serielle Verbindung ist nicht geöffnet.") eff_timeout = timeout if timeout is not None else self.timeout self.serial.reset_input_buffer() sequence = self._next_sequence() start_payload = self._encode_path_payload(path) + struct.pack(" int: if data is None: data = b"" if chunk_size <= 0: raise BuzzerError("chunk_size muss größer als 0 sein.") if len(data) == 0: raise BuzzerError("Firmware-Datei ist leer.") expected_crc32 = binascii.crc32(data) & 0xFFFFFFFF if self.serial is None: raise BuzzerError("Serielle Verbindung ist nicht geöffnet.") eff_timeout = timeout if timeout is not None else self.timeout old_write_timeout = self.serial.write_timeout self.serial.write_timeout = max(float(old_write_timeout or 0.0), float(eff_timeout)) self.serial.reset_input_buffer() try: sequence = self._next_sequence() start_payload = struct.pack(" bytes: if self.serial is None: raise BuzzerError("Serielle Verbindung ist nicht geöffnet.") eff_timeout = timeout if timeout is not None else self.timeout self.serial.reset_input_buffer() sequence = self._next_sequence() frame = self._build_frame(FRAME_REQ, CMD_GET_TAG_BLOB, sequence, self._encode_path_payload(path)) try: self.serial.write(frame) self.serial.flush() except Exception as e: if e.__class__.__name__ == "SerialTimeoutException": raise TimeoutError(f"Schreib-Timeout am Port {self.port}. Ist das Gerät blockiert?") from e raise expected_len = None chunks = bytearray() while True: response = self._read_frame(timeout=eff_timeout) if response["sequence"] != sequence: raise BuzzerError( f"Antwort-Sequenz passt nicht: erwartet {sequence}, erhalten {response['sequence']}" ) if response["command_id"] != CMD_GET_TAG_BLOB: raise BuzzerError( f"Antwort-Kommando passt nicht: erwartet 0x{CMD_GET_TAG_BLOB:02X}, erhalten 0x{response['command_id']:02X}" ) frame_type = response["frame_type"] payload = response["payload"] if frame_type == FRAME_RESP_ERROR: error_code = payload[0] if len(payload) >= 1 else 0x32 raise BuzzerError(self._parse_controller_error_code(error_code)) if frame_type == FRAME_RESP_STREAM_START: if len(payload) != 4: raise BuzzerError("Ungültiger GET_TAG_BLOB START-Frame") expected_len = struct.unpack(" None: if blob is None: blob = b"" if len(blob) > 1024: raise BuzzerError("Tag-Blob ist zu groß (max. 1024 Bytes).") path_payload = self._encode_path_payload(path) start_payload = path_payload + struct.pack(" list: eff_timeout = custom_timeout if custom_timeout is not None else self.timeout self.serial.reset_input_buffer() try: self.serial.write(f"{command}\n".encode('utf-8')) self.serial.flush() except Exception as e: if e.__class__.__name__ == "SerialTimeoutException": raise TimeoutError(f"Schreib-Timeout am Port {self.port}. Ist das Gerät blockiert?") from e raise lines = [] start_time = time.monotonic() while (time.monotonic() - start_time) < eff_timeout: if self.serial.in_waiting > 0: try: line = self.serial.readline().decode('utf-8', errors='ignore').strip() if not line: continue if line == "OK": return lines elif line.startswith("ERR"): raise BuzzerError(self._parse_controller_error(line)) else: lines.append(line) except BuzzerError: raise except Exception as e: raise BuzzerError(f"Fehler beim Lesen der Antwort: {e}") else: time.sleep(POLL_SLEEP_SECONDS) raise TimeoutError(f"Lese-Timeout ({eff_timeout}s) beim Warten auf Antwort für: '{command}'") def send_binary(self, filepath: str, chunk_size: int = 4096, timeout: float = 10.0, progress_callback=None): """ Überträgt eine Binärdatei in Chunks, nachdem das READY-Signal empfangen wurde. """ # 1. Warte auf die READY-Bestätigung vom Controller start_time = time.time() ready = False while (time.time() - start_time) < timeout: if self.serial.in_waiting > 0: line = self.serial.readline().decode('utf-8', errors='ignore').strip() if line == "READY": ready = True break elif line.startswith("ERR"): raise BuzzerError(f"Fehler vor Binärtransfer: {self._parse_controller_error(line)}") time.sleep(POLL_SLEEP_SECONDS) if not ready: raise TimeoutError("Kein READY-Signal vom Controller empfangen.") # 2. Sende die Datei in Blöcken file_size = os.path.getsize(filepath) bytes_sent = 0 with open(filepath, 'rb') as f: while bytes_sent < file_size: # 1. Nicht blockierende Fehlerprüfung vor jedem Chunk if self.serial.in_waiting > 0: line = self.serial.readline().decode('utf-8', errors='ignore').strip() if line.startswith("ERR"): raise BuzzerError(f"Controller hat Transfer abgebrochen: {self._parse_controller_error(line)}") # 2. Chunk lesen und schreiben chunk = f.read(chunk_size) if not chunk: break self.serial.write(chunk) # WICHTIG: self.serial.flush() hier entfernen. # Dies verhindert den Deadlock mit dem OS-USB-Puffer. bytes_sent += len(chunk) # 3. Callback für UI if progress_callback: progress_callback(len(chunk)) # 3. Warte auf das finale OK (oder ERR bei CRC/Schreib-Fehlern) start_time = time.time() while (time.time() - start_time) < timeout: if self.serial.in_waiting > 0: line = self.serial.readline().decode('utf-8', errors='ignore').strip() if line == "OK": return True elif line.startswith("ERR"): raise BuzzerError(f"Fehler beim Speichern der Binärdatei: {self._parse_controller_error(line)}") time.sleep(POLL_SLEEP_SECONDS) raise TimeoutError("Zeitüberschreitung nach Binärtransfer (kein OK empfangen).")