Files
PiCopy/app.py

2795 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""PiCopy v2 - USB Copy Service mit WiFi-Fallback AP"""
import os
import re
import json
import shutil
import logging
import threading
import subprocess
import time
import uuid as _uuid_mod
import urllib.request as _urlreq
import urllib.error as _urlerr
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, request
app = Flask(__name__)
RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main'
VERSION_FILE = Path(__file__).with_name('version.txt')
def load_installed_version():
try:
return VERSION_FILE.read_text(encoding='utf-8').strip() or '1.0.4'
except Exception:
return 'X.X.X'
VERSION = load_installed_version()
BASE_DIR = Path('/opt/picopy')
CONFIG_FILE = BASE_DIR / 'config.json'
STATE_FILE = BASE_DIR / 'state.json'
LOG_DIR = BASE_DIR / 'logs'
LOG_FILE = LOG_DIR / 'picopy.log'
LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()]
)
log = logging.getLogger('picopy')
NM_AP_CON = 'PiCopy-AP'
NM_CLIENT_CON = 'PiCopy-WiFi'
WIFI_BOOT_WAIT = 25 # Sekunden warten beim Start bevor AP gestartet wird
DEFAULT_CONFIG = {
# USB
'source_ports': [], # [{port, label}, ...]
'source_port': None, 'source_label': '', # Migration legacy
'dest_port': None, 'dest_label': '',
'folder_format': '%Y-%m-%d', 'add_time': True,
'subfolder': True, 'auto_copy': True,
'file_filter': '', 'exclude_system': True,
'duplicate_handling': 'skip',
'verify_checksum': False, 'delete_source': False,
# WiFi
'wifi_ssid': '', 'wifi_password': '',
'ap_ssid': 'PiCopy', 'ap_password': 'PiCopy,',
# WireGuard
'wireguard_auto': False,
}
# -- Persistenter Kopierstatus -----------------------------------------------
copy_state = {
'running': False, 'progress': 0,
'total': 0, 'done': 0, 'current': '',
'error': None, 'last_copy': None, 'logs': [],
'bytes_total': 0, 'bytes_done': 0,
'start_ts': None, 'eta_sec': None, 'speed_bps': 0,
'phase': 'idle',
}
copy_lock = threading.Lock()
_copy_thread: threading.Thread | None = None
def load_state():
global copy_state
try:
if STATE_FILE.exists():
saved = json.loads(STATE_FILE.read_text(encoding='utf-8'))
saved['running'] = False
saved['current'] = ''
copy_state.update(saved)
except (json.JSONDecodeError, ValueError) as e:
log.warning(f'state.json korrupt ({e}), starte mit leerem Zustand')
try: STATE_FILE.rename(STATE_FILE.with_suffix('.corrupt'))
except Exception: pass
except Exception as e:
log.warning(f'state.json nicht lesbar: {e}')
def save_state():
try:
with copy_lock:
data = dict(copy_state)
_atomic_write(STATE_FILE, json.dumps(data))
except Exception:
pass
# -- WiFi Status -------------------------------------------------------------
wifi_state = {
'mode': 'unknown', # 'client' | 'ap' | 'disconnected'
'ssid': '',
'ip': '',
}
wifi_lock = threading.Lock()
# -- Config -------------------------------------------------------------------
def load_cfg():
cfg = DEFAULT_CONFIG.copy()
try:
if CONFIG_FILE.exists():
cfg.update(json.loads(CONFIG_FILE.read_text(encoding='utf-8')))
except (json.JSONDecodeError, ValueError) as e:
log.error(f'config.json korrupt ({e}), verwende Standardwerte')
try: CONFIG_FILE.rename(CONFIG_FILE.with_suffix('.corrupt'))
except Exception: pass
except Exception as e:
log.warning(f'config.json nicht lesbar: {e}')
return cfg
def save_cfg(cfg):
_atomic_write(CONFIG_FILE, json.dumps(cfg, indent=2))
# -- WiFi Hilfsfunktionen -----------------------------------------------------
def nm(*args):
return subprocess.run(['nmcli'] + list(args),
capture_output=True, text=True, timeout=20)
def get_wlan0_info():
r = nm('-t', '-f', 'DEVICE,STATE,CONNECTION', 'dev')
for line in r.stdout.splitlines():
parts = line.split(':')
if parts and parts[0] == 'wlan0':
return {
'state': parts[1] if len(parts) > 1 else '',
'connection': ':'.join(parts[2:]) if len(parts) > 2 else '',
}
return {'state': '', 'connection': ''}
def get_wifi_ip():
r = nm('-t', '-f', 'IP4.ADDRESS', 'dev', 'show', 'wlan0')
for line in r.stdout.splitlines():
if 'IP4.ADDRESS' in line:
ip = line.split(':')[-1].split('/')[0].strip()
if ip:
return ip
return ''
def is_client_connected():
info = get_wlan0_info()
return (info['state'] == 'connected'
and info['connection']
and NM_AP_CON not in info['connection'])
def is_ap_active():
r = nm('-t', '-f', 'NAME,STATE', 'con', 'show', '--active')
return any(NM_AP_CON in l and 'activated' in l for l in r.stdout.splitlines())
def start_ap(ssid, password):
log.info(f'Starte AP: {ssid}')
nm('con', 'delete', NM_AP_CON)
time.sleep(1)
r = nm('dev', 'wifi', 'hotspot',
'ifname', 'wlan0',
'ssid', ssid,
'password', password,
'con-name', NM_AP_CON)
ok = r.returncode == 0
if ok:
log.info('AP gestartet')
else:
log.error(f'AP Fehler: {r.stderr}')
return ok
def stop_ap():
log.info('Stoppe AP')
nm('con', 'down', NM_AP_CON)
def connect_client_wifi(ssid, password):
log.info(f'Verbinde mit WiFi: {ssid}')
# Bestehende PiCopy-WiFi Verbindung löschen
nm('con', 'delete', NM_CLIENT_CON)
time.sleep(1)
r = nm('dev', 'wifi', 'connect', ssid,
'password', password,
'name', NM_CLIENT_CON,
'ifname', 'wlan0')
ok = r.returncode == 0
if ok:
log.info(f'Verbunden mit {ssid}')
else:
log.error(f'WiFi-Verbindung fehlgeschlagen: {r.stderr.strip()}')
return ok
def scan_wifi_networks():
nm('dev', 'wifi', 'rescan')
time.sleep(2)
r = nm('-t', '-f', 'SSID,SIGNAL,SECURITY', 'dev', 'wifi', 'list')
seen, nets = set(), []
for line in r.stdout.splitlines():
parts = line.split(':')
if len(parts) >= 2:
ssid = parts[0].strip()
signal = parts[1].strip() if len(parts) > 1 else '0'
security = ':'.join(parts[2:]).strip() if len(parts) > 2 else ''
if ssid and ssid not in seen:
seen.add(ssid)
nets.append({'ssid': ssid, 'signal': int(signal) if signal.isdigit() else 0, 'security': security})
return sorted(nets, key=lambda x: -x['signal'])
# -- WiFi Monitor Thread -------------------------------------------------------
def update_wifi_state():
info = get_wlan0_info()
if info['state'] == 'connected':
if NM_AP_CON in info['connection']:
with wifi_lock:
wifi_state.update(mode='ap',
ssid=load_cfg().get('ap_ssid', 'PiCopy'),
ip='10.42.0.1')
else:
ip = get_wifi_ip()
with wifi_lock:
wifi_state.update(mode='client',
ssid=info['connection'],
ip=ip)
else:
with wifi_lock:
wifi_state.update(mode='disconnected', ssid='', ip='')
def wifi_monitor():
log.info(f'WiFi-Monitor: warte {WIFI_BOOT_WAIT}s auf Verbindung...')
time.sleep(WIFI_BOOT_WAIT)
while True:
try:
update_wifi_state()
with wifi_lock:
mode = wifi_state['mode']
if mode == 'disconnected':
cfg = load_cfg()
ssid = cfg.get('wifi_ssid', '')
pw = cfg.get('wifi_password', '')
connected = False
if ssid:
connected = connect_client_wifi(ssid, pw)
if connected:
time.sleep(5)
update_wifi_state()
if not connected:
ap_ssid = cfg.get('ap_ssid', 'PiCopy')
ap_pw = cfg.get('ap_password', 'PiCopy,')
if start_ap(ap_ssid, ap_pw):
time.sleep(3)
with wifi_lock:
wifi_state.update(mode='ap', ssid=ap_ssid, ip='10.42.0.1')
except Exception as e:
log.error(f'WiFi-Monitor Fehler: {e}')
time.sleep(30)
# -- WireGuard VPN -------------------------------------------------------------
WG_CONF = Path('/etc/wireguard/picopy.conf')
WG_IFACE = 'picopy'
def wg_is_installed():
return shutil.which('wg-quick') is not None
wg_state = {
'connected': False,
'ip': '',
'peer': '',
'error': None,
'has_config': False,
'installed': False,
'pkg_running': False,
'pkg_action': '',
'pkg_error': None,
}
wg_lock = threading.Lock()
def wg_update_state():
inst = wg_is_installed()
has_conf = WG_CONF.exists()
if not inst:
with wg_lock:
wg_state.update(installed=False, connected=False, ip='', peer='',
has_config=has_conf)
return
r = subprocess.run(['wg', 'show', WG_IFACE],
capture_output=True, text=True, timeout=5)
if r.returncode != 0:
with wg_lock:
wg_state.update(installed=True, connected=False, ip='', peer='',
has_config=has_conf)
return
ip_r = subprocess.run(['ip', '-4', 'addr', 'show', WG_IFACE],
capture_output=True, text=True, timeout=5)
ip = ''
for line in ip_r.stdout.splitlines():
if line.strip().startswith('inet '):
ip = line.strip().split()[1].split('/')[0]
break
peer = ''
for line in r.stdout.splitlines():
if line.startswith('peer:'):
peer = line.split(':', 1)[-1].strip()
break
with wg_lock:
wg_state.update(installed=True, connected=True, ip=ip, peer=peer,
error=None, has_config=has_conf)
def wg_connect():
if not WG_CONF.exists():
with wg_lock:
wg_state['error'] = 'Keine Konfiguration vorhanden'
return False
r = subprocess.run(['wg-quick', 'up', WG_IFACE],
capture_output=True, text=True, timeout=30)
if r.returncode == 0:
time.sleep(1)
wg_update_state()
log.info('WireGuard verbunden')
return True
lines = r.stderr.strip().splitlines() if r.stderr.strip() else []
real_errors = [l for l in lines if not l.strip().startswith('[#]')]
err = (real_errors[-1] if real_errors else lines[-1] if lines else 'Unbekannter Fehler')
if 'resolvconf' in err and 'not found' in err:
err = 'resolvconf fehlt - bitte WireGuard deinstallieren und neu installieren (openresolv wird dann mitinstalliert)'
with wg_lock:
wg_state.update(connected=False, error=err)
log.error(f'WireGuard Fehler: {err}')
return False
def wg_disconnect():
r = subprocess.run(['wg-quick', 'down', WG_IFACE],
capture_output=True, text=True, timeout=15)
with wg_lock:
wg_state.update(connected=False, ip='', peer='', error=None)
log.info('WireGuard getrennt')
return r.returncode == 0
def _wg_apt(action: str, packages: list):
"""Führt apt-get install/remove aus und aktualisiert pkg_state."""
with wg_lock:
if wg_state['pkg_running']:
return
wg_state.update(pkg_running=True, pkg_action=action, pkg_error=None)
try:
cmd = ['apt-get', action, '-y'] + packages
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300,
env={**os.environ, 'DEBIAN_FRONTEND': 'noninteractive'})
if r.returncode != 0:
err = (r.stderr.strip().splitlines()[-1]
if r.stderr.strip() else f'apt-get {action} fehlgeschlagen')
log.error(f'WireGuard apt {action}: {err}')
with wg_lock:
wg_state['pkg_error'] = err
else:
log.info(f'WireGuard apt {action} abgeschlossen')
except Exception as e:
with wg_lock:
wg_state['pkg_error'] = str(e)
finally:
with wg_lock:
wg_state['pkg_running'] = False
wg_state['pkg_action'] = ''
wg_update_state()
def wg_install():
_wg_apt('install', ['wireguard', 'wireguard-tools', 'openresolv'])
def wg_uninstall():
wg_disconnect()
_wg_apt('remove', ['wireguard', 'wireguard-tools'])
def wg_save_config(content: str):
try:
WG_CONF.parent.mkdir(parents=True, exist_ok=True)
WG_CONF.write_text(content, encoding='utf-8')
WG_CONF.chmod(0o600)
return True, ''
except Exception as e:
return False, str(e)
def wg_monitor():
while True:
try:
wg_update_state()
except Exception:
pass
time.sleep(10)
# -- USB Geräteerkennung -------------------------------------------------------
def usb_port_of(dev_name):
"""Gibt den physischen USB-Port-Pfad zurück (z.B. '2-2').
Primär via udevadm, Fallback via sysfs."""
# Primär: udevadm (zuverlässiger)
try:
r = subprocess.run(
['udevadm', 'info', '-q', 'path', '-n', f'/dev/{dev_name}'],
capture_output=True, text=True, timeout=5
)
if r.returncode == 0:
port = None
for seg in r.stdout.strip().split('/'):
if re.fullmatch(r'\d+-[\d.]+', seg):
port = seg
if port:
return port
except Exception:
pass
# Fallback: sysfs readlink
try:
real = Path(f'/sys/block/{dev_name}').resolve()
port = None
for seg in str(real).split('/'):
if re.fullmatch(r'\d+[\-\d.]+', seg) and ':' not in seg:
port = seg
return port
except Exception:
return None
def usb_devices():
try:
out = subprocess.check_output(
['lsblk', '-J', '-o', 'NAME,TRAN,MOUNTPOINT,LABEL,SIZE,MODEL'],
timeout=10, text=True
)
data = json.loads(out)
except Exception as e:
log.error(f'lsblk: {e}')
return []
result = []
for bd in data.get('blockdevices', []):
if bd.get('tran') != 'usb':
continue
name = bd['name']
port = usb_port_of(name)
model = (bd.get('label') or bd.get('model') or name).strip()
for child in (bd.get('children') or []):
result.append({
'device': f'/dev/{child["name"]}',
'usb_port': port,
'mount': child.get('mountpoint') or '',
'label': (child.get('label') or model).strip(),
'size': child.get('size') or bd.get('size') or '',
})
if not bd.get('children'):
result.append({
'device': f'/dev/{name}',
'usb_port': port,
'mount': bd.get('mountpoint') or '',
'label': model,
'size': bd.get('size') or '',
})
return result
def ensure_mount(dev_info):
mp = dev_info.get('mount')
if mp:
return mp, False
dev = dev_info['device']
mp = f'/mnt/picopy{dev.replace("/","_")}'
os.makedirs(mp, exist_ok=True)
r = subprocess.run(['mount', dev, mp], capture_output=True)
if r.returncode:
log.error(f'mount failed: {r.stderr.decode()}')
return None, False
return mp, True
# -- Kopier-Logik --------------------------------------------------------------
def add_log(msg):
log.info(msg)
with copy_lock:
copy_state['logs'].append({'t': datetime.now().strftime('%H:%M:%S'), 'm': msg})
copy_state['logs'] = copy_state['logs'][-200:]
import hashlib as _hashlib
SYSTEM_EXCLUDES = {
'.DS_Store', 'Thumbs.db', 'thumbs.db', 'desktop.ini',
'.Spotlight-V100', '.Trashes', '.fseventsd', '.TemporaryItems',
'.VolumeIcon.icns', 'RECYCLER', '$RECYCLE.BIN',
'System Volume Information', '.DocumentRevisions-V100',
}
def _should_copy(f: Path, cfg: dict) -> bool:
if cfg.get('exclude_system'):
for part in f.parts:
if part in SYSTEM_EXCLUDES:
return False
if f.name.startswith('._'):
return False
filt = cfg.get('file_filter', '').strip()
if filt:
allowed = {e.strip().lower().lstrip('.') for e in filt.split(',') if e.strip()}
if f.suffix.lower().lstrip('.') not in allowed:
return False
return True
def _unique_path(p: Path) -> Path:
stem, suffix, parent = p.stem, p.suffix, p.parent
i = 1
while True:
candidate = parent / f'{stem}_({i}){suffix}'
if not candidate.exists():
return candidate
i += 1
def _file_md5(p: Path) -> str:
h = _hashlib.md5()
with open(p, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
return h.hexdigest()
def _atomic_write(path: Path, content: str) -> None:
"""Schreibt atomar: erst .tmp, dann os.replace() - sicher bei Stromausfall."""
tmp = path.with_suffix(path.suffix + '.tmp')
try:
tmp.write_text(content, encoding='utf-8')
with open(tmp, 'rb') as fh:
os.fsync(fh.fileno()) # Daten wirklich auf Datenträger schreiben
os.replace(str(tmp), str(path)) # Atomares Umbenennen (POSIX-Garantie)
except Exception:
try: tmp.unlink(missing_ok=True)
except Exception: pass
raise
def cleanup_stale_mounts() -> None:
"""Bereinigt beim Start hängen gebliebene PiCopy-Mounts (z.B. nach Stromausfall)."""
try:
with open('/proc/mounts') as fh:
mps = [line.split()[1] for line in fh if '/mnt/picopy' in line]
for mp in mps:
log.info(f'Bereinige veralteten Mount: {mp}')
subprocess.run(['umount', '-l', mp], capture_output=True)
except Exception as e:
log.warning(f'Stale-Mount-Bereinigung fehlgeschlagen: {e}')
def _fmt_bytes(b):
if b < 1024: return f'{b} B'
if b < 1024**2: return f'{b/1024:.1f} KB'
if b < 1024**3: return f'{b/1024**2:.1f} MB'
return f'{b/1024**3:.2f} GB'
def _resolve_source_ports(cfg) -> list:
"""Gibt source_ports als [{port, label}]-Liste zurück. Migriert altes source_port-Feld."""
ports = cfg.get('source_ports') or []
if not ports and cfg.get('source_port'):
ports = [{'port': cfg['source_port'], 'label': cfg.get('source_label', '')}]
return ports
def do_copy(src_devs, dst_dev, cfg):
"""Kopiert von einer oder mehreren Quellen auf ein Ziel."""
dst_mp = None
dst_owned = False
src_mounts = [] # [(src_dev, src_mp, src_owned)]
_upload_thread = None
try:
with copy_lock:
copy_state.update(running=True, progress=0, error=None,
done=0, total=0, logs=[], current='',
bytes_total=0, bytes_done=0,
start_ts=time.time(), eta_sec=None, speed_bps=0,
phase='copy')
save_state()
n = len(src_devs)
add_log(f'Kopiervorgang gestartet ({n} Quelle{"n" if n != 1 else ""})')
dst_mp, dst_owned = ensure_mount(dst_dev)
if not dst_mp:
raise RuntimeError(f'Ziel nicht mountbar: {dst_dev["device"]}')
add_log(f'Ziel: {dst_mp} ({dst_dev["label"]})')
ts = datetime.now()
date_str = ts.strftime(cfg['folder_format'])
if cfg.get('add_time'):
date_str += '_' + ts.strftime('%H%M%S')
# -- Alle Quellen mounten & Dateien sammeln -------------------------
# source_data: [(src_dev, src_path, files, dst_dir, incomplete_marker)]
source_data = []
total = 0
bytes_total = 0
for src_dev in src_devs:
with copy_lock:
cancelled = not copy_state['running']
if cancelled:
add_log('Abgebrochen')
return
src_mp_i, src_owned_i = ensure_mount(src_dev)
src_mounts.append((src_dev, src_mp_i, src_owned_i))
if not src_mp_i:
add_log(f'Quelle nicht mountbar: {src_dev["device"]} - übersprungen')
continue
add_log(f'Quelle: {src_mp_i} ({src_dev["label"]})')
src_path = Path(src_mp_i)
all_files = [f for f in src_path.rglob('*') if f.is_file()]
files = [f for f in all_files if _should_copy(f, cfg)]
n_filtered = len(all_files) - len(files)
if n_filtered:
add_log(f'{n_filtered} Dateien gefiltert ({src_dev["label"]})')
label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source'))
dst_dir_i = Path(dst_mp) / date_str
if cfg.get('subfolder'):
dst_dir_i = dst_dir_i / label
dst_dir_i.mkdir(parents=True, exist_ok=True)
add_log(f'Zielordner: {dst_dir_i}')
for stale in dst_dir_i.rglob('*.picopy_tmp'):
stale.unlink(missing_ok=True)
incomplete_marker_i = dst_dir_i / '.picopy_incomplete'
incomplete_marker_i.write_text(json.dumps({
'started': datetime.now().isoformat(),
'source': src_dev.get('label', ''),
}))
total += len(files)
bytes_total += sum(f.stat().st_size for f in files)
source_data.append((src_dev, src_path, files, dst_dir_i, incomplete_marker_i))
with copy_lock:
copy_state['total'] = total
copy_state['bytes_total'] = bytes_total
add_log(f'{total} Dateien gesamt ({_fmt_bytes(bytes_total)})')
save_state()
# -- Phase 1: Kopieren (alle Quellen) --------------------------------
dup_mode = cfg.get('duplicate_handling', 'skip')
all_copied_pairs = []
skipped = 0
io_errors = 0
global_done = 0
for src_dev_i, src_path_i, files_i, dst_dir_i, _ in source_data:
if len(src_devs) > 1:
add_log(f'Kopiere: {src_dev_i["label"]}')
for f in files_i:
with copy_lock:
cancelled = not copy_state['running']
if cancelled:
add_log('Abgebrochen')
return
global_done += 1
rel = f.relative_to(src_path_i)
dst_f = dst_dir_i / rel
try:
dst_f.parent.mkdir(parents=True, exist_ok=True)
except OSError as mkdir_err:
io_errors += 1
add_log(f'⚠ Verzeichnis nicht erstellbar ({dst_f.parent.name}): {mkdir_err}')
with copy_lock:
copy_state.update(done=global_done,
progress=int(global_done/total*100) if total else 100,
current=str(f.name))
continue
if dst_f.exists():
if dup_mode == 'skip':
if dst_f.stat().st_size == f.stat().st_size:
skipped += 1
with copy_lock:
copy_state.update(done=global_done,
progress=int(global_done/total*100) if total else 100,
current=str(f.name))
continue
else:
add_log(f'Unvollständige Datei, wird neu kopiert: {f.name}')
elif dup_mode == 'rename':
dst_f = _unique_path(dst_f)
fsize = f.stat().st_size
tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp')
try:
shutil.copy2(f, tmp_f)
os.replace(str(tmp_f), str(dst_f))
except OSError as copy_err:
try: tmp_f.unlink(missing_ok=True)
except Exception: pass
io_errors += 1
add_log(f'⚠ Fehler bei {f.name}: {copy_err}')
with copy_lock:
copy_state.update(done=global_done,
progress=int(global_done/total*100) if total else 100,
current=str(f.name))
continue
all_copied_pairs.append((f, dst_f))
with copy_lock:
copy_state['bytes_done'] += fsize
bd = copy_state['bytes_done']
bt = copy_state['bytes_total']
elapsed = time.time() - copy_state['start_ts']
speed = bd / elapsed if elapsed > 1 else 0
eta = int((bt - bd) / speed) if speed > 0 and bt > bd else 0
copy_state.update(done=global_done,
progress=int(global_done/total*100) if total else 100,
current=str(f.name), speed_bps=int(speed), eta_sec=eta)
if global_done % 20 == 0:
save_state()
msg_parts = [f'{len(all_copied_pairs)} kopiert']
if skipped:
msg_parts.append(f'{skipped} übersprungen')
if io_errors:
msg_parts.append(f'{io_errors} Fehler (I/O)')
# -- Phase 2: Verifizieren ------------------------------------------
verify_errors = 0
verified_pairs = list(all_copied_pairs)
if cfg.get('verify_checksum') and all_copied_pairs:
with copy_lock:
copy_state.update(phase='verify', progress=0, done=0,
total=len(all_copied_pairs), current='',
eta_sec=None, speed_bps=0)
add_log(f'Verifiziere {len(all_copied_pairs)} Dateien...')
verified_pairs = []
for i, (src_f, dst_f) in enumerate(all_copied_pairs):
with copy_lock:
cancelled = not copy_state['running']
if not cancelled:
copy_state.update(done=i+1,
progress=int((i+1)/len(all_copied_pairs)*100),
current=src_f.name)
if cancelled:
add_log('Abgebrochen')
return
if _file_md5(src_f) == _file_md5(dst_f):
verified_pairs.append((src_f, dst_f))
else:
verify_errors += 1
add_log(f'⚠ Prüfsummenfehler: {src_f.name}')
try: dst_f.unlink()
except Exception: pass
if verify_errors:
msg_parts.append(f'{verify_errors} Prüfsummenfehler!')
add_log(f'Verifizierung: {verify_errors} Fehler!')
else:
add_log(f'Alle {len(verified_pairs)} Dateien verifiziert ✓')
# -- Phase 3: Quelle löschen ----------------------------------------
if cfg.get('delete_source') and verified_pairs:
if verify_errors:
add_log('Quelldateien NICHT gelöscht (Prüfsummenfehler)')
else:
with copy_lock:
copy_state.update(phase='delete', current='')
add_log(f'Lösche {len(verified_pairs)} Quelldateien...')
del_errors = 0
for src_f, _ in verified_pairs:
try:
src_f.unlink()
except Exception as e:
del_errors += 1
log.warning(f'Löschen fehlgeschlagen: {src_f}: {e}')
if del_errors:
msg_parts.append(f'{del_errors} Löschfehler')
else:
add_log('Quelle geleert ✓')
subprocess.run(['sync'], capture_output=True)
for _, _, _, _, incomplete_marker_i in source_data:
try: incomplete_marker_i.unlink(missing_ok=True)
except Exception: pass
with copy_lock:
copy_state['last_copy'] = datetime.now().isoformat()
add_log('Fertig! ' + ', '.join(msg_parts))
dst_dir_root = Path(dst_mp) / date_str
_upload_thread = threading.Thread(target=run_uploads, args=(dst_dir_root, cfg), daemon=True)
_upload_thread.start()
except Exception as e:
log.exception('Copy failed')
with copy_lock:
copy_state['error'] = str(e)
add_log(f'Fehler: {e}')
finally:
# Erst warten bis NAS-Upload fertig, dann erst unmounten
if _upload_thread is not None and _upload_thread.is_alive():
add_log('Warte auf NAS-Upload vor Unmount...')
_upload_thread.join()
subprocess.run(['sync'], capture_output=True)
for _, src_mp_i, src_owned_i in src_mounts:
if src_owned_i and src_mp_i:
subprocess.run(['umount', src_mp_i], capture_output=True)
if dst_owned and dst_mp:
subprocess.run(['umount', dst_mp], capture_output=True)
with copy_lock:
copy_state['running'] = False
copy_state['current'] = ''
copy_state['phase'] = 'idle'
save_state()
def check_auto_copy():
cfg = load_cfg()
src_ports = _resolve_source_ports(cfg)
if not cfg.get('auto_copy') or not src_ports or not cfg.get('dest_port'):
return
with copy_lock:
if copy_state['running'] or copy_state['error']:
return
devs = usb_devices()
srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports]
srcs = [s for s in srcs if s is not None]
dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None)
if srcs and dst:
log.info(f'Auto-Copy: {len(srcs)} Quelle(n) und Ziel verbunden')
threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True).start()
def usb_monitor():
try:
import pyudev
ctx = pyudev.Context()
mon = pyudev.Monitor.from_netlink(ctx)
mon.filter_by(subsystem='block', device_type='disk')
for dev in iter(mon.poll, None):
if dev.action == 'add':
log.info(f'USB eingesteckt: {dev.device_node}')
threading.Timer(3.0, check_auto_copy).start()
except ImportError:
log.warning('pyudev nicht verfügbar')
# -- Upload-Ziele (rclone) -----------------------------------------------------
RCLONE_CONF = BASE_DIR / 'rclone.conf'
upload_state = {
'running': False,
'current': '',
'results': [],
}
upload_lock = threading.Lock()
def _rclone(*args, timeout=60):
try:
return subprocess.run(
['rclone', '--config', str(RCLONE_CONF)] + list(args),
capture_output=True, text=True, timeout=timeout
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'Timeout nach {timeout}s')
except Exception as e:
return subprocess.CompletedProcess(args, 1, stdout='', stderr=str(e))
def _rclone_obscure(pw):
r = subprocess.run(['rclone', 'obscure', pw],
capture_output=True, text=True, timeout=10)
return r.stdout.strip()
def _remote_name(tid):
return f'picopy_{tid}'
def configure_smb_remote(tid, host, share, user, pw):
rn = _remote_name(tid)
_rclone('config', 'delete', rn)
args = ['config', 'create', rn, 'smb', f'host={host}', f'share={share}']
if user:
args += [f'user={user}']
if pw:
args += [f'pass={_rclone_obscure(pw)}']
r = _rclone(*args)
return r.returncode == 0, r.stderr.strip()
def delete_remote(tid):
_rclone('config', 'delete', _remote_name(tid))
def test_remote(tid):
remote = _remote_name(tid)
# 1. Verbindung prüfen
r = _rclone('lsd', f'{remote}:', timeout=15)
if r.returncode != 0:
err = r.stderr.strip().splitlines()[-1] if r.stderr.strip() else 'Verbindung fehlgeschlagen'
return False, f'Verbindung: {err}'
# 2. Schreibzugriff prüfen: Testverzeichnis anlegen + sofort löschen
test_dir = f'{remote}:.picopy_writetest'
rw = _rclone('mkdir', test_dir, timeout=15)
if rw.returncode != 0:
err = rw.stderr.strip().splitlines()[-1] if rw.stderr.strip() else 'Schreiben fehlgeschlagen'
return False, f'Kein Schreibzugriff: {err}'
_rclone('rmdir', test_dir, timeout=10)
return True, ''
def run_uploads(local_dir: Path, cfg: dict):
"""Lädt local_dir zu allen aktiven Fernzielen hoch. Läuft im Background-Thread."""
targets = [t for t in cfg.get('upload_targets', []) if t.get('enabled')]
if not targets:
return
with upload_lock:
upload_state.update(running=True, results=[], current='')
for t in targets:
name = t.get('name', t['id'])
with upload_lock:
upload_state['current'] = name
add_log(f'Upload >> {name}...')
remote = _remote_name(t['id'])
dest_root = t.get('dest_path', 'PiCopy').strip('/')
dest = f'{remote}:{dest_root}'
# 1. Verbindung prüfen
conn = _rclone('lsd', f'{remote}:', timeout=15)
if conn.returncode != 0:
lines = [l for l in conn.stderr.strip().splitlines() if l.strip()]
err = lines[-1] if lines else 'NAS nicht erreichbar'
add_log(f'Upload {name}: ✗ NAS nicht erreichbar - {err}')
add_log(f'Upload {name}: Hinweis: Prüfe ob VPN aktiv ist und den NAS blockiert')
with upload_lock:
upload_state['results'].append({'name': name, 'ok': False,
'msg': f'NAS nicht erreichbar (VPN aktiv?): {err}'})
continue
# 2. Zielordner anlegen
_rclone('mkdir', dest, timeout=30)
# 3. Kopieren
r = _rclone('copy', str(local_dir), dest,
'--create-empty-src-dirs',
'--retries', '2',
timeout=7200)
ok = r.returncode == 0
err = ''
if not ok:
lines = [l for l in r.stderr.strip().splitlines() if l.strip()]
err = lines[-1] if lines else 'Unbekannter Fehler'
with upload_lock:
upload_state['results'].append({'name': name, 'ok': ok, 'msg': err})
add_log(f'Upload {name}: {"✓ OK" if ok else "✗ Fehler - " + err}')
with upload_lock:
upload_state['running'] = False
upload_state['current'] = ''
# -- Flask Routes --------------------------------------------------------------
@app.route('/')
def index():
return HTML.replace('__PICOPY_VERSION__', VERSION)
@app.route('/api/devices')
def r_devices():
return jsonify(usb_devices())
@app.route('/api/config', methods=['GET', 'POST'])
def r_config():
if request.method == 'POST':
cfg = load_cfg()
cfg.update(request.get_json(force=True))
save_cfg(cfg)
return jsonify(ok=True)
return jsonify(load_cfg())
@app.route('/api/status')
def r_status():
with copy_lock:
cs = dict(copy_state)
with wifi_lock:
ws = dict(wifi_state)
with wg_lock:
wgs = dict(wg_state)
return jsonify(copy=cs, wifi=ws, vpn=wgs)
@app.route('/api/copy/start', methods=['POST'])
def r_start():
global _copy_thread
with copy_lock:
if copy_state['running']:
return jsonify(error='Bereits aktiv'), 400
if _copy_thread is not None and _copy_thread.is_alive():
return jsonify(error='Abbruch wird noch abgeschlossen - bitte kurz warten und erneut versuchen.'), 400
cfg = load_cfg()
devs = usb_devices()
body = request.get_json(force=True) or {}
wanted_ports = body.get('ports') # None = alle konfigurierten Quellen
src_ports = _resolve_source_ports(cfg)
srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports]
srcs = [s for s in srcs if s is not None]
if wanted_ports is not None:
srcs = [s for s in srcs if s['usb_port'] in wanted_ports]
if not srcs: return jsonify(error='Keine Quellgeräte gefunden (Ports nicht verbunden)'), 400
dst = next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None)
if not dst: return jsonify(error='Zielgerät nicht gefunden'), 400
_copy_thread = threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True)
_copy_thread.start()
return jsonify(ok=True)
@app.route('/api/copy/cancel', methods=['POST'])
def r_cancel():
with copy_lock:
copy_state['running'] = False
return jsonify(ok=True)
@app.route('/api/wifi/scan')
def r_wifi_scan():
nets = scan_wifi_networks()
return jsonify(nets)
@app.route('/api/wifi/connect', methods=['POST'])
def r_wifi_connect():
data = request.get_json(force=True)
ssid = data.get('ssid', '').strip()
pw = data.get('password', '').strip()
if not ssid:
return jsonify(error='SSID fehlt'), 400
cfg = load_cfg()
cfg['wifi_ssid'] = ssid
cfg['wifi_password'] = pw
save_cfg(cfg)
def _connect():
ap_was_active = is_ap_active()
if ap_was_active:
stop_ap()
time.sleep(2)
ok = connect_client_wifi(ssid, pw)
if ok:
time.sleep(5)
update_wifi_state()
else:
if ap_was_active:
start_ap(cfg.get('ap_ssid', 'PiCopy'), cfg.get('ap_password', 'PiCopy,'))
update_wifi_state()
threading.Thread(target=_connect, daemon=True).start()
return jsonify(ok=True, msg='Verbindungsversuch gestartet')
@app.route('/api/wifi/ap', methods=['POST'])
def r_wifi_ap():
data = request.get_json(force=True)
ssid = data.get('ssid', '').strip()
pw = data.get('password', '').strip()
if not ssid or len(pw) < 8:
return jsonify(error='SSID fehlt oder Passwort zu kurz (min. 8 Zeichen)'), 400
cfg = load_cfg()
cfg['ap_ssid'] = ssid
cfg['ap_password'] = pw
save_cfg(cfg)
def _restart_ap():
if is_ap_active():
stop_ap()
time.sleep(2)
start_ap(ssid, pw)
time.sleep(3)
with wifi_lock:
wifi_state.update(mode='ap', ssid=ssid, ip='10.42.0.1')
threading.Thread(target=_restart_ap, daemon=True).start()
return jsonify(ok=True)
@app.route('/api/wifi/status')
def r_wifi_status():
with wifi_lock:
return jsonify(dict(wifi_state))
# -- WireGuard Routes ---------------------------------------------------------
@app.route('/api/wireguard/config', methods=['GET', 'POST'])
def r_wg_config():
if request.method == 'POST':
data = request.get_json(force=True)
content = data.get('content', '')
if not content.strip():
return jsonify(error='Konfiguration ist leer'), 400
ok, err = wg_save_config(content)
if not ok:
return jsonify(error=err), 500
auto = data.get('auto')
if auto is not None:
c = load_cfg()
c['wireguard_auto'] = bool(auto)
save_cfg(c)
with wg_lock:
wg_state['has_config'] = True
return jsonify(ok=True)
if WG_CONF.exists():
content = WG_CONF.read_text(encoding='utf-8')
masked = re.sub(r'(PrivateKey\s*=\s*)(.+)', r'\1****', content)
return jsonify(exists=True, config=masked)
return jsonify(exists=False, config='')
@app.route('/api/wireguard/connect', methods=['POST'])
def r_wg_connect():
threading.Thread(target=wg_connect, daemon=True).start()
return jsonify(ok=True, msg='Verbindungsversuch gestartet')
@app.route('/api/wireguard/disconnect', methods=['POST'])
def r_wg_disconnect():
ok = wg_disconnect()
return jsonify(ok=ok)
@app.route('/api/wireguard/install', methods=['POST'])
def r_wg_install():
with wg_lock:
if wg_state['pkg_running']:
return jsonify(error='Bereits aktiv'), 400
threading.Thread(target=wg_install, daemon=True).start()
return jsonify(ok=True)
@app.route('/api/wireguard/uninstall', methods=['POST'])
def r_wg_uninstall():
with wg_lock:
if wg_state['pkg_running']:
return jsonify(error='Bereits aktiv'), 400
threading.Thread(target=wg_uninstall, daemon=True).start()
return jsonify(ok=True)
# -- Upload Routes --------------------------------------------------------------
@app.route('/api/upload/targets', methods=['GET'])
def r_upload_list():
return jsonify(load_cfg().get('upload_targets', []))
@app.route('/api/upload/targets', methods=['POST'])
def r_upload_add():
data = request.get_json(force=True)
cfg = load_cfg()
tid = data.get('id') or _uuid_mod.uuid4().hex[:8]
ctype = data.get('type', 'smb')
if ctype != 'smb':
return jsonify(error='Nur SMB/NAS wird unterstützt'), 400
ok, err = configure_smb_remote(
tid, data.get('host', ''), data.get('share', ''),
data.get('user', ''), data.get('pass', ''))
if not ok:
return jsonify(error=f'rclone: {err}'), 500
entry = {
'id': tid, 'type': ctype,
'name': data.get('name', ctype),
'dest_path': data.get('dest_path', 'PiCopy'),
'enabled': True,
}
targets = [t for t in cfg.get('upload_targets', []) if t['id'] != tid]
targets.append(entry)
cfg['upload_targets'] = targets
save_cfg(cfg)
return jsonify(ok=True, id=tid)
@app.route('/api/upload/targets/<tid>', methods=['DELETE'])
def r_upload_del(tid):
cfg = load_cfg()
cfg['upload_targets'] = [t for t in cfg.get('upload_targets', []) if t['id'] != tid]
save_cfg(cfg)
delete_remote(tid)
return jsonify(ok=True)
@app.route('/api/upload/browse', methods=['POST'])
def r_upload_browse():
"""Listet SMB-Freigaben eines Servers ohne gespeicherte Config (rclone connection string)."""
data = request.get_json(force=True)
host = data.get('host', '').strip()
user = data.get('user', '').strip()
pw = data.get('pass', '')
if not host:
return jsonify(error='Server-Adresse fehlt'), 400
conn = f':smb,host={host}'
if user:
conn += f',user={user}'
if pw:
try:
obscured = _rclone_obscure(pw)
conn += f',pass={obscured}'
except Exception:
pass
conn += ':'
r = subprocess.run(
['rclone', '--config', str(RCLONE_CONF), 'lsd', conn],
capture_output=True, text=True, timeout=15
)
if r.returncode != 0:
lines = r.stderr.strip().splitlines()
err = lines[-1] if lines else 'Verbindung fehlgeschlagen'
return jsonify(error=err), 400
shares = [line.strip().split()[-1] for line in r.stdout.splitlines() if line.strip()]
return jsonify(shares=shares)
@app.route('/api/upload/targets/<tid>/toggle', methods=['POST'])
def r_upload_toggle(tid):
cfg = load_cfg()
for t in cfg.get('upload_targets', []):
if t['id'] == tid:
t['enabled'] = not t.get('enabled', True)
break
save_cfg(cfg)
return jsonify(ok=True)
@app.route('/api/upload/targets/<tid>/test', methods=['POST'])
def r_upload_test(tid):
try:
ok, err = test_remote(tid)
except Exception as e:
log.exception('upload test failed')
ok, err = False, str(e)
return jsonify(ok=ok, error=err)
@app.route('/api/upload/status')
def r_upload_status():
with upload_lock:
return jsonify(dict(upload_state))
# -- Browse (persistente Mounts für File-Explorer) -----------------------------
_browse_mounts = {} # usb_port -> mount_point
def _mp_is_alive(mp):
"""Prüft ob ein Mount-Punkt wirklich aktiv und lesbar ist."""
try:
with open('/proc/mounts') as f:
mounted = any(mp in line.split() for line in f)
if not mounted:
return False
os.listdir(mp) # I/O-Test: schlägt fehl wenn Gerät entfernt wurde
return True
except Exception:
return False
def _drop_browse_mount(port):
"""Veralteten Mount bereinigen."""
mp = _browse_mounts.pop(port, None)
if mp:
subprocess.run(['umount', '-l', mp], capture_output=True)
log.info(f'Browse-Mount bereinigt: {mp}')
def get_browse_mp(dev):
port = dev.get('usb_port', '')
# Auto-mount vom System bevorzugen
if dev.get('mount') and _mp_is_alive(dev['mount']):
return dev['mount']
# Gecachten Mount prüfen
mp = _browse_mounts.get(port)
if mp:
if _mp_is_alive(mp):
return mp
_drop_browse_mount(port) # veraltet -> aufräumen
# Frisch mounten
mp = f'/mnt/picopy_br_{port}'
os.makedirs(mp, exist_ok=True)
r = subprocess.run(['mount', dev['device'], mp], capture_output=True)
if r.returncode == 0:
_browse_mounts[port] = mp
return mp
return None
@app.route('/api/browse')
def r_browse():
port = request.args.get('port', '')
rpath = request.args.get('path', '').lstrip('/')
devs = usb_devices()
dev = next((d for d in devs if d['usb_port'] == port), None)
if not dev:
return jsonify(error='Gerät nicht verbunden - bitte neu einstecken'), 404
mp = get_browse_mp(dev)
if not mp:
return jsonify(error='Gerät nicht lesbar - bitte neu einstecken'), 500
try:
base = Path(mp).resolve()
target = (base / rpath).resolve()
if not str(target).startswith(str(base)):
return jsonify(error='Ungültiger Pfad'), 400
if not target.is_dir():
return jsonify(error='Kein Verzeichnis'), 400
entries = []
for item in sorted(target.iterdir(),
key=lambda x: (x.is_file(), x.name.lower())):
try:
s = item.stat()
entries.append({
'name': item.name,
'dir': item.is_dir(),
'size': s.st_size if item.is_file() else None,
'mtime': datetime.fromtimestamp(s.st_mtime).strftime('%d.%m.%y %H:%M'),
})
except OSError:
pass
rel = str(target.relative_to(base))
return jsonify(path='' if rel == '.' else rel, entries=entries)
except OSError as e:
import errno as _errno
if e.errno == _errno.EIO:
# I/O-Fehler = Gerät abgezogen, Mount bereinigen
_drop_browse_mount(port)
return jsonify(error='Gerät nicht mehr erreichbar - bitte neu einstecken'), 503
return jsonify(error=str(e)), 500
except Exception as e:
return jsonify(error=str(e)), 500
# -- Update-System -------------------------------------------------------------
update_state = {
'current': VERSION,
'latest': None,
'available': False,
'checking': False,
'error': None,
'last_checked': None,
}
update_lock = threading.Lock()
def _vtuple(v):
try:
return tuple(int(x) for x in v.strip().lstrip('v').split('.'))
except Exception:
return (0,)
def check_for_updates():
with update_lock:
if update_state['checking']:
return
update_state['checking'] = True
update_state['error'] = None
try:
req = _urlreq.urlopen(f'{RAW_BASE}/version.txt', timeout=10)
latest = req.read().decode().strip()
avail = _vtuple(latest) > _vtuple(VERSION)
with update_lock:
update_state.update(latest=latest, available=avail,
last_checked=datetime.now().isoformat())
if avail:
log.info(f'Update verfügbar: {VERSION} -> {latest}')
except Exception as e:
with update_lock:
update_state['error'] = str(e)
log.warning(f'Update-Check fehlgeschlagen: {e}')
finally:
with update_lock:
update_state['checking'] = False
def update_check_loop():
time.sleep(5) # Kurz nach Start einmalig prüfen
while True:
check_for_updates()
time.sleep(6 * 3600) # Dann alle 6 Stunden
@app.route('/api/update/status')
def r_update_status():
with update_lock:
return jsonify(dict(update_state))
@app.route('/api/update/check', methods=['POST'])
def r_update_check():
threading.Thread(target=check_for_updates, daemon=True).start()
return jsonify(ok=True)
@app.route('/api/system/reboot', methods=['POST'])
def r_system_reboot():
threading.Thread(target=lambda: (
__import__('time').sleep(1),
subprocess.Popen(['reboot'])
), daemon=True).start()
return jsonify(ok=True)
@app.route('/api/update/install', methods=['POST'])
def r_update_install():
try:
log.info('Update wird heruntergeladen...')
req = _urlreq.urlopen(f'{RAW_BASE}/app.py', timeout=60)
new_code = req.read().decode()
vreq = _urlreq.urlopen(f'{RAW_BASE}/version.txt', timeout=10)
new_version = vreq.read().decode().strip()
# Syntax-Check bevor wir irgendetwas überschreiben
compile(new_code, 'app.py', 'exec')
tmp = Path('/opt/picopy/app.py.tmp')
tmp.write_text(new_code, encoding='utf-8')
with open(tmp, 'rb') as fh:
os.fsync(fh.fileno()) # Sicherstellen dass Daten auf der Platte sind
os.replace(str(tmp), '/opt/picopy/app.py') # Atomares Umbenennen
vtmp = Path('/opt/picopy/version.txt.tmp')
vtmp.write_text(new_version + '\n', encoding='utf-8')
with open(vtmp, 'rb') as fh:
os.fsync(fh.fileno())
os.replace(str(vtmp), '/opt/picopy/version.txt')
log.info('Update installiert - starte Dienst neu...')
# Systemd startet den Dienst automatisch neu
subprocess.Popen(['systemctl', 'restart', 'picopy'])
return jsonify(ok=True)
except SyntaxError as e:
return jsonify(error=f'Update-Datei ungültig: {e}'), 500
except Exception as e:
log.exception('Update fehlgeschlagen')
return jsonify(error=str(e)), 500
# -- HTML Template -------------------------------------------------------------
HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PiCopy</title>
<style>
/* -- Reset & Tokens -- */
:root {
--bg: #0a0f1e;
--bg2: #111827;
--surf: #1a2235;
--surf2: #1f2a40;
--brd: #2a3650;
--brd2: #374766;
--txt: #e8edf5;
--sub: #8b9ab5;
--acc: #4f8ef7;
--acc2: #3b7de8;
--grn: #34d399;
--grn2: #10b981;
--red: #f87171;
--ylw: #fbbf24;
--pur: #a78bfa;
--r: .6rem;
--r2: .9rem;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100vh;padding:0 0 4rem}
/* -- Topbar -- */
.topbar{background:var(--bg2);border-bottom:1px solid var(--brd);padding:.75rem 1.5rem;display:flex;align-items:center;gap:1rem;position:sticky;top:0;z-index:100;backdrop-filter:blur(8px)}
.logo{display:flex;align-items:center;gap:.55rem;font-size:1rem;font-weight:700;letter-spacing:-.02em;color:var(--txt)}
.logo-dot{width:8px;height:8px;border-radius:50%;background:var(--acc);box-shadow:0 0 8px var(--acc)}
.topbar-wifi{margin-left:auto;display:flex;align-items:center;gap:.6rem;font-size:.82rem;background:var(--surf);border:1px solid var(--brd);border-radius:9999px;padding:.3rem .75rem}
.wdot{width:7px;height:7px;border-radius:50%;transition:.3s;flex-shrink:0}
.wdot.c{background:var(--grn);box-shadow:0 0 6px var(--grn)}
.wdot.a{background:var(--pur)}
.wdot.d{background:var(--brd2)}
.upd-badge{display:none;align-items:center;gap:.4rem;font-size:.78rem;font-weight:600;background:rgba(251,191,36,.12);border:1px solid rgba(251,191,36,.4);color:var(--ylw);border-radius:9999px;padding:.28rem .75rem;cursor:pointer;transition:.15s;white-space:nowrap}
.upd-badge:hover{background:rgba(251,191,36,.22)}
#wifi-label{font-weight:600;color:var(--txt)}
#wifi-ip{color:var(--sub);font-family:monospace;font-size:.76rem}
/* -- Layout -- */
.page{max-width:1120px;margin:0 auto;padding:1.25rem 1.25rem 0;display:grid;gap:1rem;grid-template-columns:1fr}
@media(min-width:640px){.page{grid-template-columns:1fr 1fr}}
.col2{grid-column:1/-1}
.site-footer{max-width:1120px;margin:1.2rem auto 0;padding:0 1.25rem;color:var(--sub);font-size:.78rem;display:flex;align-items:center;justify-content:space-between;gap:.75rem;flex-wrap:wrap}
.site-footer a{color:var(--sub);text-decoration:none;transition:color .15s}
.site-footer a:hover{color:var(--txt)}
.site-version{font-family:ui-monospace,monospace;color:var(--brd2)}
/* -- Cards -- */
.card{background:var(--surf);border:1px solid var(--brd);border-radius:var(--r2);overflow:hidden}
.card-head{display:flex;align-items:center;gap:.6rem;padding:.75rem 1.1rem;border-bottom:1px solid var(--brd);background:var(--surf2)}
.card-icon{width:28px;height:28px;border-radius:.45rem;display:flex;align-items:center;justify-content:center;font-size:.95rem;flex-shrink:0}
.card-icon.blue{background:rgba(79,142,247,.15);color:var(--acc)}
.card-icon.green{background:rgba(52,211,153,.15);color:var(--grn)}
.card-icon.pur{background:rgba(167,139,250,.15);color:var(--pur)}
.card-icon.ylw{background:rgba(251,191,36,.15);color:var(--ylw)}
.card-icon.red{background:rgba(248,113,113,.15);color:var(--red)}
.card-title{font-size:.82rem;font-weight:700;letter-spacing:.03em;color:var(--txt)}
.card-sub{font-size:.74rem;color:var(--sub);margin-left:auto}
.card-body{padding:1.1rem}
/* -- Buttons -- */
.btn{display:inline-flex;align-items:center;gap:.35rem;padding:.42rem .9rem;border:1px solid var(--brd2);border-radius:.45rem;background:transparent;color:var(--txt);font-size:.83rem;font-weight:500;cursor:pointer;transition:all .15s;white-space:nowrap;line-height:1.2}
.btn:hover{border-color:var(--acc);color:var(--acc);background:rgba(79,142,247,.07)}
.btn.pri{background:var(--acc);border-color:var(--acc);color:#fff}
.btn.pri:hover{background:var(--acc2);border-color:var(--acc2)}
.btn.grn{background:var(--grn2);border-color:var(--grn2);color:#fff}
.btn.grn:hover{background:#0ea472}
.btn.danger{border-color:var(--red);color:var(--red)}
.btn.danger:hover{background:rgba(248,113,113,.08)}
.btn.ghost{border-color:transparent;color:var(--sub)}
.btn.ghost:hover{color:var(--txt);border-color:var(--brd2)}
.btn.sm{padding:.25rem .6rem;font-size:.76rem}
.btn:disabled{opacity:.35;cursor:default;pointer-events:none}
.btn-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.85rem}
/* -- Progress -- */
.prog-track{height:5px;background:var(--bg);border-radius:9999px;overflow:hidden;margin:.65rem 0 .3rem}
.prog-fill{height:100%;background:linear-gradient(90deg,var(--acc),var(--grn));border-radius:9999px;transition:width .5s ease}
.prog-fill.err{background:var(--red)}
.prog-fill.done{background:var(--grn)}
.meta-row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-top:.35rem}
.pill{display:inline-flex;align-items:center;gap:.3rem;font-size:.74rem;padding:.18rem .55rem;border-radius:9999px;border:1px solid var(--brd2);color:var(--sub);background:var(--surf2)}
.pill.acc{border-color:rgba(79,142,247,.4);color:var(--acc);background:rgba(79,142,247,.08)}
.pill.grn{border-color:rgba(52,211,153,.4);color:var(--grn);background:rgba(52,211,153,.08)}
.pill.red{border-color:rgba(248,113,113,.4);color:var(--red);background:rgba(248,113,113,.08)}
/* -- Status text -- */
.st-headline{font-size:1.05rem;font-weight:700;letter-spacing:-.01em}
.st-run{color:var(--acc)}.st-ok{color:var(--grn)}.st-err{color:var(--red)}.st-idle{color:var(--sub)}
/* -- Form fields -- */
.field{margin-bottom:.85rem}
.field:last-child{margin-bottom:0}
.field label{display:block;font-size:.76rem;font-weight:600;color:var(--sub);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.35rem}
.field input,.field select,.field textarea{width:100%;padding:.48rem .7rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.45rem;color:var(--txt);font-size:.87rem;transition:border-color .15s}
.field input:focus,.field select,.field textarea:focus{outline:none;border-color:var(--acc)}
.field textarea{resize:vertical;font-family:ui-monospace,monospace}
.tog{display:flex;align-items:center;gap:.55rem;margin-bottom:.7rem;cursor:pointer;user-select:none;font-size:.87rem;color:var(--txt)}
.tog input{accent-color:var(--acc);width:16px;height:16px;cursor:pointer;flex-shrink:0}
.tog span{line-height:1.35}
.flash{font-size:.78rem;min-height:1rem;padding:.2rem 0}
.flash.ok{color:var(--grn)}.flash.err{color:var(--red)}.flash.warn{color:#f4a332}
/* -- Port Slots -- */
/* port-pair: immer echtes 1fr 1fr, unabhängig vom Explorer */
.port-pair{display:grid;grid-template-columns:1fr 1fr;gap:.85rem;align-items:start}
@media(max-width:500px){.port-pair{grid-template-columns:1fr}}
.port-slot{border:1.5px solid var(--brd);border-radius:var(--r);padding:.85rem;transition:border-color .2s}
.port-slot.src-on{border-color:var(--grn2)}
.port-slot.dst-on{border-color:var(--acc)}
.role-tag{display:inline-flex;align-items:center;gap:.3rem;font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;padding:.14rem .45rem;border-radius:9999px;margin-bottom:.6rem}
.role-tag.src{background:rgba(52,211,153,.12);color:var(--grn)}
.role-tag.dst{background:rgba(79,142,247,.12);color:var(--acc)}
.port-display{display:flex;align-items:center;gap:.6rem;padding:.6rem .75rem;background:var(--bg2);border-radius:.5rem;margin-bottom:.75rem}
.dot{width:9px;height:9px;border-radius:50%;flex-shrink:0;transition:.3s}
.dot.on{background:var(--grn);box-shadow:0 0 7px var(--grn)}
.dot.off{background:var(--brd2)}
.port-path{font-family:ui-monospace,monospace;font-size:.92rem;font-weight:700;line-height:1.2}
.port-info{font-size:.72rem;color:var(--sub);margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hint-box{font-size:.72rem;color:var(--sub);margin-top:.65rem;padding:.45rem .65rem;background:var(--bg2);border-radius:.4rem;border-left:3px solid var(--brd2);line-height:1.5}
/* -- Port+Explorer grid -- */
/* pex-grid: port-pair links, explorer rechts */
.pex-grid{display:grid;gap:.85rem;grid-template-columns:1fr}
@media(min-width:960px){.pex-grid{grid-template-columns:1fr auto}.pex-grid .expl-wrap{width:320px}}
.expl-wrap{border:1px solid var(--brd);border-radius:var(--r);overflow:hidden;display:flex;flex-direction:column}
/* -- File Explorer -- */
.expl-bar{display:flex;align-items:stretch;gap:.4rem;padding:.45rem .8rem;background:var(--surf2);border-bottom:1px solid var(--brd);flex-shrink:0}
.etab{display:inline-flex;align-items:center;padding:0 .6rem;border-radius:.35rem;font-size:.76rem;font-weight:600;cursor:pointer;border:1px solid transparent;color:var(--sub);transition:.15s;white-space:nowrap}
.etab.on{background:var(--acc);color:#fff;border-color:var(--acc)}
.etab:hover:not(.on){border-color:var(--brd2);color:var(--txt)}
.expl-reload{margin-left:auto;display:inline-flex;align-items:center;background:transparent;border:1px solid transparent;color:var(--sub);border-radius:.35rem;padding:0 .45rem;cursor:pointer;font-size:.9rem;transition:.15s}
.expl-reload:hover{color:var(--txt);border-color:var(--brd2)}
.expl-bread{display:flex;align-items:center;gap:.2rem;flex-wrap:wrap;padding:.4rem .8rem;background:var(--bg2);border-bottom:1px solid var(--brd);font-size:.74rem;min-height:30px;flex-shrink:0}
.bseg{cursor:pointer;color:var(--acc)}
.bseg:hover{text-decoration:underline}
.bsep{color:var(--brd2)}
.expl-scroll{overflow-y:auto;max-height:360px;flex:1}
@media(min-width:960px){.expl-scroll{max-height:410px}}
.expl-row{display:grid;grid-template-columns:1.5rem 1fr auto auto;align-items:center;gap:.4rem;padding:.36rem .75rem;border-bottom:1px solid rgba(42,54,80,.7);font-size:.81rem;transition:background .1s}
.expl-row:last-child{border-bottom:none}
.expl-row.dir{cursor:pointer}
.expl-row.dir:hover{background:rgba(79,142,247,.06)}
.expl-row.up{cursor:pointer;color:var(--sub)}
.expl-row.up:hover{background:rgba(139,154,181,.05)}
.expl-ico{text-align:center;font-size:.95rem}
.expl-nm{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.expl-row.dir .expl-nm{font-weight:500}
.expl-sz{font-family:monospace;font-size:.71rem;color:var(--sub);text-align:right;white-space:nowrap}
.expl-dt{font-size:.69rem;color:var(--brd2);white-space:nowrap}
@media(max-width:360px){.expl-dt{display:none}}
.expl-empty{padding:1.5rem;text-align:center;color:var(--sub);font-size:.84rem}
/* -- Log -- */
.log-wrap{font-family:ui-monospace,monospace;font-size:.75rem;max-height:300px;overflow-y:auto;background:var(--bg2);border-radius:.45rem;padding:.5rem}
.log-row{display:flex;gap:.5rem;padding:.18rem 0;border-bottom:1px solid rgba(42,54,80,.5)}
.log-row:last-child{border-bottom:none}
.log-t{color:var(--brd2);flex-shrink:0}
.log-m{color:var(--sub)}
/* -- Upload targets -- */
.ut-row{display:flex;align-items:center;gap:.55rem;padding:.6rem .75rem;border:1px solid var(--brd);border-radius:.5rem;transition:.15s}
.ut-row.on{border-color:rgba(79,142,247,.35);background:rgba(79,142,247,.04)}
.ut-ico{font-size:1.1rem;flex-shrink:0}
.ut-nm{font-weight:600;font-size:.86rem}
.ut-meta{font-size:.72rem;color:var(--sub)}
.ut-acts{display:flex;gap:.3rem;margin-left:auto;flex-shrink:0}
.add-panel{border:1px solid var(--brd);border-radius:.6rem;padding:.9rem;margin-top:.75rem;background:var(--bg2)}
/* -- Tabs (WiFi) -- */
.tab-strip{display:flex;gap:.25rem;margin-bottom:.9rem;border-bottom:1px solid var(--brd);padding-bottom:.6rem}
.tab{padding:.28rem .7rem;border-radius:.35rem;font-size:.8rem;font-weight:500;cursor:pointer;color:var(--sub);transition:.15s;border:1px solid transparent}
.tab.on{background:var(--acc);color:#fff;border-color:var(--acc)}
.tab:hover:not(.on){border-color:var(--brd2);color:var(--txt)}
.tpane{display:none}.tpane.on{display:block}
.net-list{display:flex;flex-direction:column;gap:.3rem;max-height:200px;overflow-y:auto;margin-top:.5rem}
.net-row{display:flex;align-items:center;gap:.5rem;padding:.32rem .55rem;border:1px solid var(--brd);border-radius:.4rem;cursor:pointer;font-size:.82rem;transition:.15s}
.net-row:hover{border-color:var(--acc);background:rgba(79,142,247,.06)}
.net-sig{font-size:.7rem;color:var(--sub);margin-left:auto}
/* -- Divider -- */
.sec{font-size:.7rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--sub);padding:.1rem 0;margin:.7rem 0 .5rem;display:flex;align-items:center;gap:.5rem}
.sec::after{content:'';flex:1;height:1px;background:var(--brd)}
.empty{color:var(--sub);font-size:.85rem;padding:.3rem 0}
/* -- WireGuard VPN -- */
.wdot.vpn{background:var(--pur);box-shadow:0 0 6px var(--pur)}
</style>
</head>
<body>
<!-- Topbar -->
<header class="topbar">
<div class="logo">
<div class="logo-dot"></div>
PiCopy
</div>
<div id="upd-badge" class="upd-badge" onclick="installUpdate()" title="Klicken zum Installieren">
&#8593; <span id="upd-version"></span> verfügbar
</div>
<div class="topbar-wifi">
<div class="wdot d" id="wdot"></div>
<span id="wifi-label">Verbinde...</span>
<span id="wifi-ip"></span>
</div>
<div id="vpn-pill" class="topbar-wifi" style="display:none">
<div class="wdot d" id="vpn-dot"></div>
<span id="vpn-label" style="font-weight:600;color:var(--txt)">VPN</span>
<span id="vpn-ip" style="color:var(--sub);font-family:monospace;font-size:.76rem"></span>
</div>
</header>
<main class="page">
<!-- -- Kopierstatus -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon blue">▶</div>
<span class="card-title">Kopierstatus</span>
<span class="card-sub" id="st-time"></span>
<button id="st-dismiss" onclick="dismissStatus()" title="Meldung schließen" style="display:none;margin-left:.5rem;background:transparent;border:1px solid var(--brd2);color:var(--sub);border-radius:.35rem;padding:.18rem .45rem;cursor:pointer;font-size:.8rem;line-height:1;transition:.15s" onmouseover="this.style.color='var(--txt)'" onmouseout="this.style.color='var(--sub)'">✕</button>
</div>
<div class="card-body">
<div class="st-headline st-idle" id="st-text">Bereit</div>
<div id="prog-wrap" style="display:none">
<div class="prog-track"><div class="prog-fill" id="prog-fill" style="width:0%"></div></div>
<div class="meta-row">
<span class="pill acc" id="prog-pct" style="display:none"></span>
<span class="pill" id="prog-files" style="display:none"></span>
<span class="pill" id="prog-bytes" style="display:none"></span>
<span class="pill acc" id="eta-pill" style="display:none"></span>
<span class="pill" id="speed-pill" style="display:none"></span>
</div>
<div id="cur-file" style="font-size:.74rem;color:var(--sub);margin-top:.3rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace"></div>
</div>
<div id="st-summary" style="font-size:.81rem;color:var(--sub);margin-top:.4rem"></div>
<!-- Upload-Status -->
<div id="upload-block" style="display:none;margin-top:.75rem;padding:.65rem .85rem;background:var(--bg2);border-radius:.5rem;border:1px solid var(--brd)">
<div class="sec" style="margin-top:0">Fernkopie</div>
<div id="upload-current" style="font-size:.83rem;color:var(--acc)"></div>
<div id="upload-results" style="margin-top:.3rem;display:flex;flex-direction:column;gap:.2rem"></div>
</div>
<div class="btn-row">
<button id="btn-start" class="btn pri" onclick="startCopy()">▶&nbsp;Kopieren starten</button>
<button id="btn-cancel" class="btn danger" onclick="cancelCopy()" style="display:none">■&nbsp;Abbrechen</button>
<button class="btn ghost" onclick="refreshDevices()">&#8635;&nbsp;Geräte neu laden</button>
</div>
<div id="copy-hint" class="flash" style="display:none"></div>
</div>
</div>
<!-- -- USB Ports + Explorer -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon green">&#8644;</div>
<span class="card-title">USB Ports &amp; Datei-Explorer</span>
</div>
<div class="card-body">
<div class="pex-grid">
<!-- Quelle + Ziel in eigenem gleichbreiten Grid -->
<div class="port-pair">
<!-- Quellen (dynamisch) -->
<div id="slot-src">
<div id="sources-list"></div>
<div style="margin-top:.6rem">
<div class="role-tag src" style="margin-bottom:.5rem">+ Quelle hinzufügen</div>
<div class="field">
<label>Port lernen - Gerät wählen</label>
<select id="src-select">
<option value="">- Gerät einstecken, dann hier wählen -</option>
</select>
</div>
<div class="field">
<label>Bezeichnung</label>
<input type="text" id="src-label" placeholder="z.B. Kamera 1 / linker Port">
</div>
<button class="btn grn" style="width:100%" onclick="addSource()">+&nbsp;Quelle hinzufügen</button>
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
<div class="hint-box">Gerät einstecken &rarr; aus Liste wählen &rarr; Hinzufügen. Mehrere Quellen werden nacheinander auf dasselbe Ziel kopiert.</div>
</div>
</div>
<!-- Ziel -->
<div class="port-slot" id="slot-dst">
<div class="role-tag dst">▼ Ziel</div>
<div class="port-display">
<div class="dot off" id="dst-dot"></div>
<div style="min-width:0">
<div class="port-path" id="dst-port-path">-</div>
<div class="port-info" id="dst-dev-info">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung</label>
<input type="text" id="dst-label" placeholder="z.B. rechter schwarzer Port">
</div>
<div class="field">
<label>Port lernen - Gerät wählen</label>
<select id="dst-select">
<option value="">- Gerät einstecken, dann hier wählen -</option>
</select>
</div>
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">✓&nbsp;Als festes Ziel speichern</button>
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
<div class="hint-box">Gerät in den gewünschten Port &rarr; aus Liste wählen &rarr; Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
</div>
</div><!-- /port-pair -->
<!-- File Explorer -->
<div class="expl-wrap">
<div class="expl-bar">
<div id="src-tabs" style="display:contents"></div>
<button class="etab" id="etab-dst" onclick="expl.switchRole('dst')">⬇ Ziel</button>
<button class="expl-reload" onclick="expl.reload()" title="Neu laden">&#8635;</button>
</div>
<div class="expl-bread" id="expl-bread"></div>
<div class="expl-scroll" id="expl-body">
<div class="expl-empty">Port konfigurieren und Gerät verbinden</div>
</div>
</div>
</div>
<!-- Nicht zugewiesene Geräte -->
<div id="unassigned-wrap" style="display:none;margin-top:.85rem">
<div class="sec">Weitere verbundene Geräte</div>
<div id="unassigned-list" style="display:flex;flex-direction:column;gap:.35rem"></div>
</div>
</div>
</div>
<!-- -- Kopier-Einstellungen -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon ylw">⚙</div>
<span class="card-title">Kopier-Einstellungen</span>
</div>
<div class="card-body" style="display:grid;grid-template-columns:1fr 1fr;gap:0 2rem">
<!-- Linke Spalte: Ordner & Auto -->
<div>
<div class="sec" style="margin-top:0">Ordnerstruktur</div>
<div class="field">
<label>Datumsformat</label>
<select id="c-fmt">
<option value="%Y-%m-%d">JJJJ-MM-TT &nbsp;(2024-01-15)</option>
<option value="%Y%m%d">JJJJMMTT &nbsp;(20240115)</option>
<option value="%d-%m-%Y">TT-MM-JJJJ &nbsp;(15-01-2024)</option>
<option value="%Y/%m/%d">JJJJ/MM/TT &nbsp;(Unterordner)</option>
</select>
</div>
<label class="tog"><input type="checkbox" id="c-time"><span>Uhrzeit im Ordnernamen</span></label>
<label class="tog"><input type="checkbox" id="c-sub"><span>Unterordner pro Quelle</span></label>
<label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren</span></label>
<div class="sec">Dateifilter</div>
<div class="field">
<label>Nur diese Typen kopieren (leer = alle)</label>
<input type="text" id="c-filter" placeholder="jpg, raw, mp4, mov ...">
</div>
<div style="display:flex;gap:.35rem;flex-wrap:wrap;margin-top:-.35rem;margin-bottom:.85rem">
<button class="btn sm ghost" onclick="setFilter('jpg,jpeg,heic,raw,cr2,nef,arw,dng,png')">📷 Fotos</button>
<button class="btn sm ghost" onclick="setFilter('mp4,mov,avi,mkv,mts,m2ts,wmv')">🎬 Videos</button>
<button class="btn sm ghost" onclick="setFilter('jpg,jpeg,heic,raw,cr2,nef,arw,dng,mp4,mov,mts,m2ts')">📷+🎬</button>
<button class="btn sm ghost" onclick="setFilter('')">✕ Alle</button>
</div>
<label class="tog"><input type="checkbox" id="c-excl"><span>Systemdateien ausschließen<br><span style="font-size:.72rem;color:var(--sub)">.DS_Store, Thumbs.db, RECYCLER, System Volume Information ...</span></span></label>
</div>
<!-- Rechte Spalte: Duplikate & Sicherheit -->
<div>
<div class="sec" style="margin-top:0">Duplikate</div>
<div class="field">
<label>Wenn Zieldatei bereits existiert</label>
<select id="c-dup">
<option value="skip">Überspringen (empfohlen)</option>
<option value="overwrite">Überschreiben</option>
<option value="rename">Umbenennen &nbsp;(_1, _2 ...)</option>
</select>
</div>
<div class="sec">Integrität &amp; Aufräumen</div>
<label class="tog" style="margin-bottom:.85rem">
<input type="checkbox" id="c-verify">
<span>Dateien nach Kopieren per MD5 verifizieren<br>
<span style="font-size:.72rem;color:var(--sub)">Stellt sicher dass jede Datei identisch ankam - dauert länger</span></span>
</label>
<label class="tog">
<input type="checkbox" id="c-delsrc">
<span style="color:var(--ylw)">⚠ Quelldateien nach Kopieren löschen<br>
<span style="font-size:.72rem;color:var(--sub)">Löscht Dateien von der Quelle nach erfolgreichem Kopieren (bei Verify: nur verifizierte)</span></span>
</label>
</div>
<!-- Speichern-Zeile über beide Spalten -->
<div style="grid-column:1/-1;margin-top:.25rem">
<div class="btn-row" style="margin-top:0">
<button class="btn pri" onclick="saveCopyCfg()">✓&nbsp;Speichern</button>
<div id="copy-cfg-msg" class="flash ok" style="display:none;align-self:center">Gespeichert!</div>
</div>
</div>
</div>
</div>
<!-- -- Upload-Ziele -- -->
<div class="card">
<div class="card-head">
<div class="card-icon pur">^</div>
<span class="card-title">Fernkopie - NAS / SMB</span>
</div>
<div class="card-body">
<div id="ut-list" style="display:flex;flex-direction:column;gap:.45rem;margin-bottom:.65rem"></div>
<button class="btn" onclick="utToggleForm()" id="ut-add-btn">+&nbsp;NAS-Ziel hinzufügen</button>
<div id="ut-form" class="add-panel" style="display:none">
<!-- Schritt 1: Verbindungsdaten -->
<div id="ut-step1">
<div class="sec" style="margin-top:0">Schritt 1 Server-Verbindung</div>
<div class="field"><label>Server (IP / Hostname)</label>
<input type="text" id="ut-host" placeholder="192.168.1.100 oder nas.local" autocomplete="off"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
<div class="field"><label>Benutzer</label>
<input type="text" id="ut-user" placeholder="(leer = anonym)" autocomplete="off"></div>
<div class="field"><label>Passwort</label>
<input type="password" id="ut-pass" placeholder="(leer = kein Passwort)" autocomplete="off"></div>
</div>
<div class="btn-row">
<button class="btn pri" id="ut-connect-btn" onclick="utConnect()">&#128279;&nbsp;Verbinden &amp; Freigaben laden</button>
<button class="btn ghost" onclick="utToggleForm()">Abbrechen</button>
</div>
</div>
<!-- Schritt 2: Freigabe wählen (nach erfolgreicher Verbindung) -->
<div id="ut-step2" style="display:none">
<div class="sec" style="margin-top:0">Schritt 2 Freigabe &amp; Details</div>
<div class="field"><label>Freigabe wählen</label>
<select id="ut-share-sel" style="width:100%"></select></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
<div class="field"><label>Name (Anzeigename)</label>
<input type="text" id="ut-name" placeholder="z.B. Heimserver NAS"></div>
<div class="field"><label>Ziel-Ordner auf dem NAS</label>
<input type="text" id="ut-dest" placeholder="PiCopy" value="PiCopy"></div>
</div>
<div class="btn-row">
<button class="btn pri" onclick="utSave()">✓&nbsp;Speichern &amp; Verbindung testen</button>
<button class="btn ghost" onclick="utBack()">&#8592;&nbsp;Zurück</button>
</div>
</div>
<div id="ut-form-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
</div>
<!-- -- WiFi + Log nebeneinander -- -->
<div class="card">
<div class="card-head">
<div class="card-icon acc" style="background:rgba(79,142,247,.15);color:var(--acc)">⌘</div>
<span class="card-title">WiFi-Einstellungen</span>
</div>
<div class="card-body">
<div class="tab-strip">
<div class="tab on" onclick="swTab('tc','ta')">Heimnetz</div>
<div class="tab" onclick="swTab('ta','tc')">Hotspot (AP)</div>
</div>
<div id="tc" class="tpane on">
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.75rem;line-height:1.5">Heimnetz für die Router-Verbindung. Ohne Verbindung startet PiCopy automatisch einen eigenen Hotspot.</div>
<div class="field">
<label>Netzwerk (SSID)</label>
<div style="display:flex;gap:.4rem">
<input type="text" id="w-ssid" placeholder="WLAN-Name" style="flex:1">
<button class="btn ghost" onclick="scanNets()" title="Netzwerke suchen">🔍</button>
</div>
</div>
<div id="net-list" class="net-list" style="display:none"></div>
<div class="field"><label>Passwort</label><input type="password" id="w-pw" placeholder="WLAN-Passwort"></div>
<button class="btn pri" onclick="connectWifi()">🔌&nbsp;Verbinden &amp; Speichern</button>
<div id="wifi-flash" class="flash" style="margin-top:.4rem"></div>
</div>
<div id="ta" class="tpane">
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.75rem;line-height:1.5">Startet automatisch wenn kein Heimnetz erreichbar ist.<br>IP im Hotspot-Modus: <b style="color:var(--txt)">10.42.0.1:8080</b></div>
<div class="field"><label>Hotspot-Name (SSID)</label><input type="text" id="ap-ssid" placeholder="PiCopy"></div>
<div class="field"><label>Passwort (min. 8 Zeichen)</label><input type="password" id="ap-pw" placeholder="PiCopy,"></div>
<button class="btn pri" onclick="saveAP()">✓&nbsp;Speichern &amp; Neustart</button>
<div id="ap-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
</div>
<!-- -- WireGuard VPN -- -->
<div class="card">
<div class="card-head">
<div class="card-icon pur">⚿</div>
<span class="card-title">WireGuard VPN</span>
<span class="card-sub" id="wg-status-sub"></span>
</div>
<div class="card-body">
<!-- Paket nicht installiert -->
<div id="wg-not-installed" style="display:none">
<div style="display:flex;align-items:center;gap:.75rem;padding:.75rem .9rem;background:rgba(251,191,36,.07);border:1px solid rgba(251,191,36,.28);border-radius:.5rem;margin-bottom:.6rem">
<span style="font-size:1.3rem;flex-shrink:0">📦</span>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:.87rem;color:var(--ylw)">WireGuard nicht installiert</div>
<div style="font-size:.76rem;color:var(--sub);margin-top:.15rem">wireguard + wireguard-tools + openresolv werden per apt-get installiert</div>
</div>
<button class="btn pri" onclick="wgInstall()" style="flex-shrink:0">Installieren</button>
</div>
</div>
<!-- Paketoperation läuft -->
<div id="wg-pkg-progress" style="display:none">
<div style="display:flex;align-items:center;gap:.75rem;padding:.75rem .9rem;background:rgba(79,142,247,.07);border:1px solid rgba(79,142,247,.25);border-radius:.5rem;margin-bottom:.6rem">
<span style="font-size:1.3rem;flex-shrink:0" id="wg-pkg-icon">⏳</span>
<div>
<div style="font-weight:600;font-size:.87rem" id="wg-pkg-title">Installiere WireGuard...</div>
<div style="font-size:.76rem;color:var(--sub);margin-top:.1rem">apt-get läuft - bitte warten (bis 60 s)</div>
</div>
</div>
</div>
<!-- Hauptbereich (wenn installiert) -->
<div id="wg-installed-ui" style="display:none">
<!-- Verbindungsstatus -->
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.85rem;padding:.65rem .85rem;background:var(--bg2);border-radius:.5rem;border:1px solid var(--brd)">
<div id="wg-dot" class="wdot d"></div>
<div style="flex:1;min-width:0">
<div id="wg-label" style="font-weight:600;font-size:.87rem">Getrennt</div>
<div id="wg-detail" style="font-size:.74rem;color:var(--sub);font-family:monospace;margin-top:.1rem"></div>
</div>
<button id="wg-btn-connect" class="btn grn sm" onclick="wgConnect()" style="display:none">⚿&nbsp;Verbinden</button>
<button id="wg-btn-disconnect" class="btn danger sm" onclick="wgDisconnect()" style="display:none">✕&nbsp;Trennen</button>
</div>
<!-- Konfiguration -->
<div class="sec" style="margin-top:0">Konfiguration</div>
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.65rem;line-height:1.5">
WireGuard .conf einfügen - wird als <code style="background:var(--bg2);padding:.1rem .3rem;border-radius:.25rem">/etc/wireguard/picopy.conf</code> gespeichert (Permissions 600). Der private Schlüssel wird maskiert angezeigt.
</div>
<div class="field">
<label>WireGuard Konfiguration (.conf)</label>
<textarea id="wg-config" rows="9" placeholder="[Interface]&#10;PrivateKey = ...&#10;Address = 10.x.x.x/32&#10;DNS = ...&#10;&#10;[Peer]&#10;PublicKey = ...&#10;Endpoint = mein-nas.dyndns.org:51820&#10;AllowedIPs = 192.168.1.0/24" style="font-family:ui-monospace,monospace;font-size:.77rem;line-height:1.6"></textarea>
</div>
<label class="tog"><input type="checkbox" id="wg-auto"><span>Beim Start automatisch verbinden</span></label>
<div class="btn-row">
<button class="btn pri" onclick="wgSaveConfig()">✓&nbsp;Konfiguration speichern</button>
<button class="btn danger" onclick="wgUninstall()" style="margin-left:auto" title="wireguard-Paket entfernen">✕&nbsp;Deinstallieren</button>
</div>
</div>
<div id="wg-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
<!-- -- System -- -->
<div class="card">
<div class="card-head">
<div class="card-icon" style="background:rgba(255,180,60,.1);color:#f4a332">⚙</div>
<span class="card-title">System</span>
</div>
<div class="card-body" style="display:flex;flex-direction:column;gap:.6rem">
<button class="btn" style="width:100%" onclick="checkUpdate()">🔍&nbsp;Nach Update suchen</button>
<div id="sys-update-flash" class="flash" style="display:none"></div>
<button class="btn" style="width:100%;background:rgba(220,60,60,.12);color:#e05555;border-color:rgba(220,60,60,.25)" onclick="rebootDevice()">&#8634;&nbsp;Gerät neu starten</button>
</div>
</div>
<!-- -- Logs -- -->
<div class="card col2">
<div class="card-head">
<div class="card-icon" style="background:rgba(139,154,181,.1);color:var(--sub)">=</div>
<span class="card-title">Logs</span>
</div>
<div class="card-body" style="padding:.65rem .85rem">
<div id="log-box" class="log-wrap"><div class="expl-empty">Noch keine Einträge</div></div>
</div>
</div>
</main>
<footer class="site-footer">
<a href="https://leuschner.dev" target="_blank" rel="noopener">© 2026 Made with ❤ by Tobias Leuschner</a>
<span class="site-version">Version v__PICOPY_VERSION__</span>
</footer>
<script>
let cfg = {}, devs = [];
const $ = id => document.getElementById(id);
const api = async (p, m='GET', b=null) => {
const o={method:m,headers:{'Content-Type':'application/json'}};
if(b) o.body=JSON.stringify(b);
return (await fetch('/api'+p,o)).json();
};
// -- Tabs ---------------------------------------------------------------------
function swTab(show,hide){
$(show).classList.add('on'); $(hide).classList.remove('on');
document.querySelectorAll('.tab').forEach(t=>
t.classList.toggle('on',t.textContent.trim().startsWith(show==='tc'?'Heim':'Hot'))
);
}
// -- Port Slots ----------------------------------------------------------------
async function refreshDevices(){
devs = await api('/devices');
renderSources();
renderSlot('dst', cfg.dest_port, cfg.dest_label);
renderUnassigned();
populateSel();
}
let selectedPortSet = new Set();
function renderSources(){
const ports = cfg.source_ports || [];
$('sources-list').innerHTML = ports.map((sp, i) => {
const dev = devs.find(d => d.usb_port === sp.port);
const info = dev
? (dev.label||dev.device) + (dev.size ? ' | '+dev.size : '')
: 'Gerät nicht verbunden';
const chk = selectedPortSet.has(sp.port) ? 'checked' : '';
return `<div class="port-slot ${dev?'src-on':''}" style="margin-bottom:.5rem">
<div class="role-tag src">&#9650; Quelle ${i+1}${sp.label?' '+sp.label:''}</div>
<div class="port-display">
<div class="dot ${dev?'on':'off'}"></div>
<div style="min-width:0">
<div class="port-path">Port ${sp.port}</div>
<div class="port-info">${info}</div>
</div>
<label style="margin-left:auto;display:flex;align-items:center;gap:.3rem;font-size:.76rem;cursor:pointer;flex-shrink:0;white-space:nowrap">
<input type="checkbox" ${chk} onchange="toggleSrc('${sp.port}',this.checked)">
Kopieren
</label>
<button class="btn sm danger" style="margin-left:.4rem;flex-shrink:0"
onclick="removeSource('${sp.port}')">&#10005;</button>
</div>
</div>`;
}).join('') + (ports.length === 0
? '<div style="color:var(--sub);font-size:.83rem;margin-bottom:.5rem">Noch keine Quelle konfiguriert.</div>'
: '');
renderExplorerTabs();
}
function toggleSrc(port, on){
if(on) selectedPortSet.add(port); else selectedPortSet.delete(port);
}
function renderExplorerTabs(){
const ports = cfg.source_ports || [];
$('src-tabs').innerHTML = ports.map((sp, i) => {
const r = 'src_'+i;
const label = sp.label || ('Quelle '+(i+1));
return `<button class="etab ${expl.role===r?'on':''}" id="etab-${r}"
onclick="expl.switchRole('${r}')">&#9650; ${label}</button>`;
}).join('');
// Fallback: falls aktive Rolle nicht mehr existiert
if(expl.role!=='dst' && !ports.some((_,i)=>expl.role==='src_'+i)){
expl.role = ports.length>0 ? 'src_0' : 'dst';
}
}
function renderSlot(r, port, label){
const dev=devs.find(d=>d.usb_port===port);
const dot=$(r+'-dot'), pp=$(r+'-port-path'), pi=$(r+'-dev-info');
const sl=$('slot-'+r), lb=$(r+'-label');
sl.classList.toggle('dst-on', !!port);
if(port){
pp.textContent='Port '+port+(label?' | '+label:'');
if(dev){ dot.className='dot on'; pi.textContent=(dev.label||dev.device)+(dev.size?' | '+dev.size:'')+(dev.mount?' | '+dev.mount:''); }
else { dot.className='dot off'; pi.textContent='Gerät nicht verbunden'; }
} else {
dot.className='dot off'; pp.textContent='-'; pi.textContent='Kein Port konfiguriert';
}
if(lb && !lb.dataset.dirty) lb.value=label||'';
}
function populateSel(){
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
const mkOpts = filter => devs.filter(filter)
.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} - ${d.label||d.device} (${d.size})</option>`)
.join('');
const blank = v => `<option value="">- ${v} -</option>`;
const srcEl=$('src-select'), srcPrev=srcEl.value;
srcEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
+ mkOpts(d => !srcSet.has(d.usb_port) && d.usb_port !== cfg.dest_port);
if(srcPrev && devs.find(d=>d.usb_port===srcPrev)) srcEl.value=srcPrev;
const dstEl=$('dst-select'), dstPrev=dstEl.value;
dstEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
+ mkOpts(d => !srcSet.has(d.usb_port));
if(dstPrev && devs.find(d=>d.usb_port===dstPrev)) dstEl.value=dstPrev;
}
function renderUnassigned(){
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
const list=devs.filter(d=>!srcSet.has(d.usb_port)&&d.usb_port!==cfg.dest_port);
const w=$('unassigned-wrap');
if(!list.length){w.style.display='none';return;}
w.style.display='block';
$('unassigned-list').innerHTML=list.map(d=>`
<div style="display:flex;align-items:center;gap:.65rem;padding:.5rem .75rem;background:var(--bg2);border-radius:.45rem;font-size:.83rem">
<div style="width:8px;height:8px;border-radius:50%;background:var(--ylw);flex-shrink:0"></div>
<span style="font-weight:600">${d.label||d.device}</span>
<span style="color:var(--sub);font-size:.73rem">${d.device} | Port ${d.usb_port||'?'} | ${d.size}</span>
</div>`).join('');
}
async function addSource(){
const port=$('src-select').value, label=$('src-label').value.trim();
if(!port){flash('src-flash','err','Bitte zuerst ein Gerät wählen.');return;}
if(port===cfg.dest_port){flash('src-flash','err','Port bereits als Ziel konfiguriert!');return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash('src-flash','err','Port bereits als Quelle hinzugefügt!');return;}
cfg.source_ports = [...(cfg.source_ports||[]), {port, label}];
selectedPortSet.add(port);
await api('/config','POST',cfg);
$('src-label').value='';
flash('src-flash','ok','✓ Quelle Port '+port+' hinzugefügt.');
renderSources(); populateSel(); renderUnassigned();
}
async function removeSource(port){
cfg.source_ports = (cfg.source_ports||[]).filter(sp=>sp.port!==port);
selectedPortSet.delete(port);
await api('/config','POST',cfg);
renderSources(); populateSel(); renderUnassigned();
}
async function assignPort(role){
const sid='dst-select', lid='dst-label';
const fid='dst-flash', pk='dest_port', lk='dest_label';
const port=$(sid).value, label=$(lid).value.trim();
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err','Port bereits als Quelle konfiguriert!');return;}
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok','✓ Port '+port+' als Ziel gespeichert.');
renderSlot('dst',cfg.dest_port,cfg.dest_label);
populateSel(); renderUnassigned();
}
['dst-label'].forEach(id=>window.addEventListener('DOMContentLoaded',()=>{
const el=$(id); if(el) el.addEventListener('input',()=>el.dataset.dirty='1');
}));
// -- Copy ----------------------------------------------------------------------
async function startCopy(){
_dismissed=false;
if(_autoDismissTimer){ clearTimeout(_autoDismissTimer); _autoDismissTimer=null; }
const ports=[...(cfg.source_ports||[]).map(sp=>sp.port).filter(p=>selectedPortSet.has(p))];
if(!ports.length){flash('copy-hint','warn','Keine Quelle ausgewählt bitte mindestens eine Quelle anhaken.');return;}
const r=await api('/copy/start','POST',{ports});
if(r.error) flash('copy-hint','warn',r.error);
else $('copy-hint').style.display='none';
}
async function cancelCopy(){ await api('/copy/cancel','POST'); }
// -- Config --------------------------------------------------------------------
async function loadCfg(){
cfg=await api('/config');
// Migration: altes source_port-Feld -> source_ports-Array
if(!cfg.source_ports) cfg.source_ports=[];
if(cfg.source_ports.length===0 && cfg.source_port)
cfg.source_ports=[{port:cfg.source_port, label:cfg.source_label||''}];
// Alle konfigurierten Quellen standardmäßig ausgewählt
selectedPortSet = new Set(cfg.source_ports.map(sp=>sp.port));
$('c-fmt').value=cfg.folder_format||'%Y-%m-%d';
$('c-time').checked=!!cfg.add_time; $('c-sub').checked=!!cfg.subfolder; $('c-auto').checked=!!cfg.auto_copy;
$('c-filter').value=cfg.file_filter||'';
$('c-excl').checked=cfg.exclude_system!==false;
$('c-dup').value=cfg.duplicate_handling||'skip';
$('c-verify').checked=!!cfg.verify_checksum;
$('c-delsrc').checked=!!cfg.delete_source;
$('w-ssid').value=cfg.wifi_ssid||''; $('ap-ssid').value=cfg.ap_ssid||'PiCopy';
}
async function saveCopyCfg(){
cfg.folder_format=$('c-fmt').value; cfg.add_time=$('c-time').checked;
cfg.subfolder=$('c-sub').checked; cfg.auto_copy=$('c-auto').checked;
cfg.file_filter=$('c-filter').value.trim();
cfg.exclude_system=$('c-excl').checked;
cfg.duplicate_handling=$('c-dup').value;
cfg.verify_checksum=$('c-verify').checked;
cfg.delete_source=$('c-delsrc').checked;
await api('/config','POST',cfg);
const m=$('copy-cfg-msg'); m.style.display='block';
setTimeout(()=>m.style.display='none',2500);
}
function setFilter(v){ $('c-filter').value=v; }
// -- WiFi ----------------------------------------------------------------------
async function scanNets(){
$('net-list').style.display='flex'; $('net-list').innerHTML='<div class="expl-empty" style="padding:.5rem">Suche...</div>';
const nets=await api('/wifi/scan');
if(!nets.length){$('net-list').innerHTML='<div class="expl-empty" style="padding:.5rem">Keine Netzwerke</div>';return;}
$('net-list').innerHTML=nets.map(n=>{
const b=n.signal>66?'▂▄▆█':n.signal>33?'▂▄▆░':'▂▄░░';
return`<div class="net-row" onclick="pickNet('${n.ssid.replace(/'/g,"\\'")}')"><span>${n.ssid}</span><span class="net-sig">${b} ${n.signal}%</span></div>`;
}).join('');
}
function pickNet(s){$('w-ssid').value=s;$('net-list').style.display='none';$('w-pw').focus();}
async function connectWifi(){
const ssid=$('w-ssid').value.trim(),pw=$('w-pw').value;
if(!ssid){flash('wifi-flash','err','Bitte SSID eingeben');return;}
flash('wifi-flash','ok','Verbinde... (bis 30s)');
const r=await api('/wifi/connect','POST',{ssid,password:pw});
if(r.error) flash('wifi-flash','err',r.error);
else flash('wifi-flash','ok','Gestartet. Neue IP erscheint oben.');
}
async function saveAP(){
const s=$('ap-ssid').value.trim(),p=$('ap-pw').value;
if(!s){flash('ap-flash','err','Name fehlt');return;}
if(p.length<8){flash('ap-flash','err','Min. 8 Zeichen');return;}
const r=await api('/wifi/ap','POST',{ssid:s,password:p});
if(r.error) flash('ap-flash','err',r.error);
else flash('ap-flash','ok','Gespeichert! Hotspot startet neu.');
}
// -- Upload-Ziele --------------------------------------------------------------
let utTargets=[], _utConn={};
async function loadUTs(){utTargets=await api('/upload/targets');renderUTs();}
function renderUTs(){
const el=$('ut-list');
if(!utTargets.length){el.innerHTML='<div class="empty">Noch keine Fernziele konfiguriert</div>';return;}
el.innerHTML=utTargets.map(t=>`
<div class="ut-row ${t.enabled?'on':''}">
<span class="ut-ico">🖧</span>
<div style="flex:1;min-width:0">
<div class="ut-nm">${t.name}</div>
<div class="ut-meta">SMB/NAS | ${t.dest_path}</div>
</div>
<div class="ut-acts">
<button class="btn sm ghost" id="ut-test-${t.id}" onclick="utTest('${t.id}')">&#128269; Test</button>
<button class="btn sm ${t.enabled?'grn':'ghost'}" onclick="utToggle('${t.id}')">${t.enabled?'Aktiv':'Inaktiv'}</button>
<button class="btn sm danger" onclick="utDel('${t.id}','${t.name}')">✕</button>
</div>
<div id="ut-test-result-${t.id}" style="display:none;font-size:.76rem;margin-top:.35rem;padding:.3rem .5rem;border-radius:.35rem"></div>
</div>`).join('');
}
function utToggleForm(){
const f=$('ut-form'),b=$('ut-add-btn'),show=f.style.display==='none';
f.style.display=show?'block':'none';
b.innerHTML=show?'✕ Abbrechen':'+ NAS-Ziel hinzufügen';
if(show){
$('ut-step1').style.display=''; $('ut-step2').style.display='none';
['ut-host','ut-user','ut-pass','ut-name'].forEach(id=>{$(id).value='';});
$('ut-dest').value='PiCopy';
$('ut-form-flash').style.display='none';
_utConn={};
}
}
async function utConnect(){
const host=$('ut-host').value.trim();
if(!host){flash('ut-form-flash','err','Server-Adresse fehlt');return;}
const btn=$('ut-connect-btn');
btn.disabled=true; btn.textContent='Verbinde...';
$('ut-form-flash').style.display='none';
const r=await api('/upload/browse','POST',{
host, user:$('ut-user').value.trim(), pass:$('ut-pass').value
});
btn.disabled=false; btn.innerHTML='&#128279;&nbsp;Verbinden &amp; Freigaben laden';
if(r.error){flash('ut-form-flash','err',''+r.error);return;}
if(!r.shares||!r.shares.length){flash('ut-form-flash','warn','Verbunden, aber keine Freigaben gefunden');return;}
_utConn={host, user:$('ut-user').value.trim(), pass:$('ut-pass').value};
$('ut-share-sel').innerHTML=r.shares.map(s=>`<option value="${s}">${s}</option>`).join('');
if(!$('ut-name').value) $('ut-name').value=host;
$('ut-step1').style.display='none'; $('ut-step2').style.display='';
}
function utBack(){
$('ut-step1').style.display=''; $('ut-step2').style.display='none';
$('ut-form-flash').style.display='none';
}
async function utSave(){
const name=$('ut-name').value.trim(), dest=$('ut-dest').value.trim()||'PiCopy';
const share=$('ut-share-sel').value;
if(!name){flash('ut-form-flash','err','Name fehlt');return;}
if(!share){flash('ut-form-flash','err','Bitte eine Freigabe wählen');return;}
const body={type:'smb',name,dest_path:dest,share,
host:_utConn.host, user:_utConn.user, pass:_utConn.pass};
flash('ut-form-flash','warn','Speichere...');
const r=await api('/upload/targets','POST',body);
if(r.error){flash('ut-form-flash','err',r.error);return;}
flash('ut-form-flash','warn','Teste Verbindung - Schreibzugriff wird geprüft...');
try{
const t=await api('/upload/targets/'+r.id+'/test','POST');
if(t.ok){flash('ut-form-flash','ok','✓ Verbindung OK - Lesen & Schreiben erfolgreich');utToggleForm();await loadUTs();}
else flash('ut-form-flash','err',''+(t.error||'Test fehlgeschlagen'));
}catch(e){flash('ut-form-flash','err','✗ Test fehlgeschlagen (Server-Timeout)');}
}
async function utTest(id){
const btn=$('ut-test-'+id), res=$('ut-test-result-'+id);
btn.disabled=true; btn.textContent='Teste...';
res.style.display='none';
const r=await api('/upload/targets/'+id+'/test','POST');
btn.disabled=false; btn.innerHTML='&#128269; Test';
res.style.display='block';
if(r.ok){
res.style.background='rgba(52,211,153,.12)'; res.style.color='var(--grn)';
res.textContent='✓ Verbindung OK - Lesen & Schreiben erfolgreich';
} else {
res.style.background='rgba(248,113,113,.1)'; res.style.color='var(--red)';
res.textContent='' + (r.error||'Test fehlgeschlagen');
}
}
async function utToggle(id){await api('/upload/targets/'+id+'/toggle','POST');await loadUTs();}
async function utDel(id,name){
if(!confirm('"'+name+'" wirklich löschen?'))return;
await api('/upload/targets/'+id,'DELETE');await loadUTs();
}
// -- File Explorer -------------------------------------------------------------
const expl={
role:'src_0', paths:{dst:''},
switchRole(r){
this.role=r;
document.querySelectorAll('.etab').forEach(t=>t.classList.remove('on'));
const tab=$('etab-'+r); if(tab) tab.classList.add('on');
this.load(this.paths[r]||'');
},
reload(){this.load(this.paths[this.role]||'');},
navigate(p){this.load(p);},
async load(path=''){
let port;
if(this.role==='dst'){
port=cfg.dest_port;
} else {
const idx=parseInt(this.role.replace('src_',''),10);
port=cfg.source_ports&&cfg.source_ports[idx]?cfg.source_ports[idx].port:null;
}
const body=$('expl-body'), bread=$('expl-bread');
if(!port){body.innerHTML='<div class="expl-empty">Kein Port konfiguriert</div>';bread.innerHTML='';return;}
const dev=devs.find(d=>d.usb_port===port);
if(!dev){body.innerHTML='<div class="expl-empty">Gerät nicht verbunden</div>';bread.innerHTML='<span style="color:var(--sub)">Port '+port+' - nicht verbunden</span>';return;}
body.innerHTML='<div class="expl-empty">Lade...</div>';
try{
const data=await api('/browse?port='+encodeURIComponent(port)+'&path='+encodeURIComponent(path));
if(data.error){body.innerHTML='<div class="expl-empty">⚠ '+data.error+'</div>';return;}
this.paths[this.role]=data.path||''; // role z.B. 'src_0', 'dst'
this._bread(data.path||'',dev.label||dev.device);
this._list(data.entries||[],data.path||'');
}catch(e){body.innerHTML='<div class="expl-empty">Verbindungsfehler</div>';}
},
_bread(path,label){
const el=$('expl-bread');
let h=`<span class="bseg" onclick="expl.navigate('')" title="${label}">⌂ ${label}</span>`;
if(path){
let acc='';
path.split('/').filter(Boolean).forEach(p=>{
acc+=(acc?'/':'')+p;const a=acc;
h+=`<span class="bsep"> > </span><span class="bseg" onclick="expl.navigate('${a.replace(/'/g,"\\'")}')">${p}</span>`;
});
}
el.innerHTML=h;
},
_list(entries,cur){
const body=$('expl-body');
let h='';
if(cur){
const par=cur.includes('/')?cur.substring(0,cur.lastIndexOf('/')):'';
h+=`<div class="expl-row up" onclick="expl.navigate('${par}')"><span class="expl-ico"><-</span><span class="expl-nm" style="color:var(--sub)">..</span><span></span><span></span></div>`;
}
if(!entries.length&&!cur){body.innerHTML='<div class="expl-empty">Laufwerk leer</div>';return;}
if(!entries.length){body.innerHTML=h+'<div class="expl-empty">Ordner leer</div>';return;}
entries.forEach(e=>{
const ico=e.dir?'📁':fileIcon(e.name);
const np=(cur?cur+'/':'')+e.name;
const click=e.dir?`onclick="expl.navigate('${np.replace(/'/g,"\\'")}')" `:'';
h+=`<div class="expl-row ${e.dir?'dir':''}" ${click}>
<span class="expl-ico">${ico}</span>
<span class="expl-nm">${e.name}</span>
<span class="expl-sz">${e.size!=null?fmtBytes(e.size):''}</span>
<span class="expl-dt">${e.mtime||''}</span>
</div>`;
});
body.innerHTML=h;
}
};
function fileIcon(n){
const e=(n.split('.').pop()||'').toLowerCase();
if(['jpg','jpeg','png','gif','bmp','raw','cr2','nef','arw','heic','webp','dng'].includes(e))return'🖼';
if(['mp4','mov','avi','mkv','mts','m2ts','wmv','3gp'].includes(e))return'🎬';
if(['mp3','wav','flac','aac','m4a','ogg'].includes(e))return'🎵';
if(['pdf','doc','docx','txt','xls','xlsx'].includes(e))return'📄';
if(['zip','rar','7z','tar','gz'].includes(e))return'🗜';
return'📄';
}
function fmtBytes(b){
if(!b)return'';
if(b<1024)return b+' B';
if(b<1048576)return(b/1024).toFixed(1)+' KB';
if(b<1073741824)return(b/1048576).toFixed(1)+' MB';
return(b/1073741824).toFixed(2)+' GB';
}
function fmtETA(s){
if(!s||s<=0)return'';
if(s<60)return'<1 Min.';
const m=Math.round(s/60);
if(m<60)return'~'+m+' Min.';
const h=Math.floor(m/60),rm=m%60;
return'~'+h+'h'+(rm?' '+rm+'m':'');
}
function fmtSpd(bps){
if(!bps||bps<=0)return'';
if(bps<1048576)return(bps/1024).toFixed(0)+' KB/s';
return(bps/1048576).toFixed(1)+' MB/s';
}
// -- Poll ----------------------------------------------------------------------
async function poll(){
try{
const {copy:c,wifi:w,vpn:v}=await api('/status');
// VPN Topbar + Card
if(v){
const vp=$('vpn-pill'),vdot=$('vpn-dot'),vl=$('vpn-label'),vi=$('vpn-ip');
const ni=$('wg-not-installed'),pp=$('wg-pkg-progress'),ui=$('wg-installed-ui');
if(v.pkg_running){
ni.style.display='none'; pp.style.display='block'; ui.style.display='none';
const act=v.pkg_action==='remove'?'Deinstalliere':'Installiere';
$('wg-pkg-title').textContent=act+' WireGuard...';
$('wg-pkg-icon').textContent='';
$('wg-status-sub').textContent=act+'...';
vp.style.display='none';
} else if(!v.installed){
ni.style.display='block'; pp.style.display='none'; ui.style.display='none';
$('wg-status-sub').textContent='Nicht installiert';
vp.style.display='none';
if(v.pkg_error) flash('wg-flash','err',v.pkg_error);
} else {
ni.style.display='none'; pp.style.display='none'; ui.style.display='block';
const wgd=$('wg-dot'),wgl=$('wg-label'),wgdet=$('wg-detail');
const bc=$('wg-btn-connect'),bd=$('wg-btn-disconnect');
if(v.connected){
vp.style.display='flex'; vdot.className='wdot c';
vl.textContent='VPN aktiv'; vi.textContent=v.ip||'';
wgd.className='wdot c'; wgl.textContent='Verbunden';
wgdet.textContent=v.ip?(v.ip+(v.peer?' | peer ...'+v.peer.slice(-8):'')):'';
bc.style.display='none'; bd.style.display=''; bd.disabled=false;
$('wg-status-sub').textContent=v.ip||'';
} else {
vp.style.display=v.has_config?'flex':'none';
vdot.className='wdot d'; vl.textContent='VPN'; vi.textContent='';
wgd.className='wdot d'; wgl.textContent='Getrennt';
wgdet.textContent=v.error||'';
bc.style.display=v.has_config?'':'none'; bc.disabled=false; bd.style.display='none';
$('wg-status-sub').textContent=v.has_config?'Konfiguriert':'Nicht konfiguriert';
}
}
}
// WiFi
const wd=$('wdot'),wl=$('wifi-label'),wi=$('wifi-ip');
if(w.mode==='client'){wd.className='wdot c';wl.textContent=w.ssid||'Verbunden';wi.textContent=w.ip||'';}
else if(w.mode==='ap'){wd.className='wdot a';wl.textContent='Hotspot: '+(w.ssid||'PiCopy');wi.textContent='10.42.0.1';}
else{wd.className='wdot d';wl.textContent='Kein WLAN';wi.textContent='';}
// Copy
const tx=$('st-text'),pf=$('prog-fill'),pw=$('prog-wrap'),pp=$('prog-pct');
const pfiles=$('prog-files'),pbytes=$('prog-bytes'),eta=$('eta-pill'),spd=$('speed-pill');
const cf=$('cur-file'),sum=$('st-summary'),time=$('st-time');
const bS=$('btn-start'),bC=$('btn-cancel');
if(c.running){
const ph=c.phase||'copy';
if(ph==='verify'){
tx.className='st-headline st-run'; tx.textContent='Verifiziere... '+c.progress+'%';
pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%';
pp.style.display=''; pp.textContent=c.progress+'%';
pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' geprüft';
pbytes.style.display='none'; eta.style.display='none'; spd.style.display='none';
cf.textContent=c.current||'';
} else if(ph==='delete'){
tx.className='st-headline st-run'; tx.textContent='Quelle wird geleert...';
pw.style.display='none'; pp.style.display='none'; pfiles.style.display='none';
pbytes.style.display='none'; eta.style.display='none'; spd.style.display='none';
cf.textContent='';
} else {
tx.className='st-headline st-run'; tx.textContent='Kopiert... '+c.progress+'%';
pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%';
pp.style.display=''; pp.textContent=c.progress+'%';
pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' Dateien';
if(c.bytes_total>0){pbytes.style.display='';pbytes.textContent=fmtBytes(c.bytes_done)+' / '+fmtBytes(c.bytes_total);}else pbytes.style.display='none';
const e=fmtETA(c.eta_sec); eta.style.display=e?'':'none'; eta.textContent=e?''+e:'';
const s=fmtSpd(c.speed_bps); spd.style.display=s?'':'none'; spd.textContent=s?''+s:'';
cf.textContent=c.current||'';
}
sum.textContent=''; bS.style.display='none'; bC.style.display=''; time.textContent='';
}else{
bS.style.display=''; bC.style.display='none'; cf.textContent='';
eta.style.display='none'; spd.style.display='none'; pfiles.style.display='none'; pbytes.style.display='none'; pp.style.display='none';
if(c.error){
tx.className='st-headline st-err'; tx.textContent='Fehler: '+c.error;
pf.className='prog-fill err'; pw.style.display='block'; pf.style.width='100%';
sum.textContent=''; time.textContent='';
}else if(c.last_copy && !_dismissed){
tx.className='st-headline st-ok'; tx.textContent='✓ Abgeschlossen';
pf.className='prog-fill done'; pw.style.display='block'; pf.style.width='100%';
sum.textContent=c.total+' Dateien | '+fmtBytes(c.bytes_total);
time.textContent=new Date(c.last_copy).toLocaleString('de-DE');
$('st-dismiss').style.display='';
// Auto-dismiss nach 5 Minuten
if(!_autoDismissTimer && c.last_copy){
const age=(Date.now()-new Date(c.last_copy).getTime())/1000;
const remaining=Math.max(0,300-age);
_autoDismissTimer=setTimeout(dismissStatus, remaining*1000);
}
}else{
tx.className='st-headline st-idle'; tx.textContent='Bereit';
pw.style.display='none'; sum.textContent=''; time.textContent='';
$('st-dismiss').style.display='none';
}
}
// Log
if(c.logs&&c.logs.length)
$('log-box').innerHTML=c.logs.slice().reverse().map(l=>`<div class="log-row"><span class="log-t">${l.t}</span><span class="log-m">${l.m}</span></div>`).join('');
}catch(e){}
// Upload status
try{
const u=await api('/upload/status');
const ub=$('upload-block');
if(u.running||u.results.length){
ub.style.display='block';
$('upload-current').innerHTML=u.running?''+u.current+'...':'';
$('upload-results').innerHTML=u.results.map(r=>`<div style="font-size:.79rem;color:${r.ok?'var(--grn)':'var(--red)'}">${r.ok?'':''} ${r.name}${r.msg?' - '+r.msg:''}</div>`).join('');
}else ub.style.display='none';
}catch(e){}
}
let _dismissed = false, _autoDismissTimer = null;
function dismissStatus(){
_dismissed = true;
if(_autoDismissTimer){ clearTimeout(_autoDismissTimer); _autoDismissTimer=null; }
$('st-text').className='st-headline st-idle'; $('st-text').textContent='Bereit';
$('prog-wrap').style.display='none';
$('st-summary').textContent=''; $('st-time').textContent='';
$('st-dismiss').style.display='none';
}
// -- Update --------------------------------------------------------------------
async function pollUpdate() {
try {
const u = await api('/update/status');
const badge = $('upd-badge'), vEl = $('upd-version');
if (u.available && u.latest) {
vEl.textContent = 'v' + u.latest;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
} catch(e) {}
}
async function installUpdate() {
const u = await api('/update/status');
const latest = (u.latest || '?');
if (!confirm(
'Update auf v' + latest + ' installieren?\n\n' +
'PiCopy lädt die neue Version herunter und startet neu.\n' +
'Das Web-Interface ist für ca. 10 Sekunden nicht erreichbar.'
)) return;
$('upd-badge').innerHTML = '&#8595; Installiere...';
$('upd-badge').style.pointerEvents = 'none';
try {
await api('/update/install', 'POST');
} catch(e) {}
// Warte bis der Dienst wieder läuft, dann reload
setTimeout(async function waitForRestart() {
try {
await fetch('/api/update/status');
location.reload();
} catch(e) {
setTimeout(waitForRestart, 2000);
}
}, 5000);
}
async function checkUpdate() {
const btn = event.currentTarget;
btn.disabled = true; btn.innerHTML = '&#128269;&nbsp;Prüfe...';
try {
await api('/update/check', 'POST');
// Warten bis der Server-Check abgeschlossen ist (max 15 s, alle 500 ms)
let u;
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 500));
u = await api('/update/status');
if (!u.checking) break;
}
await pollUpdate(); // Badge sofort aktualisieren
const fl = $('sys-update-flash');
if (u.available && u.latest) {
fl.className = 'flash warn'; fl.textContent = 'Update v' + u.latest + ' verfügbar - über das Badge oben installieren.';
} else if (u.error) {
fl.className = 'flash err'; fl.textContent = 'Fehler: ' + u.error;
} else {
fl.className = 'flash ok'; fl.textContent = 'PiCopy ist aktuell.';
}
fl.style.display = 'block';
if (fl.className.includes('ok')) setTimeout(() => fl.style.display = 'none', 3500);
} catch(e) {
const fl = $('sys-update-flash');
fl.className = 'flash err'; fl.textContent = 'Verbindung fehlgeschlagen.'; fl.style.display = 'block';
} finally {
btn.disabled = false; btn.innerHTML = '🔍&nbsp;Nach Update suchen';
}
}
async function rebootDevice() {
if (!confirm('Gerät jetzt neu starten?\n\nDas Web-Interface ist für ca. 30 Sekunden nicht erreichbar.')) return;
try { await api('/system/reboot', 'POST'); } catch(e) {}
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;color:#888;font-size:1rem">&#8634; Gerät startet neu - bitte warten...</div>';
setTimeout(async function waitForRestart() {
try { await fetch('/api/update/status'); location.reload(); }
catch(e) { setTimeout(waitForRestart, 2000); }
}, 10000);
}
// -- WireGuard VPN -------------------------------------------------------------
async function wgInstall(){
if(!confirm('wireguard + wireguard-tools jetzt per apt-get installieren?\n\nDauer: ca. 30-90 Sekunden.'))return;
flash('wg-flash','ok','Starte Installation...');
const r=await api('/wireguard/install','POST');
if(r.error) flash('wg-flash','err',r.error);
}
async function wgUninstall(){
if(!confirm('WireGuard wirklich deinstallieren?\n\nDer aktive VPN-Tunnel wird vorher getrennt.\nDie Konfigurationsdatei bleibt erhalten.'))return;
flash('wg-flash','ok','Deinstalliere...');
const r=await api('/wireguard/uninstall','POST');
if(r.error) flash('wg-flash','err',r.error);
}
async function wgConnect(){
$('wg-btn-connect').disabled=true;
flash('wg-flash','ok','Verbinde VPN...');
await api('/wireguard/connect','POST');
}
async function wgDisconnect(){
$('wg-btn-disconnect').disabled=true;
flash('wg-flash','ok','Trenne VPN...');
const r=await api('/wireguard/disconnect','POST');
if(!r.ok) flash('wg-flash','err','Trennen fehlgeschlagen');
}
async function wgSaveConfig(){
const content=$('wg-config').value.trim();
if(!content){flash('wg-flash','err','Konfiguration ist leer');return;}
if(!content.includes('[Interface]')){flash('wg-flash','err','Ungültige Konfiguration - [Interface] fehlt');return;}
const auto=$('wg-auto').checked;
flash('wg-flash','ok','Speichere...');
const r=await api('/wireguard/config','POST',{content,auto});
if(r.error){flash('wg-flash','err',r.error);return;}
flash('wg-flash','ok','✓ Konfiguration gespeichert');
}
async function loadWgConfig(){
try{
const r=await api('/wireguard/config');
if(r.exists && r.config) $('wg-config').value=r.config;
const c=await api('/config');
$('wg-auto').checked=!!c.wireguard_auto;
}catch(e){}
}
function flash(id,cls,msg){
const el=$(id); el.className='flash '+cls; el.textContent=msg; el.style.display='block';
if(cls==='ok') setTimeout(()=>el.style.display='none',3500);
}
(async()=>{
await loadCfg();
await refreshDevices();
await loadUTs();
await loadWgConfig();
expl.load('');
setInterval(poll,1500);
setInterval(refreshDevices,8000);
setInterval(pollUpdate,60000);
poll();
pollUpdate();
setTimeout(pollUpdate, 8000); // Server-Check-Ergebnis abholen bevor der 60s-Takt greift
})();
</script>
</body>
</html>"""
if __name__ == '__main__':
cleanup_stale_mounts()
load_state()
wg_update_state()
threading.Thread(target=usb_monitor, daemon=True).start()
threading.Thread(target=wifi_monitor, daemon=True).start()
threading.Thread(target=wg_monitor, daemon=True).start()
threading.Thread(target=update_check_loop, daemon=True).start()
if load_cfg().get('wireguard_auto') and WG_CONF.exists():
threading.Thread(target=wg_connect, daemon=True).start()
log.info(f'PiCopy v{VERSION} läuft auf http://0.0.0.0:8080')
app.run(host='0.0.0.0', port=8080, debug=False, use_reloader=False)