sync
This commit is contained in:
@@ -1,18 +1,60 @@
|
||||
# core/commands/check.py
|
||||
from core.connection import BuzzerError
|
||||
|
||||
def execute(conn, path: str) -> dict:
|
||||
"""Holt die CRC32 einer datei und gibt sie als Int zurück."""
|
||||
lines = conn.send_command("check " + path)
|
||||
if not lines:
|
||||
raise BuzzerError("Keine Antwort auf 'check' empfangen.")
|
||||
def _split_parent_and_name(path: str) -> tuple[str, str]:
|
||||
normalized = path.rstrip("/")
|
||||
if not normalized or normalized == "/":
|
||||
raise BuzzerError("Für CHECK wird ein Dateipfad benötigt.")
|
||||
|
||||
parts = lines[0].split()
|
||||
if len(parts) != 1:
|
||||
raise BuzzerError(f"Unerwartetes Check-Format: {lines[0]}")
|
||||
|
||||
crc32 = int(parts[0], 16)
|
||||
idx = normalized.rfind("/")
|
||||
if idx <= 0:
|
||||
return "/", normalized
|
||||
|
||||
parent = normalized[:idx]
|
||||
name = normalized[idx + 1:]
|
||||
if not name:
|
||||
raise BuzzerError("Ungültiger Dateipfad für CHECK.")
|
||||
return parent, name
|
||||
|
||||
|
||||
def _lookup_file_size_bytes(conn, path: str) -> int | None:
|
||||
parent, filename = _split_parent_and_name(path)
|
||||
lines = conn.list_directory(parent)
|
||||
|
||||
for line in lines:
|
||||
parts = line.split(",", 2)
|
||||
if len(parts) != 3:
|
||||
continue
|
||||
|
||||
entry_type, entry_size, entry_name = parts
|
||||
if entry_type == "F" and entry_name == filename:
|
||||
try:
|
||||
return int(entry_size)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _estimate_crc_timeout_seconds(conn, size_bytes: int | None) -> float:
|
||||
min_timeout = float(getattr(conn, "crc_timeout_min_seconds", 2.0))
|
||||
ms_per_100kb = float(getattr(conn, "crc_timeout_ms_per_100kb", 1.5))
|
||||
|
||||
base = max(float(conn.timeout), min_timeout)
|
||||
if size_bytes is None or size_bytes <= 0:
|
||||
return base
|
||||
|
||||
blocks_100kb = size_bytes / (100.0 * 1024.0)
|
||||
extra = blocks_100kb * (ms_per_100kb / 1000.0)
|
||||
return base + extra
|
||||
|
||||
def execute(conn, path: str) -> dict:
|
||||
"""Holt die CRC32 nur über Audiodaten und passt Timeout für große Dateien an."""
|
||||
size_bytes = _lookup_file_size_bytes(conn, path)
|
||||
timeout = _estimate_crc_timeout_seconds(conn, size_bytes)
|
||||
crc32 = conn.check_file_crc(path, timeout=timeout)
|
||||
|
||||
return {
|
||||
"crc32": crc32
|
||||
"crc32": crc32,
|
||||
"size_bytes": size_bytes,
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
# core/commands/mkdir.py
|
||||
# core/commands/confirm.py
|
||||
from core.connection import BuzzerError
|
||||
|
||||
|
||||
def execute(conn):
|
||||
"""Confirmt die aktuelle Firmware."""
|
||||
"""Bestätigt die aktuell laufende Firmware per Binary-Protokoll."""
|
||||
try:
|
||||
conn.send_command(f"confirm")
|
||||
print(f"✅ Firmware erfolgreich bestätigt.")
|
||||
conn.confirm_firmware()
|
||||
print("✅ Firmware erfolgreich bestätigt.")
|
||||
except BuzzerError as e:
|
||||
print(f"❌ Fehler beim Bestätigen der Firmware: {e}")
|
||||
57
buzzer_tool/core/commands/fw_put.py
Normal file
57
buzzer_tool/core/commands/fw_put.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
import time
|
||||
import sys
|
||||
|
||||
|
||||
def _estimate_fw_timeout_seconds(conn, total_size: int) -> float:
|
||||
base = float(getattr(conn, "timeout", 5.0))
|
||||
erase_budget = 8.0
|
||||
stream_and_write_budget = total_size / (25.0 * 1024.0)
|
||||
return max(base, erase_budget + stream_and_write_budget)
|
||||
|
||||
|
||||
def execute(conn, source: str):
|
||||
if not os.path.isfile(source):
|
||||
raise FileNotFoundError(f"Firmware-Datei nicht gefunden: {source}")
|
||||
|
||||
with open(source, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
total_size = len(data)
|
||||
if total_size == 0:
|
||||
raise ValueError("Firmware-Datei ist leer.")
|
||||
|
||||
print(f"Sende 🧩 Firmware ({total_size / 1024:.1f} KB) -> secondary slot")
|
||||
fw_timeout = _estimate_fw_timeout_seconds(conn, total_size)
|
||||
print(f" Timeout fw_put: {fw_timeout:.1f}s")
|
||||
print(" Phase 1/3: Lösche secondary slot und initialisiere Flash...")
|
||||
|
||||
start_time = time.monotonic()
|
||||
last_ui_update = start_time
|
||||
transfer_started = False
|
||||
|
||||
def progress_handler(chunk_len, sent_file, total_file):
|
||||
nonlocal last_ui_update, transfer_started
|
||||
if not transfer_started:
|
||||
transfer_started = True
|
||||
print(" Phase 2/3: Übertrage Firmware...")
|
||||
now = time.monotonic()
|
||||
if now - last_ui_update < 0.2 and sent_file < total_file:
|
||||
return
|
||||
last_ui_update = now
|
||||
|
||||
elapsed = now - start_time
|
||||
speed = (sent_file / 1024.0) / elapsed if elapsed > 0 else 0.0
|
||||
perc = (sent_file / total_file) * 100.0 if total_file > 0 else 100.0
|
||||
eta_sec = ((total_file - sent_file) / (sent_file / elapsed)) if sent_file > 0 and elapsed > 0 else 0.0
|
||||
eta_str = f"{int(eta_sec // 60):02d}:{int(eta_sec % 60):02d}"
|
||||
|
||||
sys.stdout.write(
|
||||
f"\r \033[90mProg: {perc:3.0f}% | {speed:6.1f} KB/s | ETA: {eta_str}\033[0m"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
crc32 = conn.fw_put_data(data, timeout=fw_timeout, progress_callback=progress_handler)
|
||||
print("\n Phase 3/3: Finalisiere und warte auf Geräte-ACK...")
|
||||
print(f"\r \033[32mFertig: Firmware übertragen (CRC32: 0x{crc32:08x}).{' ' * 16}\033[0m")
|
||||
print("ℹ️ Nächste Schritte: reboot -> testen -> confirm")
|
||||
@@ -1,56 +0,0 @@
|
||||
# core/commands/get_tag.py
|
||||
from core.connection import BuzzerError
|
||||
from core.util import hex_to_bytearray
|
||||
|
||||
def execute(conn, path: str) -> dict:
|
||||
"""Holt Tags einer Datei und gibt sie als strukturiertes Dictionary zurück."""
|
||||
lines = conn.send_command("get_tag " + path)
|
||||
if not lines:
|
||||
raise BuzzerError("Keine Antwort auf 'get_tag' empfangen.")
|
||||
|
||||
parts = lines[0].split()
|
||||
if len(parts) != 1:
|
||||
raise BuzzerError(f"Unerwartetes get_tag-Format: {lines[0]}")
|
||||
|
||||
data = hex_to_bytearray(parts[0])
|
||||
if data is None:
|
||||
raise BuzzerError("Ungültiger Hex-String in get_tag-Antwort.")
|
||||
|
||||
pos = 0
|
||||
tags = {}
|
||||
while pos < len(data):
|
||||
tag_type = data[pos]
|
||||
pos += 1
|
||||
if pos >= len(data):
|
||||
raise BuzzerError(f"Unerwartetes Ende des Hex-Strings bei get_tag (Tag-Typ erwartet). Position: {pos}/{len(data)}")
|
||||
|
||||
match tag_type:
|
||||
case 0x01: # Kommentar
|
||||
length = data[pos]
|
||||
pos += 1
|
||||
if pos + length > len(data):
|
||||
raise BuzzerError(f"Unerwartetes Ende des Hex-Strings bei get_tag (Kommentar erwartet). Position: {pos}")
|
||||
comment = data[pos:pos+length].decode('utf-8')
|
||||
pos += length
|
||||
tags["comment"] = comment
|
||||
|
||||
case 0x02: # Author
|
||||
length = data[pos]
|
||||
pos += 1
|
||||
if pos + length > len(data):
|
||||
raise BuzzerError(f"Unerwartetes Ende des Hex-Strings bei get_tag (Author erwartet). Position: {pos}")
|
||||
author = data[pos:pos+length].decode('utf-8')
|
||||
pos += length
|
||||
tags["author"] = author
|
||||
|
||||
case 0x10: # CRC32
|
||||
if pos + 4 > len(data):
|
||||
raise BuzzerError(f"Unerwartetes Ende des Hex-Strings bei get_tag (CRC32 erwartet). Position: {pos}")
|
||||
crc32 = int.from_bytes(data[pos:pos+4], byteorder='big')
|
||||
pos += 4
|
||||
tags["crc32"] = hex(crc32)
|
||||
|
||||
case _: # Default / Unbekannter Tag
|
||||
tags[f"unknown_0x{tag_type:02x}"] = tag_value_raw.hex()
|
||||
|
||||
return tags
|
||||
@@ -3,24 +3,23 @@ from core.connection import BuzzerError
|
||||
|
||||
def execute(conn) -> dict:
|
||||
"""Holt die Systeminformationen und gibt sie als strukturiertes Dictionary zurück."""
|
||||
lines = conn.send_command("info")
|
||||
if not lines:
|
||||
raise BuzzerError("Keine Antwort auf 'info' empfangen.")
|
||||
protocol_version = conn.get_protocol_version()
|
||||
if protocol_version != 1:
|
||||
raise BuzzerError(f"Inkompatibles Protokoll: Gerät nutzt v{protocol_version}, Host erwartet v1.")
|
||||
|
||||
parts = lines[0].split(';')
|
||||
# Auf 6 Parameter aktualisiert
|
||||
if len(parts) != 6:
|
||||
raise BuzzerError(f"Unerwartetes Info-Format: {lines[0]}")
|
||||
status_code, app_version = conn.get_firmware_status()
|
||||
flash = conn.get_flash_status()
|
||||
|
||||
protocol_version = int(parts[0])
|
||||
if protocol_version != 2:
|
||||
raise BuzzerError(f"Inkompatibles Protokoll: Gerät nutzt v{protocol_version}, Host erwartet v2.")
|
||||
f_frsize = flash["block_size"]
|
||||
f_blocks = flash["total_blocks"]
|
||||
f_bfree = flash["free_blocks"]
|
||||
|
||||
app_version = parts[1]
|
||||
f_frsize = int(parts[2])
|
||||
f_blocks = int(parts[3])
|
||||
f_bfree = int(parts[4])
|
||||
image_status = parts[5].strip() # CONFIRMED oder UNCONFIRMED
|
||||
status_map = {
|
||||
1: "CONFIRMED",
|
||||
2: "TESTING",
|
||||
3: "PENDING",
|
||||
}
|
||||
image_status = status_map.get(status_code, f"UNKNOWN({status_code})")
|
||||
|
||||
total_kb = (f_blocks * f_frsize) / 1024
|
||||
free_kb = (f_bfree * f_frsize) / 1024
|
||||
|
||||
@@ -11,7 +11,7 @@ def get_file_tree(conn, target_path="/", recursive=False) -> list:
|
||||
cmd_path = target_path.rstrip('/') if target_path != '/' else '/'
|
||||
|
||||
try:
|
||||
lines = conn.send_command(f"ls {cmd_path}")
|
||||
lines = conn.list_directory(cmd_path)
|
||||
except BuzzerError as e:
|
||||
return [{"type": "E", "name": f"Fehler beim Lesen: {e}", "path": target_path}]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from core.connection import BuzzerError
|
||||
def execute(conn, path: str):
|
||||
"""Erstellt ein Verzeichnis auf dem Controller."""
|
||||
try:
|
||||
conn.send_command(f"mkdir {path}")
|
||||
conn.mkdir(path)
|
||||
print(f"📁 Verzeichnis '{path}' erfolgreich erstellt.")
|
||||
except BuzzerError as e:
|
||||
print(f"❌ Fehler beim Erstellen von '{path}': {e}")
|
||||
10
buzzer_tool/core/commands/mv.py
Normal file
10
buzzer_tool/core/commands/mv.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from core.connection import BuzzerError
|
||||
|
||||
|
||||
def execute(conn, source: str, target: str):
|
||||
try:
|
||||
conn.rename(source, target)
|
||||
print(f"✅ Umbenannt/Verschoben: '{source}' -> '{target}'")
|
||||
except BuzzerError as e:
|
||||
print(f"❌ Fehler beim Umbenennen/Verschieben: {e}")
|
||||
raise
|
||||
62
buzzer_tool/core/commands/pull.py
Normal file
62
buzzer_tool/core/commands/pull.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import posixpath
|
||||
import time
|
||||
|
||||
|
||||
def _resolve_local_target(remote_path: str, target: str | None) -> str:
|
||||
if target:
|
||||
return target
|
||||
|
||||
basename = posixpath.basename(remote_path.rstrip("/"))
|
||||
if not basename:
|
||||
raise ValueError("Kann keinen lokalen Dateinamen aus dem Remote-Pfad ableiten. Bitte Zielpfad angeben.")
|
||||
return basename
|
||||
|
||||
|
||||
def execute(conn, source: str, target: str | None = None):
|
||||
local_path = _resolve_local_target(source, target)
|
||||
|
||||
os.makedirs(os.path.dirname(local_path) or ".", exist_ok=True)
|
||||
|
||||
last_print = 0.0
|
||||
start_time = time.monotonic()
|
||||
|
||||
def _progress(_chunk_len: int, received: int, expected: int | None):
|
||||
nonlocal last_print
|
||||
now = time.monotonic()
|
||||
if now - last_print < 0.2:
|
||||
return
|
||||
last_print = now
|
||||
|
||||
elapsed = max(now - start_time, 1e-6)
|
||||
speed_kb_s = (received / 1024.0) / elapsed
|
||||
|
||||
if expected is not None and expected > 0:
|
||||
percent = (received * 100.0) / expected
|
||||
remaining = max(expected - received, 0)
|
||||
eta_sec = (remaining / 1024.0) / speed_kb_s if speed_kb_s > 0 else 0.0
|
||||
eta_str = f"{int(eta_sec // 60):02d}:{int(eta_sec % 60):02d}"
|
||||
print(
|
||||
f"\r⬇️ {received}/{expected} B ({percent:5.1f}%) | {speed_kb_s:6.1f} KB/s | ETA {eta_str}",
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f"\r⬇️ {received} B | {speed_kb_s:6.1f} KB/s", end="", flush=True)
|
||||
|
||||
data = conn.get_file_data(source, progress_callback=_progress)
|
||||
|
||||
if len(data) > 0:
|
||||
print()
|
||||
|
||||
with open(local_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
total_duration = max(time.monotonic() - start_time, 1e-6)
|
||||
avg_speed_kb_s = (len(data) / 1024.0) / total_duration
|
||||
print(f"✅ Heruntergeladen: '{source}' -> '{local_path}' ({len(data)} B, {avg_speed_kb_s:.1f} KB/s)")
|
||||
return {
|
||||
"source": source,
|
||||
"target": local_path,
|
||||
"size": len(data),
|
||||
}
|
||||
@@ -1,61 +1,202 @@
|
||||
import os
|
||||
import zlib
|
||||
import glob
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from core.connection import BuzzerError
|
||||
|
||||
def get_file_crc32(filepath: str) -> int:
|
||||
"""Berechnet die IEEE CRC32-Prüfsumme einer Datei in Chunks."""
|
||||
crc = 0
|
||||
with open(filepath, 'rb') as f:
|
||||
while chunk := f.read(4096):
|
||||
crc = zlib.crc32(chunk, crc)
|
||||
return crc & 0xFFFFFFFF
|
||||
TAG_MAGIC = b"TAG!"
|
||||
TAG_FOOTER_LEN = 7
|
||||
TAG_VERSION_V1 = 0x01
|
||||
TAG_TYPE_CRC32 = 0x10
|
||||
|
||||
def execute(conn, sources: list, target: str):
|
||||
# 1. Globbing auflösen
|
||||
resolved_files = [f for src in sources for f in glob.glob(src) if os.path.isfile(f)]
|
||||
|
||||
if not resolved_files:
|
||||
|
||||
def _split_audio_and_tag_blob(filepath: str) -> tuple[bytes, bytes | None]:
|
||||
with open(filepath, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
if len(data) < TAG_FOOTER_LEN:
|
||||
return data, None
|
||||
|
||||
if data[-4:] != TAG_MAGIC:
|
||||
return data, None
|
||||
|
||||
tag_total_len = int.from_bytes(data[-6:-4], byteorder="little", signed=False)
|
||||
tag_version = data[-7]
|
||||
if tag_version != TAG_VERSION_V1:
|
||||
return data, None
|
||||
|
||||
if tag_total_len < TAG_FOOTER_LEN or tag_total_len > len(data):
|
||||
return data, None
|
||||
|
||||
audio_end = len(data) - tag_total_len
|
||||
tag_payload_len = tag_total_len - TAG_FOOTER_LEN
|
||||
tag_payload = data[audio_end:audio_end + tag_payload_len]
|
||||
audio_data = data[:audio_end]
|
||||
return audio_data, tag_payload
|
||||
|
||||
|
||||
def _upsert_crc32_tag(tag_blob: bytes | None, crc32: int) -> tuple[bytes, bool]:
|
||||
crc_payload = int(crc32).to_bytes(4, byteorder="little", signed=False)
|
||||
crc_tlv = bytes([TAG_TYPE_CRC32, 0x04, 0x00]) + crc_payload
|
||||
|
||||
if not tag_blob:
|
||||
return crc_tlv, True
|
||||
|
||||
pos = 0
|
||||
out = bytearray()
|
||||
found_crc = False
|
||||
|
||||
while pos < len(tag_blob):
|
||||
if pos + 3 > len(tag_blob):
|
||||
return crc_tlv, True
|
||||
|
||||
tag_type = tag_blob[pos]
|
||||
tag_len = tag_blob[pos + 1] | (tag_blob[pos + 2] << 8)
|
||||
header = tag_blob[pos:pos + 3]
|
||||
pos += 3
|
||||
|
||||
if pos + tag_len > len(tag_blob):
|
||||
return crc_tlv, True
|
||||
|
||||
value = tag_blob[pos:pos + tag_len]
|
||||
pos += tag_len
|
||||
|
||||
if tag_type == TAG_TYPE_CRC32:
|
||||
if not found_crc:
|
||||
out.extend(crc_tlv)
|
||||
found_crc = True
|
||||
continue
|
||||
|
||||
out.extend(header)
|
||||
out.extend(value)
|
||||
|
||||
if not found_crc:
|
||||
out.extend(crc_tlv)
|
||||
|
||||
return bytes(out), True
|
||||
|
||||
|
||||
def _collect_source_files(sources: list[str], recursive: bool) -> list[dict]:
|
||||
entries = []
|
||||
|
||||
for source in sources:
|
||||
matches = glob.glob(source)
|
||||
if not matches:
|
||||
print(f"⚠️ Keine Treffer für Quelle: {source}")
|
||||
continue
|
||||
|
||||
for match in matches:
|
||||
if os.path.isfile(match):
|
||||
entries.append({"local": match, "relative": os.path.basename(match)})
|
||||
continue
|
||||
|
||||
if os.path.isdir(match):
|
||||
if recursive:
|
||||
for root, _, files in os.walk(match):
|
||||
for name in sorted(files):
|
||||
local_path = os.path.join(root, name)
|
||||
rel = os.path.relpath(local_path, match)
|
||||
entries.append({"local": local_path, "relative": rel.replace("\\", "/")})
|
||||
else:
|
||||
for name in sorted(os.listdir(match)):
|
||||
local_path = os.path.join(match, name)
|
||||
if os.path.isfile(local_path):
|
||||
entries.append({"local": local_path, "relative": name})
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def _remote_parent(path: str) -> str:
|
||||
idx = path.rfind("/")
|
||||
if idx <= 0:
|
||||
return "/"
|
||||
return path[:idx]
|
||||
|
||||
|
||||
def _ensure_remote_dir(conn, remote_dir: str) -> None:
|
||||
if not remote_dir or remote_dir == "/":
|
||||
return
|
||||
|
||||
current = ""
|
||||
for part in [p for p in remote_dir.split("/") if p]:
|
||||
current = f"{current}/{part}"
|
||||
try:
|
||||
conn.mkdir(current)
|
||||
except BuzzerError as e:
|
||||
msg = str(e)
|
||||
if "0x11" in msg or "existiert bereits" in msg:
|
||||
continue
|
||||
raise
|
||||
|
||||
|
||||
def _build_upload_plan(entries: list[dict], target: str) -> list[dict]:
|
||||
if not entries:
|
||||
return []
|
||||
|
||||
needs_dir_semantics = target.endswith("/") or len(entries) > 1 or any("/" in e["relative"] for e in entries)
|
||||
if not needs_dir_semantics:
|
||||
return [{"local": entries[0]["local"], "remote": target}]
|
||||
|
||||
base = target.rstrip("/")
|
||||
if not base:
|
||||
base = "/"
|
||||
|
||||
plan = []
|
||||
for entry in entries:
|
||||
rel = entry["relative"].lstrip("/")
|
||||
if base == "/":
|
||||
remote = f"/{rel}"
|
||||
else:
|
||||
remote = f"{base}/{rel}"
|
||||
plan.append({"local": entry["local"], "remote": remote})
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def execute(conn, sources: list[str], target: str, recursive: bool = False):
|
||||
uploads = _build_upload_plan(_collect_source_files(sources, recursive=recursive), target)
|
||||
if not uploads:
|
||||
print("Keine gültigen Dateien gefunden.")
|
||||
return
|
||||
|
||||
total_size_all = sum(os.path.getsize(f) for f in resolved_files)
|
||||
total_size_all = sum(os.path.getsize(item["local"]) for item in uploads)
|
||||
sent_all = 0
|
||||
start_time_all = time.monotonic()
|
||||
is_target_dir = target.endswith('/')
|
||||
last_ui_update = start_time_all
|
||||
|
||||
for filepath in resolved_files:
|
||||
filename = os.path.basename(filepath)
|
||||
filesize = os.path.getsize(filepath)
|
||||
crc32 = get_file_crc32(filepath)
|
||||
dest_path = f"{target}{filename}" if is_target_dir else target
|
||||
|
||||
print(f"Sende 📄 {filename} ({filesize/1024:.1f} KB) -> {dest_path}")
|
||||
|
||||
for item in uploads:
|
||||
local_path = item["local"]
|
||||
remote_path = item["remote"]
|
||||
filename = os.path.basename(local_path)
|
||||
|
||||
audio_data, tag_blob = _split_audio_and_tag_blob(local_path)
|
||||
audio_size = len(audio_data)
|
||||
|
||||
_ensure_remote_dir(conn, _remote_parent(remote_path))
|
||||
|
||||
print(f"Sende 📄 {filename} ({audio_size / 1024:.1f} KB Audio) -> {remote_path}")
|
||||
start_time_file = time.monotonic()
|
||||
sent_file = 0
|
||||
|
||||
def progress_handler(chunk_len):
|
||||
nonlocal sent_file, sent_all
|
||||
sent_file += chunk_len
|
||||
def progress_handler(chunk_len, sent_file, total_file):
|
||||
nonlocal sent_all, last_ui_update
|
||||
sent_all += chunk_len
|
||||
|
||||
elapsed = time.monotonic() - start_time_file
|
||||
speed = (sent_file / 1024) / elapsed if elapsed > 0 else 0
|
||||
|
||||
# Prozentberechnungen
|
||||
perc_file = (sent_file / filesize) * 100
|
||||
perc_all = (sent_all / total_size_all) * 100
|
||||
|
||||
# ETA (Basierend auf Gesamtgeschwindigkeit)
|
||||
elapsed_all = time.monotonic() - start_time_all
|
||||
avg_speed_all = sent_all / elapsed_all if elapsed_all > 0 else 0
|
||||
eta_sec = (total_size_all - sent_all) / avg_speed_all if avg_speed_all > 0 else 0
|
||||
|
||||
now = time.monotonic()
|
||||
if now - last_ui_update < 0.2 and sent_file < total_file:
|
||||
return
|
||||
last_ui_update = now
|
||||
|
||||
elapsed = now - start_time_file
|
||||
speed = (sent_file / 1024.0) / elapsed if elapsed > 0 else 0.0
|
||||
perc_file = (sent_file / total_file) * 100.0 if total_file > 0 else 100.0
|
||||
perc_all = (sent_all / total_size_all) * 100.0 if total_size_all > 0 else 100.0
|
||||
|
||||
elapsed_all = now - start_time_all
|
||||
avg_speed_all = sent_all / elapsed_all if elapsed_all > 0 else 0.0
|
||||
eta_sec = (total_size_all - sent_all) / avg_speed_all if avg_speed_all > 0 else 0.0
|
||||
eta_str = f"{int(eta_sec // 60):02d}:{int(eta_sec % 60):02d}"
|
||||
|
||||
# Ausgabezeile (\r überschreibt die aktuelle Zeile)
|
||||
sys.stdout.write(
|
||||
f"\r \033[90mProg: {perc_file:3.0f}% | Gesamt: {perc_all:3.0f}% | "
|
||||
f"{speed:6.1f} KB/s | ETA: {eta_str}\033[0m"
|
||||
@@ -63,20 +204,17 @@ def execute(conn, sources: list, target: str):
|
||||
sys.stdout.flush()
|
||||
|
||||
try:
|
||||
cmd = f"put {dest_path};{filesize};{crc32}\n"
|
||||
conn.serial.write(cmd.encode('utf-8'))
|
||||
conn.serial.flush()
|
||||
|
||||
# Binärtransfer mit unserem Handler
|
||||
conn.send_binary(filepath, progress_callback=progress_handler)
|
||||
|
||||
# Zeile nach Erfolg abschließen
|
||||
print(f"\r \033[32mFertig: {filename} übertragen. \033[0m")
|
||||
audio_crc32 = conn.put_file_data(remote_path, audio_data, progress_callback=progress_handler)
|
||||
|
||||
rewritten_blob, _ = _upsert_crc32_tag(tag_blob, audio_crc32)
|
||||
conn.set_tag_blob(remote_path, rewritten_blob)
|
||||
tag_note = " (CRC32-Tag gesetzt)"
|
||||
|
||||
print(f"\r \033[32mFertig: {filename} übertragen{tag_note}.{' ' * 20}\033[0m")
|
||||
except Exception as e:
|
||||
print(f"\n ❌ \033[31mFehler: {e}\033[0m")
|
||||
|
||||
total_duration = time.monotonic() - start_time_all
|
||||
total_kb = total_size_all / 1024
|
||||
avg_speed = total_kb / total_duration if total_duration > 0 else 0
|
||||
|
||||
total_kb = total_size_all / 1024.0
|
||||
avg_speed = total_kb / total_duration if total_duration > 0 else 0.0
|
||||
print(f"\nÜbertragung abgeschlossen: {total_kb:.1f} KB in {total_duration:.1f}s ({avg_speed:.1f} KB/s)")
|
||||
@@ -1,10 +1,11 @@
|
||||
# core/commands/mkdir.py
|
||||
# core/commands/reboot.py
|
||||
from core.connection import BuzzerError
|
||||
|
||||
|
||||
def execute(conn):
|
||||
"""Startet den Buzzer neu."""
|
||||
"""Startet den Buzzer per Binary-Protokoll neu."""
|
||||
try:
|
||||
conn.send_command(f"reboot")
|
||||
print(f"🔄 Buzzer wird neu gestartet.")
|
||||
conn.reboot_device()
|
||||
print("🔄 Buzzer wird neu gestartet.")
|
||||
except BuzzerError as e:
|
||||
print(f"❌ Fehler beim Neustarten des Buzzers: {e}")
|
||||
@@ -17,7 +17,7 @@ def _delete_recursive(conn, nodes):
|
||||
def _try_rm(conn, path, is_dir=False):
|
||||
icon = "📁" if is_dir else "📄"
|
||||
try:
|
||||
conn.send_command(f"rm {path}")
|
||||
conn.rm(path)
|
||||
print(f" 🗑️ {icon} Gelöscht: {path}")
|
||||
except BuzzerError as e:
|
||||
print(f" ❌ Fehler bei {path}: {e}")
|
||||
@@ -53,21 +53,16 @@ def execute(conn, path: str, recursive: bool = False):
|
||||
|
||||
# 2. Rekursives Löschen (-r)
|
||||
if recursive:
|
||||
print(f"Sammle Dateibaum für rekursives Löschen von '{path}'...")
|
||||
tree = get_file_tree(conn, target_path=path, recursive=True)
|
||||
|
||||
if len(tree) == 1 and tree[0].get("type") == "E":
|
||||
print(f"❌ Pfad nicht gefunden oder Fehler: {tree[0]['name']}")
|
||||
return
|
||||
|
||||
if not tree:
|
||||
print(f"Ordner '{path}' ist bereits leer.")
|
||||
else:
|
||||
_delete_recursive(conn, tree)
|
||||
|
||||
# 3. Standard-Löschen (Einzeldatei oder am Ende der Rekursion der leere Ordner)
|
||||
try:
|
||||
conn.rm_recursive(path)
|
||||
print(f"🗑️ '{path}' rekursiv gelöscht.")
|
||||
except BuzzerError as e:
|
||||
print(f"❌ Fehler beim rekursiven Löschen von '{path}': {e}")
|
||||
return
|
||||
|
||||
# 3. Standard-Löschen (Einzeldatei oder leeres Verzeichnis)
|
||||
try:
|
||||
conn.send_command(f"rm {path}")
|
||||
conn.rm(path)
|
||||
print(f"🗑️ '{path}' erfolgreich gelöscht.")
|
||||
except BuzzerError as e:
|
||||
print(f"❌ Fehler beim Löschen von '{path}': {e}")
|
||||
5
buzzer_tool/core/commands/stat.py
Normal file
5
buzzer_tool/core/commands/stat.py
Normal file
@@ -0,0 +1,5 @@
|
||||
def execute(conn, path: str):
|
||||
info = conn.stat(path)
|
||||
entry_type = "Ordner" if info["type"] == "D" else "Datei"
|
||||
print(f"{path}: {entry_type}, Größe: {info['size']} B")
|
||||
return info
|
||||
140
buzzer_tool/core/commands/tags.py
Normal file
140
buzzer_tool/core/commands/tags.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import json
|
||||
from core.connection import BuzzerError
|
||||
|
||||
TAG_TYPE_TO_KEY = {
|
||||
0x00: "description",
|
||||
0x01: "author",
|
||||
0x10: "crc32",
|
||||
0x20: "fileformat",
|
||||
}
|
||||
|
||||
KEY_TO_TAG_TYPE = {v: k for k, v in TAG_TYPE_TO_KEY.items()}
|
||||
VALID_TAG_KEYS = frozenset(KEY_TO_TAG_TYPE.keys())
|
||||
|
||||
def _u16le(value: int) -> bytes:
|
||||
return bytes((value & 0xFF, (value >> 8) & 0xFF))
|
||||
|
||||
|
||||
def _parse_tlv(blob: bytes) -> dict:
|
||||
tags = {}
|
||||
pos = 0
|
||||
|
||||
while pos < len(blob):
|
||||
if pos + 3 > len(blob):
|
||||
raise BuzzerError("Ungültiger Tag-Blob: TLV-Header abgeschnitten")
|
||||
|
||||
tag_type = blob[pos]
|
||||
tag_len = blob[pos + 1] | (blob[pos + 2] << 8)
|
||||
pos += 3
|
||||
|
||||
if pos + tag_len > len(blob):
|
||||
raise BuzzerError("Ungültiger Tag-Blob: TLV-Wert abgeschnitten")
|
||||
|
||||
value = blob[pos:pos + tag_len]
|
||||
pos += tag_len
|
||||
|
||||
key = TAG_TYPE_TO_KEY.get(tag_type, f"unknown_0x{tag_type:02x}")
|
||||
|
||||
if tag_type in (0x00, 0x01):
|
||||
tags[key] = value.decode("utf-8", errors="replace")
|
||||
elif tag_type == 0x10:
|
||||
if tag_len != 4:
|
||||
raise BuzzerError("Ungültiger crc32-Tag: len muss 4 sein")
|
||||
crc32 = int.from_bytes(value, byteorder="little", signed=False)
|
||||
tags[key] = f"0x{crc32:08x}"
|
||||
elif tag_type == 0x20:
|
||||
if tag_len != 5:
|
||||
raise BuzzerError("Ungültiger fileformat-Tag: len muss 5 sein")
|
||||
bits = value[0]
|
||||
samplerate = int.from_bytes(value[1:5], byteorder="little", signed=False)
|
||||
tags[key] = {"bits_per_sample": bits, "sample_rate": samplerate}
|
||||
else:
|
||||
tags[key] = value.hex()
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def _build_tlv(tags: dict) -> bytes:
|
||||
entries = []
|
||||
|
||||
if "description" in tags and tags["description"] is not None:
|
||||
data = str(tags["description"]).encode("utf-8")
|
||||
entries.append(bytes([KEY_TO_TAG_TYPE["description"]]) + _u16le(len(data)) + data)
|
||||
|
||||
if "author" in tags and tags["author"] is not None:
|
||||
data = str(tags["author"]).encode("utf-8")
|
||||
entries.append(bytes([KEY_TO_TAG_TYPE["author"]]) + _u16le(len(data)) + data)
|
||||
|
||||
if "crc32" in tags and tags["crc32"] is not None:
|
||||
crc_val = tags["crc32"]
|
||||
if isinstance(crc_val, str):
|
||||
crc_val = int(crc_val, 16) if crc_val.lower().startswith("0x") else int(crc_val)
|
||||
data = int(crc_val).to_bytes(4, byteorder="little", signed=False)
|
||||
entries.append(bytes([KEY_TO_TAG_TYPE["crc32"]]) + _u16le(4) + data)
|
||||
|
||||
if "fileformat" in tags and tags["fileformat"] is not None:
|
||||
ff = tags["fileformat"]
|
||||
if not isinstance(ff, dict):
|
||||
raise BuzzerError("fileformat muss ein Objekt sein: {bits_per_sample, sample_rate}")
|
||||
bits = int(ff.get("bits_per_sample", 16))
|
||||
samplerate = int(ff.get("sample_rate", 16000))
|
||||
data = bytes([bits]) + samplerate.to_bytes(4, byteorder="little", signed=False)
|
||||
entries.append(bytes([KEY_TO_TAG_TYPE["fileformat"]]) + _u16le(5) + data)
|
||||
|
||||
return b"".join(entries)
|
||||
|
||||
|
||||
def get_tags(conn, path: str) -> dict:
|
||||
blob = conn.get_tag_blob(path)
|
||||
if not blob:
|
||||
return {}
|
||||
return _parse_tlv(blob)
|
||||
|
||||
|
||||
def write_tags(conn, path: str, tags_update: dict) -> dict:
|
||||
unknown_keys = [k for k in tags_update.keys() if k not in VALID_TAG_KEYS]
|
||||
if unknown_keys:
|
||||
unknown_str = ", ".join(sorted(str(k) for k in unknown_keys))
|
||||
valid_str = ", ".join(sorted(VALID_TAG_KEYS))
|
||||
raise BuzzerError(
|
||||
f"Unbekannter Tag-Key in write_tags: {unknown_str}. Erlaubte Keys: {valid_str}"
|
||||
)
|
||||
|
||||
current = get_tags(conn, path)
|
||||
merged = dict(current)
|
||||
|
||||
for key, value in tags_update.items():
|
||||
if value is None:
|
||||
merged.pop(key, None)
|
||||
else:
|
||||
merged[key] = value
|
||||
|
||||
blob = _build_tlv(merged)
|
||||
conn.set_tag_blob(path, blob)
|
||||
return merged
|
||||
|
||||
|
||||
def remove_tag(conn, path: str, key: str) -> dict:
|
||||
current = get_tags(conn, path)
|
||||
current.pop(key, None)
|
||||
blob = _build_tlv(current)
|
||||
conn.set_tag_blob(path, blob)
|
||||
return current
|
||||
|
||||
|
||||
def parse_tags_json_input(raw: str) -> dict:
|
||||
text = raw.strip()
|
||||
if text.startswith("@"):
|
||||
file_path = text[1:]
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
raise BuzzerError(f"Ungültiges JSON für write_tags: {e}")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise BuzzerError("write_tags erwartet ein JSON-Objekt.")
|
||||
|
||||
return data
|
||||
Reference in New Issue
Block a user