This commit is contained in:
2026-03-07 08:51:50 +01:00
parent 4f3fbff258
commit f85143d7e5
60 changed files with 3245 additions and 1205 deletions

36
tool/core/cmd/crc32.py Normal file
View File

@@ -0,0 +1,36 @@
# tool/core/cmd/crc32.py
import struct
from core.utils import console, console_err
from core.protocol import COMMANDS, ERRORS
class crc32:
def __init__(self, bus):
self.bus = bus
def get(self, path: str):
path_bytes = path.encode('utf-8')
payload = struct.pack('B', len(path_bytes)) + path_bytes
self.bus.send_request(COMMANDS['crc_32'], payload)
# 1 Byte Type + 4 Byte Size = 5
data = self.bus.receive_response(length=8, timeout=5)
if not data or data.get('type') == 'error':
return None
payload = data['data']
crc_value = struct.unpack('<I', payload[0:4])[0]
audio_crc_value = struct.unpack('<I', payload[4:8])[0]
result = {
'crc32': crc_value,
'audio_crc32': audio_crc_value
}
return result
def print(self, result, path: str):
if not result:
return
console.print(f"[info_title]CRC32[/info_title] für [info]{path}[/info]:")
console.print(f" • CRC32 Datei: [info]{result['crc32']:08X}[/info]")
console.print(f" • CRC32 Audio: [info]{result['audio_crc32']:08X}[/info]")

View File

@@ -0,0 +1,23 @@
from core.utils import console, console_err
from core.protocol import COMMANDS
class fw_confirm:
def __init__(self, bus):
self.bus = bus
def get(self):
# Fehler 1: Der Key in COMMANDS heißt 'confirm_fw'
self.bus.send_request(COMMANDS['confirm_fw'])
# Fehler 2: Try-Except entfernt, damit der ControllerError (z.B. bei nicht-pending Image)
# sauber nach buzz.py durchschlägt.
data = self.bus.receive_ack()
return data is not None and data.get('type') == 'ack'
def print(self, result):
if result:
console.print("✓ Laufende Firmware wurde [info]erfolgreich bestätigt[/info] (Permanent).")
else:
# Wird im Fehlerfall eigentlich nicht mehr erreicht, da buzz.py abbricht,
# bleibt aber als Fallback für leere Antworten.
console_err.print("❌ Fehler beim Bestätigen der Firmware.")

View File

@@ -2,6 +2,7 @@
import struct
import zlib
from pathlib import Path
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn
from core.utils import console, console_err
from core.protocol import COMMANDS
@@ -21,24 +22,34 @@ class get_file:
source_path_bytes = source_path.encode('utf-8')
payload = struct.pack('B', len(source_path_bytes)) + source_path_bytes
device_file_crc = None
try:
self.bus.send_request(COMMANDS['crc_32'], payload)
crc_resp = self.bus.receive_response(length=4)
if crc_resp and crc_resp.get('type') == 'response':
device_file_crc = struct.unpack('<I', crc_resp['data'])[0]
except Exception:
device_file_crc = None
self.bus.send_request(COMMANDS['get_file'], payload)
stream_res = self.bus.receive_stream()
# Fortschrittsbalken Setup
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
DownloadColumn(),
TransferSpeedColumn(),
"",
TimeRemainingColumn(),
console=console,
transient=False
) as progress:
task = progress.add_task(f"Lade {source_path}...", total=None)
def update_bar(received, total):
progress.update(task, total=total, completed=received)
stream_res = self.bus.receive_stream(progress_callback=update_bar)
if not stream_res or stream_res.get('type') == 'error':
return None
file_data = stream_res['data']
remote_crc = stream_res['crc32']
remote_crc = stream_res.get('crc32')
local_crc = zlib.crc32(file_data) & 0xFFFFFFFF
duratuion = stream_res.get('duration')
if local_crc == remote_crc:
with open(p, 'wb') as f:
@@ -55,8 +66,8 @@ class get_file:
'dest_path': dest_path,
'crc32_remote': remote_crc,
'crc32_local': local_crc,
'crc32_device_file': device_file_crc,
'size': len(file_data)
'size': len(file_data),
'duration': duratuion
}
def print(self, result):
@@ -73,4 +84,6 @@ class get_file:
console.print(f" • Local CRC: [info]{result['crc32_local']:08X}[/info]")
if result.get('crc32_device_file') is not None:
console.print(f" • Device CRC: [info]{result['crc32_device_file']:08X}[/info]")
console.print(f" • Zielpfad: [info]{result['dest_path']}[/info]")
console.print(f" • Zielpfad: [info]{result['dest_path']}[/info]")
if result.get('duration') is not None and result.get('duration') > 0:
console.print(f" • Dauer: [info]{result['duration']:.2f} s[/info]")

View File

@@ -0,0 +1,37 @@
import struct
from core.utils import console
from core.protocol import COMMANDS
class get_setting:
def __init__(self, bus):
self.bus = bus
def get(self, key: str):
key_bytes = key.encode('utf-8')
payload = struct.pack('B', len(key_bytes)) + key_bytes
self.bus.send_request(COMMANDS['get_setting'], payload)
# varlen_params=1 liest exakt 1 Byte Länge + entsprechend viele Datenbytes
data = self.bus.receive_response(length=0, varlen_params=1)
if not data or data.get('type') == 'error':
return None
raw = data['data']
val_len = raw[0]
val_buf = raw[1:1+val_len]
# Binärdaten zurück in Python-Typen parsen
if key == "audio/vol" and val_len == 1:
return struct.unpack('<B', val_buf)[0]
elif key == "play/norepeat" and val_len == 1:
return bool(struct.unpack('<B', val_buf)[0])
elif key == "settings/storage_interval" and val_len == 2:
return struct.unpack('<H', val_buf)[0]
else:
return None
def print(self, result, key: str):
if result is not None:
console.print(f"⚙️ [info]{key}[/info] = [info]{result}[/info]")

43
tool/core/cmd/get_tags.py Normal file
View File

@@ -0,0 +1,43 @@
# tool/core/cmd/get_tags.py
import struct
import json
from core.utils import console
from core.protocol import COMMANDS
from core.tag import TagManager
class get_tags:
def __init__(self, bus):
self.bus = bus
def get_raw_tlvs(self, path: str):
"""Holt die rohen TLVs vom Gerät."""
path_bytes = path.encode('utf-8')
payload = struct.pack('B', len(path_bytes)) + path_bytes
self.bus.send_request(COMMANDS['get_tags'], payload)
stream_res = self.bus.receive_stream()
if not stream_res or stream_res.get('type') == 'error': return []
return TagManager.parse_tlvs(stream_res['data'])
def get(self, path: str):
tlvs = self.get_raw_tlvs(path)
result = {"system": {}, "json": {}}
for tlv in tlvs:
if tlv['type'] == 0x00:
if tlv['index'] == 0x00 and len(tlv['value']) == 8:
codec, bit_depth, _, samplerate = struct.unpack('<BBHI', tlv['value'])
result["system"]["format"] = {"codec": codec, "bit_depth": bit_depth, "samplerate": samplerate}
elif tlv['index'] == 0x01 and len(tlv['value']) == 4:
result["system"]["crc32"] = f"0x{struct.unpack('<I', tlv['value'])[0]:08X}"
elif tlv['type'] == 0x10:
try:
result["json"].update(json.loads(tlv['value'].decode('utf-8')))
except:
pass
return result
def print(self, result, path: str):
console.print(f"[info]Metadaten[/info] für [info]{path}[/info]:")
console.print(json.dumps(result, indent=2, ensure_ascii=False))

23
tool/core/cmd/play.py Normal file
View File

@@ -0,0 +1,23 @@
import struct
from core.utils import console, console_err
from core.protocol import COMMANDS
class play:
def __init__(self, bus):
self.bus = bus
def get(self, path: str, interrupt: bool):
flags = 0x01 if interrupt else 0x00
path_bytes = path.encode('utf-8')
# Payload: [1 Byte Flags] + [1 Byte Path Length] + [Path String]
payload = struct.pack('B', flags) + struct.pack('B', len(path_bytes)) + path_bytes
self.bus.send_request(COMMANDS['play'], payload)
data = self.bus.receive_ack()
return data is not None and data.get('type') == 'ack'
def print(self, result, path: str, interrupt: bool):
if result:
mode = "sofort (Interrupt)" if interrupt else "in die Warteschlange (Queue)"
console.print(f"▶ Wiedergabe von [info]{path}[/info] {mode} eingereiht.")

100
tool/core/cmd/put_file.py Normal file
View File

@@ -0,0 +1,100 @@
# tool/core/cmd/put_file.py
import struct
import zlib
from pathlib import Path
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn
from core.utils import console, console_err
from core.protocol import COMMANDS
from core.tag import TagManager
from core.cmd.put_tags import put_tags
class put_file:
def __init__(self, bus):
self.bus = bus
def get(self, source_path: str, dest_path: str, cli_tags_json: str = None):
try:
p = Path(source_path)
if not p.exists() or not p.is_file():
console_err.print(f"Fehler: Quelldatei existiert nicht: {source_path}")
return None
with open(p, 'rb') as f:
file_data = f.read()
except Exception as e:
console_err.print(f"Fehler beim Lesen: {e}")
return None
# 1. Lokale Tags abtrennen
audio_data, local_tlvs = TagManager.split_file(file_data)
audio_size = len(audio_data)
# 2. Upload der REINEN Audiodaten
dest_path_bytes = dest_path.encode('utf-8')
payload = struct.pack('B', len(dest_path_bytes)) + dest_path_bytes + struct.pack('<I', audio_size)
self.bus.send_request(COMMANDS['put_file'], payload)
self.bus.receive_ack(timeout=5.0)
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), DownloadColumn(), TransferSpeedColumn(), "", TimeRemainingColumn(), console=console, transient=False) as progress:
task = progress.add_task(f"Sende {source_path}...", total=audio_size)
stream_res = self.bus.send_stream(audio_data, progress_callback=lambda sent, total: progress.update(task, total=total, completed=sent))
if not stream_res: return None
remote_crc = stream_res.get('crc32')
local_crc = zlib.crc32(audio_data) & 0xFFFFFFFF
if local_crc != remote_crc:
return {'success': False, 'source_path': source_path, 'crc32_remote': remote_crc, 'crc32_local': local_crc}
# 3. Tags aktualisieren (CRC32 + evtl. CLI-Tags)
# Alten CRC-Tag entfernen, neuen einsetzen
final_tlvs = [t for t in local_tlvs if not (t['type'] == 0x00 and t['index'] == 0x01)]
final_tlvs.append({'type': 0x00, 'index': 0x01, 'value': struct.pack('<I', local_crc)})
# Falls CLI-Tags übergeben wurden (-t), diese priorisiert anwenden
if cli_tags_json:
try:
cli_tlvs = TagManager.parse_cli_json(cli_tags_json)
# Bestehendes JSON löschen, wenn neues im CLI-Input definiert ist
if any(t['type'] == 0x10 for t in cli_tlvs):
final_tlvs = [t for t in final_tlvs if t['type'] != 0x10]
final_tlvs.extend(cli_tlvs)
except ValueError as e:
console_err.print(f"[warning]Warnung: Tags konnten nicht geparst werden ({e}). Datei wurde ohne extra Tags hochgeladen.[/warning]")
# 4. Tags via separatem Befehl anhängen
tag_cmd = put_tags(self.bus)
tag_blob = TagManager.build_blob(final_tlvs)
tag_cmd.send_blob(dest_path, tag_blob)
return {
'success': True,
'source_path': source_path,
'dest_path': dest_path,
'crc32_remote': remote_crc,
'crc32_local': local_crc,
'size': audio_size,
'duration': stream_res.get('duration')
}
def print(self, result):
if not result:
return
if result.get('success'):
console.print(f"✓ Datei [info]{result['source_path']}[/info] erfolgreich hochgeladen und Tags generiert.")
console.print(f" • Größe: [info]{result['size'] / 1024:.2f} KB[/info]")
else:
console_err.print(f"❌ CRC-FEHLER: Datei [error]{result['source_path']}[/error] wurde auf dem Gerät korrumpiert!")
if 'crc32_remote' in result:
console.print(f" • Remote CRC: [info]{result['crc32_remote']:08X}[/info]")
if 'crc32_local' in result:
console.print(f" • Local CRC: [info]{result['crc32_local']:08X}[/info]")
if 'dest_path' in result:
console.print(f" • Zielpfad: [info]{result['dest_path']}[/info]")
if result.get('duration') is not None and result.get('duration') > 0:
console.print(f" • Dauer: [info]{result['duration']:.2f} s[/info]")

89
tool/core/cmd/put_fw.py Normal file
View File

@@ -0,0 +1,89 @@
import struct
import zlib
from pathlib import Path
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, DownloadColumn, TransferSpeedColumn, TimeRemainingColumn
from core.utils import console, console_err
from core.protocol import COMMANDS
class put_fw:
def __init__(self, bus):
self.bus = bus
def get(self, file_path: str):
try:
p = Path(file_path)
if not p.exists() or not p.is_file():
console_err.print(f"Fehler: Firmware-Datei existiert nicht: {file_path}")
return None
file_size = p.stat().st_size
with open(p, 'rb') as f:
file_data = f.read()
except Exception as e:
console_err.print(f"Lese-Fehler: {e}")
return None
# 1. Schritt: Löschvorgang mit minimalem Feedback
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
console=console,
transient=True
) as progress:
erase_task = progress.add_task("Lösche Firmware Slot...", total=None)
payload = struct.pack('<I', file_size)
self.bus.send_request(COMMANDS['put_fw'], payload)
# Warten auf ACK (Balken pulsiert ohne Byte-Anzeige)
self.bus.receive_ack(timeout=10.0)
progress.update(erase_task, description="✓ Slot gelöscht", completed=100, total=100)
# 2. Schritt: Eigentlicher Transfer mit allen Metriken (Bytes, Speed, Time)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
DownloadColumn(),
TransferSpeedColumn(),
"",
TimeRemainingColumn(),
console=console,
transient=False
) as progress:
transfer_task = progress.add_task("Sende Firmware...", total=file_size)
stream_res = self.bus.send_stream(
file_data,
progress_callback=lambda sent, total: progress.update(transfer_task, total=total, completed=sent)
)
if not stream_res:
return None
remote_crc = stream_res.get('crc32')
local_crc = zlib.crc32(file_data) & 0xFFFFFFFF
return {
'success': local_crc == remote_crc,
'source_path': file_path,
'crc32_remote': remote_crc,
'crc32_local': local_crc,
'size': file_size,
'duration': stream_res.get('duration')
}
def print(self, result):
if not result:
return
if result['success']:
console.print(f"✓ Firmware [info]{result['source_path']}[/info] erfolgreich in Slot 1 geschrieben.")
console.print(" [warning]Achtung:[/warning] Das Image ist als 'Pending' markiert. Führe 'reboot' aus, um das Update zu installieren.")
else:
console_err.print(f"❌ CRC-FEHLER: Die Firmware wurde korrumpiert übertragen!")
console.print(f" • Größe: [info]{result['size'] / 1024:.2f} KB[/info]")
console.print(f" • Remote CRC: [info]{result['crc32_remote']:08X}[/info]")
console.print(f" • Local CRC: [info]{result['crc32_local']:08X}[/info]")

68
tool/core/cmd/put_tags.py Normal file
View File

@@ -0,0 +1,68 @@
# tool/core/cmd/put_tags.py
import struct
import json
from core.utils import console, console_err
from core.protocol import COMMANDS
from core.tag import TagManager
from core.cmd.get_tags import get_tags
class put_tags:
def __init__(self, bus):
self.bus = bus
def get(self, path: str, json_str: str, overwrite: bool):
try:
new_tlvs = TagManager.parse_cli_json(json_str)
except ValueError as e:
console_err.print(f"[error]{e}[/error]")
return False
getter = get_tags(self.bus)
existing_tlvs = getter.get_raw_tlvs(path)
if overwrite:
# Bei Overwrite: Alle alten JSON-Tags löschen
existing_tlvs = [t for t in existing_tlvs if t['type'] != 0x10]
else:
# Ohne Overwrite: Bestehende JSON-Werte mit neuen mischen
existing_json = {}
for t in existing_tlvs:
if t['type'] == 0x10:
try: existing_json.update(json.loads(t['value'].decode('utf-8')))
except: pass
# Neues JSON einmischen
for nt in new_tlvs:
if nt['type'] == 0x10:
try: existing_json.update(json.loads(nt['value'].decode('utf-8')))
except: pass
existing_tlvs = [t for t in existing_tlvs if t['type'] != 0x10]
if existing_json:
existing_tlvs.append({'type': 0x10, 'index': 0x00, 'value': json.dumps(existing_json, ensure_ascii=False).encode('utf-8')})
# System-Tags (0x00) überschreiben alte direkt
new_sys_tlvs = [t for t in new_tlvs if t['type'] == 0x00]
for nt in new_sys_tlvs:
existing_tlvs = [t for t in existing_tlvs if not (t['type'] == nt['type'] and t['index'] == nt['index'])]
existing_tlvs.append(nt)
new_tlvs = existing_tlvs
blob = TagManager.build_blob(new_tlvs)
return self.send_blob(path, blob)
def send_blob(self, path: str, blob: bytes):
path_bytes = path.encode('utf-8')
req_payload = struct.pack('B', len(path_bytes)) + path_bytes + struct.pack('<I', len(blob))
self.bus.send_request(COMMANDS['put_tags'], req_payload)
self.bus.receive_ack(timeout=2.0)
if self.bus.send_stream(blob):
return True
return False
def print(self, result, path: str):
if result:
console.print(f"✓ Metadaten erfolgreich auf [info]{path}[/info] geschrieben.")

23
tool/core/cmd/reboot.py Normal file
View File

@@ -0,0 +1,23 @@
import serial
from core.utils import console, console_err
from core.protocol import COMMANDS
class reboot:
def __init__(self, bus):
self.bus = bus
def get(self):
self.bus.send_request(COMMANDS['reboot'])
try:
data = self.bus.receive_ack()
return data is not None and data.get('type') == 'ack'
except serial.SerialException:
# SerialException MUSS hier ignoriert werden, da der Controller
# den USB-Port beim Reboot hart schließt
return True
def print(self, result):
if result:
console.print("🔄 Neustart-Befehl erfolgreich gesendet. Controller [info]bootet neu...[/info]")
else:
console_err.print("❌ Fehler beim Senden des Neustart-Befehls.")

View File

@@ -0,0 +1,38 @@
import struct
from core.utils import console, console_err
from core.protocol import COMMANDS
class set_setting:
def __init__(self, bus):
self.bus = bus
def get(self, key: str, value: str):
key_bytes = key.encode('utf-8')
val_bytes = b''
# Typen-Konvertierung basierend auf dem Key
try:
if key == "audio/vol":
val_bytes = struct.pack('<B', int(value))
elif key == "play/norepeat":
val_int = 1 if str(value).lower() in ['1', 'true', 'on', 'yes'] else 0
val_bytes = struct.pack('<B', val_int)
elif key == "settings/storage_interval":
val_bytes = struct.pack('<H', int(value))
else:
console_err.print(f"[error]Unbekannter Key: {key}[/error]")
return False
except ValueError:
console_err.print(f"[error]Ungültiger Wert für {key}: {value}[/error]")
return False
# Payload: [Key Len] [Key] [Val Len] [Val Bytes]
payload = struct.pack('B', len(key_bytes)) + key_bytes + struct.pack('B', len(val_bytes)) + val_bytes
self.bus.send_request(COMMANDS['set_setting'], payload)
data = self.bus.receive_ack()
return data is not None and data.get('type') == 'ack'
def print(self, result, key: str, value: str):
if result:
console.print(f"✓ Setting [info]{key}[/info] wurde auf [info]{value}[/info] gesetzt.")

15
tool/core/cmd/stop.py Normal file
View File

@@ -0,0 +1,15 @@
from core.utils import console, console_err
from core.protocol import COMMANDS
class stop:
def __init__(self, bus):
self.bus = bus
def get(self):
self.bus.send_request(COMMANDS['stop'])
data = self.bus.receive_ack()
return data is not None and data.get('type') == 'ack'
def print(self, result):
if result:
console.print("⏹ Wiedergabe gestoppt und Warteschlange geleert.")

View File

@@ -2,7 +2,7 @@
VERSION = {
"min_protocol_version": 1,
"max_protocol_version": 1,
"current_protocol_version": None
"current_protocol_version": None,
}
SYNC_SEQ = b'BUZZ'
@@ -33,56 +33,45 @@ ERRORS = {
0x40: "NOT_IMPLEMENTED",
}
FRAME_TYPE_REQUEST = 0x01
FRAME_TYPE_ACK = 0x10
FRAME_TYPE_RESPONSE = 0x11
FRAME_TYPE_STREAM_START = 0x12
FRAME_TYPE_STREAM_CHUNK = 0x13
FRAME_TYPE_STREAM_END = 0x14
FRAME_TYPE_LIST_START = 0x15
FRAME_TYPE_LIST_CHUNK = 0x16
FRAME_TYPE_LIST_END = 0x17
FRAME_TYPE_ERROR = 0xFF
FRAME_TYPES = {
'request': FRAME_TYPE_REQUEST,
'ack': FRAME_TYPE_ACK,
'response': FRAME_TYPE_RESPONSE,
'error': FRAME_TYPE_ERROR,
'stream_start': FRAME_TYPE_STREAM_START,
'stream_chunk': FRAME_TYPE_STREAM_CHUNK,
'stream_end': FRAME_TYPE_STREAM_END,
'list_start': FRAME_TYPE_LIST_START,
'list_chunk': FRAME_TYPE_LIST_CHUNK,
'list_end': FRAME_TYPE_LIST_END
'request': 0x01,
'ack': 0x10,
'response': 0x11,
'stream_start': 0x12,
'stream_chunk': 0x13,
'stream_end': 0x14,
'list_start': 0x15,
'list_chunk': 0x16,
'list_end': 0x17,
'error': 0xFF,
}
CMD_GET_PROTOCOL_VERSION = 0x00
CMD_GET_FIRMWARE_STATUS = 0x01
CMD_GET_FLASH_INFO = 0x02
CMD_LIST_DIR = 0x10
CMD_CRC_32 = 0x11
CMD_MKDIR = 0x12
CMD_RM = 0x13
CMD_STAT = 0x18
CMD_RENAME = 0x19
CMD_PUT_FILE = 0x20
CMD_PUT_FW = 0x21
CMD_GET_FILE = 0x22
COMMANDS = {
'get_protocol_version': CMD_GET_PROTOCOL_VERSION,
'get_firmware_status': CMD_GET_FIRMWARE_STATUS,
'get_flash_info': CMD_GET_FLASH_INFO,
'list_dir': CMD_LIST_DIR,
'stat': CMD_STAT,
'rm': CMD_RM,
'rename': CMD_RENAME,
'mkdir': CMD_MKDIR,
'crc_32': CMD_CRC_32,
'put_file': CMD_PUT_FILE,
'get_file': CMD_GET_FILE,
'put_fw': CMD_PUT_FW,
'get_protocol_version': 0x00,
'get_firmware_status': 0x01,
'get_flash_info': 0x02,
'confirm_fw': 0x03,
'reboot': 0x04,
'list_dir': 0x10,
'crc_32': 0x11,
'mkdir': 0x12,
'rm': 0x13,
'stat': 0x18,
'rename': 0x19,
'put_file': 0x20,
'put_fw': 0x21,
'get_file': 0x22,
'put_tags': 0x24,
'get_tags': 0x25,
'play': 0x30,
'stop': 0x31,
'set_setting': 0x40,
'get_setting': 0x41,
}

View File

@@ -120,6 +120,40 @@ class SerialBus:
full_frame += payload
self.send_binary(full_frame)
def send_stream(self, data: bytes, chunk_size: int = 4096, progress_callback=None):
"""Sendet einen Datenstrom in Chunks und wartet auf die Bestätigung (CRC)."""
start_time = time.time()
size = len(data)
sent_size = 0
while sent_size < size:
chunk = data[sent_size:sent_size+chunk_size]
self.connection.write(chunk)
sent_size += len(chunk)
if progress_callback:
progress_callback(sent_size, size)
if not self.wait_for_sync(SYNC_SEQ, max_time=self.timeout + 5.0):
raise TimeoutError("Timeout beim Warten auf Stream-Ende (Flash ist evtl. noch beschäftigt).")
ftype = self._read_exact(1, "Frame-Typ")[0]
if ftype == FRAME_TYPES['stream_end']:
end_time = time.time()
crc32 = struct.unpack('<I', self._read_exact(4, "CRC32"))[0]
return {
'crc32': crc32,
'duration': end_time - start_time
}
elif ftype == FRAME_TYPES['error']:
err_code_raw = self.connection.read(1)
err_code = err_code_raw[0] if err_code_raw else 0xFF
err_name = ERRORS.get(err_code, "UNKNOWN")
raise ControllerError(err_code, err_name)
else:
raise ValueError(f"Unerwarteter Frame-Typ nach Stream-Upload: 0x{ftype:02X}")
def receive_ack(self, timeout: float = None):
wait_time = timeout if timeout is not None else self.timeout
@@ -212,42 +246,46 @@ class SerialBus:
err_name = ERRORS.get(err_code, "UNKNOWN")
raise ControllerError(err_code, err_name)
def receive_stream(self, chunk_size: int = 1024):
def receive_stream(self, chunk_size: int = 1024, progress_callback=None):
"""Liest einen Datenstrom in Chunks, bis ein Fehler oder Ende-Signal kommt."""
is_stream = False
data_chunks = []
start_time = None
while True:
if not self.wait_for_sync(SYNC_SEQ):
raise TimeoutError("Timeout beim Warten auf Sync im Stream-Modus.")
ftype = self._read_exact(1, "Frame-Typ")[0]
if self.debug: console.print(f"Empfangener Frame-Typ: 0x{ftype:02X} (start: 0x{FRAME_TYPES['stream_start']:02X}, chunk: 0x{FRAME_TYPES['stream_chunk']:02X}, end: 0x{FRAME_TYPES['stream_end']:02X})")
if ftype == FRAME_TYPES['stream_start']:
is_stream = True
data_chunks = []
size = struct.unpack('<I', self._read_exact(4, "Stream-Größe"))[0]
if self.debug: console.print(f"Stream gestartet, erwartete Gesamtgröße: {size} Bytes")
start_time = time.time()
received_size = 0
while received_size < size:
chunk_length = min(chunk_size, size - received_size)
try:
chunk_data = self._read_exact(chunk_length, f"Daten-Chunk @ {received_size}/{size}")
except Exception as e:
raise IOError(f"Stream-Abbruch bei {received_size}/{size} Bytes: {e}") from e
chunk_data = self._read_exact(chunk_length, f"Daten-Chunk @ {received_size}/{size}")
data_chunks.append(chunk_data)
received_size += len(chunk_data)
if self.debug: console.print(f"Empfangen: {received_size}/{size} Bytes ({(received_size/size)*100:.2f}%)")
# Callback für UI-Update (z.B. Progress Bar)
if progress_callback:
progress_callback(received_size, size)
if self.debug: console.print("Stream vollständig empfangen.")
elif ftype == FRAME_TYPES['stream_end']:
end_time = time.time()
if not is_stream: raise ValueError("Ende ohne Start.")
crc32 = struct.unpack('<I', self._read_exact(4, "CRC32"))[0]
if self.debug: console.print(f"Stream-Ende empfangen, CRC32: 0x{crc32:08X}")
return {'data': b''.join(data_chunks), 'crc32': crc32}
return {
'data': b''.join(data_chunks),
'crc32': crc32,
'duration': end_time - start_time if start_time and end_time else None
}
# elif ftype == FRAME_TYPES['list_chunk']:
# if not is_list: raise ValueError("Chunk ohne Start.")

95
tool/core/tag.py Normal file
View File

@@ -0,0 +1,95 @@
# tool/core/tag.py
import struct
import json
class TagManager:
FOOTER_MAGIC = b'TAG!'
FOOTER_SIZE = 8
FORMAT_VERSION = 1
@classmethod
def split_file(cls, file_data: bytes):
"""Trennt eine Datei in reine Audiodaten und eine Liste von TLVs auf."""
if len(file_data) < cls.FOOTER_SIZE:
return file_data, []
footer = file_data[-cls.FOOTER_SIZE:]
total_size, version, magic = struct.unpack('<HH4s', footer)
if magic != cls.FOOTER_MAGIC or version != cls.FORMAT_VERSION:
return file_data, []
if total_size > len(file_data) or total_size < cls.FOOTER_SIZE:
return file_data, []
audio_limit = len(file_data) - total_size
audio_data = file_data[:audio_limit]
tag_data = file_data[audio_limit:-cls.FOOTER_SIZE]
tlvs = cls.parse_tlvs(tag_data)
return audio_data, tlvs
@classmethod
def parse_tlvs(cls, tag_data: bytes):
"""Parst einen rohen TLV-Byteblock in eine Liste von Dictionaries."""
tlvs = []
pos = 0
while pos + 4 <= len(tag_data):
t, i, length = struct.unpack('<BBH', tag_data[pos:pos+4])
pos += 4
if pos + length > len(tag_data):
break # Korrupt
val = tag_data[pos:pos+length]
tlvs.append({'type': t, 'index': i, 'value': val})
pos += length
return tlvs
@classmethod
def build_blob(cls, tlvs: list):
"""Baut aus einer TLV-Liste den fertigen Byte-Blob inklusive Footer."""
# Sortierung: Type 0x00 (System) zwingend nach vorne
tlvs = sorted(tlvs, key=lambda x: x['type'])
payload = b""
for tlv in tlvs:
payload += struct.pack('<BBH', tlv['type'], tlv['index'], len(tlv['value']))
payload += tlv['value']
total_size = len(payload) + cls.FOOTER_SIZE
footer = struct.pack('<HH4s', total_size, cls.FORMAT_VERSION, cls.FOOTER_MAGIC)
return payload + footer
@classmethod
def parse_cli_json(cls, json_str: str):
"""Konvertiert den Kommandozeilen-JSON-String in neue TLVs."""
try:
data = json.loads(json_str)
except json.JSONDecodeError as e:
raise ValueError(f"Ungültiges JSON-Format: {e}")
new_tlvs = []
# 1. System Tags (0x00)
if "system" in data:
sys_data = data["system"]
if "format" in sys_data:
fmt = sys_data["format"]
if isinstance(fmt, str) and fmt.startswith("0x"):
val = bytes.fromhex(fmt[2:])
else:
val = struct.pack('<BBHI', fmt.get("codec", 0), fmt.get("bit_depth", 16), 0, fmt.get("samplerate", 16000))
new_tlvs.append({'type': 0x00, 'index': 0x00, 'value': val})
if "crc32" in sys_data:
crc_str = sys_data["crc32"]
crc_val = int(crc_str, 16) if isinstance(crc_str, str) else crc_str
new_tlvs.append({'type': 0x00, 'index': 0x01, 'value': struct.pack('<I', crc_val)})
# 2. JSON Tags (0x10)
if "json" in data:
# Bei leerem JSON-Objekt ("{}") wird kein 0x10 TLV erstellt
if data["json"]:
json_bytes = json.dumps(data["json"], ensure_ascii=False).encode('utf-8')
new_tlvs.append({'type': 0x10, 'index': 0x00, 'value': json_bytes})
return new_tlvs