Files
PiCopy/app.py

3711 lines
155 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 posixpath
import select
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'
INTERNAL_DEST_DIR = BASE_DIR / 'internal'
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': '',
'dest_type': 'usb', 'internal_dest_label': 'Interner Speicher',
'internal_share_enabled': False,
'ui_lang': 'de',
'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)
# -- Interner Speicher / SMB-Freigabe -----------------------------------------
SAMBA_CONF = Path('/etc/samba/smb.conf')
SAMBA_BEGIN = '# BEGIN PICOPY INTERNAL SHARE'
SAMBA_END = '# END PICOPY INTERNAL SHARE'
internal_share_state = {
'installed': False,
'enabled': False,
'active': False,
'path': str(INTERNAL_DEST_DIR),
'share': 'PiCopy',
'pkg_running': False,
'pkg_error': None,
'error': None,
}
internal_share_lock = threading.Lock()
def _internal_usage():
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
usage = shutil.disk_usage(INTERNAL_DEST_DIR)
return {
'path': str(INTERNAL_DEST_DIR),
'total': usage.total,
'used': usage.used,
'free': usage.free,
}
def internal_dest_device(cfg=None):
cfg = cfg or load_cfg()
usage = _internal_usage()
return {
'device': 'internal',
'usb_port': '__internal__',
'mount': str(INTERNAL_DEST_DIR),
'label': cfg.get('internal_dest_label') or 'Interner Speicher',
'size': _fmt_bytes(usage['free']) + ' frei',
'internal': True,
}
def smbd_installed():
return shutil.which('smbd') is not None
def _systemctl(*args, timeout=20):
try:
return subprocess.run(['systemctl'] + list(args), capture_output=True,
text=True, timeout=timeout)
except Exception as e:
return subprocess.CompletedProcess(['systemctl'] + list(args), 1,
stdout='', stderr=str(e))
def _smbd_active():
if not smbd_installed():
return False
r = _systemctl('is-active', 'smbd', timeout=5)
return r.returncode == 0 and r.stdout.strip() == 'active'
def internal_share_update_state():
cfg = load_cfg()
usage = _internal_usage()
with internal_share_lock:
internal_share_state.update(
installed=smbd_installed(),
enabled=bool(cfg.get('internal_share_enabled')),
active=_smbd_active(),
path=usage['path'],
total=usage['total'],
used=usage['used'],
free=usage['free'],
)
return dict(internal_share_state)
def _write_samba_share(enabled: bool):
old = SAMBA_CONF.read_text(encoding='utf-8') if SAMBA_CONF.exists() else ''
pattern = re.compile(rf'\n?{re.escape(SAMBA_BEGIN)}.*?{re.escape(SAMBA_END)}\n?', re.S)
cleaned = pattern.sub('\n', old).rstrip() + '\n'
if enabled:
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
INTERNAL_DEST_DIR.chmod(0o755)
block = f"""
{SAMBA_BEGIN}
[PiCopy]
path = {INTERNAL_DEST_DIR}
browseable = yes
read only = yes
guest ok = yes
force user = root
{SAMBA_END}
"""
cleaned += block
tmp = SAMBA_CONF.with_suffix('.conf.picopy_tmp')
tmp.write_text(cleaned, encoding='utf-8')
os.replace(str(tmp), str(SAMBA_CONF))
def _install_samba_if_needed():
if smbd_installed():
return True, ''
with internal_share_lock:
internal_share_state.update(pkg_running=True, pkg_error=None)
try:
r = subprocess.run(['apt-get', 'install', '-y', 'samba'],
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 'samba-Installation fehlgeschlagen')
with internal_share_lock:
internal_share_state['pkg_error'] = err
return False, err
return True, ''
except Exception as e:
with internal_share_lock:
internal_share_state['pkg_error'] = str(e)
return False, str(e)
finally:
with internal_share_lock:
internal_share_state['pkg_running'] = False
def set_internal_share_enabled(enabled: bool):
ok, err = (True, '')
if enabled:
ok, err = _install_samba_if_needed()
if not ok:
return False, err
elif not smbd_installed():
cfg = load_cfg()
cfg['internal_share_enabled'] = False
save_cfg(cfg)
internal_share_update_state()
return True, ''
try:
_write_samba_share(enabled)
if enabled:
_systemctl('enable', '--now', 'smbd', timeout=60)
_systemctl('restart', 'smbd', timeout=60)
else:
_systemctl('restart', 'smbd', timeout=60)
cfg = load_cfg()
cfg['internal_share_enabled'] = enabled
save_cfg(cfg)
internal_share_update_state()
return True, ''
except Exception as e:
with internal_share_lock:
internal_share_state['error'] = str(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):
if dev_info.get('internal'):
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
return str(INTERNAL_DEST_DIR), False
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 _configured_destination(cfg, devs):
if cfg.get('dest_type') == 'internal':
return internal_dest_device(cfg)
return next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None)
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_files = [dst_f for _, dst_f in verified_pairs if dst_f.exists()]
if upload_files:
_upload_thread = threading.Thread(
target=run_uploads,
args=(dst_dir_root, cfg, upload_files),
daemon=True
)
_upload_thread.start()
elif any(t.get('enabled') for t in cfg.get('upload_targets', [])):
add_log('NAS-Upload: keine neu auf das Ziel übertragenen Dateien')
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:
return
if cfg.get('dest_type') != 'internal' and 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 = _configured_destination(cfg, devs)
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': [],
'progress': 0,
'total': 0,
'done': 0,
'bytes_total': 0,
'bytes_done': 0,
'current_file': '',
'eta_sec': None,
'speed_bps': 0,
}
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 _parse_percent(text: str):
m = re.search(r'(\d+(?:\.\d+)?)%', text)
if not m:
return None
try:
return max(0.0, min(100.0, float(m.group(1))))
except ValueError:
return None
def _rclone_copyto_progress(src: Path, dest: str, base_done: int,
file_size: int, total_bytes: int, start_ts: float,
timeout: int = 7200):
args = [
'rclone', '--config', str(RCLONE_CONF),
'copyto', str(src), dest,
'--retries', '1',
'--progress',
'--stats', '1s',
'--stats-one-line',
]
try:
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1)
started = time.time()
stderr_parts = []
buf = ''
while True:
if p.poll() is not None:
break
if time.time() - started > timeout:
p.kill()
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'Timeout nach {timeout}s')
ready, _, _ = select.select([p.stderr], [], [], 0.2) if p.stderr else ([], [], [])
if not ready:
time.sleep(0.1)
continue
chunk = p.stderr.read(1)
if not chunk:
continue
stderr_parts.append(chunk)
if chunk not in ('\r', '\n'):
buf += chunk
continue
pct = _parse_percent(buf)
buf = ''
if pct is not None:
transferred = int(file_size * pct / 100)
bytes_done = base_done + transferred
elapsed = time.time() - start_ts
speed = bytes_done / elapsed if elapsed > 1 else 0
eta = int((total_bytes - bytes_done) / speed) if speed > 0 and total_bytes > bytes_done else 0
with upload_lock:
upload_state.update(bytes_done=bytes_done,
progress=int(bytes_done / total_bytes * 100) if total_bytes else 100,
speed_bps=int(speed), eta_sec=eta)
stdout, stderr_tail = p.communicate(timeout=5)
if stderr_tail:
stderr_parts.append(stderr_tail)
return subprocess.CompletedProcess(args, p.returncode, stdout=stdout or '',
stderr=''.join(stderr_parts))
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 _remote_name(tid):
return f'picopy_{tid}'
def _join_remote_path(*parts) -> str:
return '/'.join(str(p).strip('/') for p in parts if str(p).strip('/'))
def _remote_exists(remote_path: str) -> bool:
return _remote_size(remote_path) is not None
def _remote_size(remote_path: str):
r = _rclone('lsjson', remote_path, timeout=20)
if r.returncode != 0:
return None
try:
data = json.loads(r.stdout or '[]')
if isinstance(data, dict):
return data.get('Size')
if isinstance(data, list) and data:
item = data[0]
return item.get('Size') if isinstance(item, dict) else None
return None
except (json.JSONDecodeError, ValueError):
return None
def _remote_unique_rel_path(t: dict, rel_path: str) -> str:
if not _remote_exists(_smb_conn(t, rel_path)):
return rel_path
parent = posixpath.dirname(rel_path)
name = posixpath.basename(rel_path)
stem, suffix = posixpath.splitext(name)
i = 1
while True:
candidate_name = f'{stem}_({i}){suffix}'
candidate = _join_remote_path(parent, candidate_name)
if not _remote_exists(_smb_conn(t, candidate)):
return candidate
i += 1
def _smb_conn(t: dict, path: str = '') -> str:
"""Baut ein rclone-Ziel fuer gespeicherte SMB-Targets.
Bei rclone SMB ist die Freigabe der erste Pfadteil nach dem Remote:
remote:share/ordner. Die Remote-Konfiguration enthaelt Host und Login.
"""
share = t.get('smb_share', '')
remote_path = _join_remote_path(share, path)
if t.get('id'):
return f'{_remote_name(t["id"])}:{remote_path}'
host = t.get('smb_host', '')
if not host:
return f':{remote_path}'
conn = f':smb,host={host}'
if t.get('smb_user'):
conn += f',user={t["smb_user"]}'
if t.get('smb_pass'):
conn += f',pass={t["smb_pass"]}'
conn += f':{remote_path}'
return conn
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}']
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):
cfg = load_cfg()
targets = cfg.get('upload_targets', [])
t = next((x for x in targets if x['id'] == tid), {'id': tid})
dest_root = t.get('dest_path', 'PiCopy').strip('/')
root = _smb_conn(t)
dest = _smb_conn(t, dest_root)
test_dir_name = '.picopy_writetest'
test_dir = _smb_conn(t, f'{dest_root}/{test_dir_name}' if dest_root else test_dir_name)
# 1. Verbindung prüfen
r = _rclone('lsd', root, 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. Zielordner und Schreibzugriff prüfen: Ziel anlegen, Testverzeichnis anlegen + sofort löschen
mk = _rclone('mkdir', dest, timeout=15)
if mk.returncode != 0:
err = mk.stderr.strip().splitlines()[-1] if mk.stderr.strip() else 'Zielordner konnte nicht angelegt werden'
return False, f'Zielordner: {err}'
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, upload_files=None):
"""Lädt die zuletzt lokal geschriebenen Dateien zu allen aktiven Fernzielen hoch."""
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='',
progress=0, total=0, done=0,
bytes_total=0, bytes_done=0,
current_file='', eta_sec=None, speed_bps=0)
for t in targets:
name = t.get('name', t['id'])
with upload_lock:
upload_state.update(current=name, progress=0, total=0, done=0,
bytes_total=0, bytes_done=0,
current_file='', eta_sec=None, speed_bps=0)
add_log(f'Upload >> {name}...')
dest_root = t.get('dest_path', 'PiCopy').strip('/')
root = _smb_conn(t)
# local_dir ist der lokal erzeugte Datumsordner. Auf dem NAS soll die
# gleiche Struktur entstehen wie auf dem Ziellaufwerk: Ziel/Datum/...
dest_rel = _join_remote_path(dest_root, local_dir.name)
dest = _smb_conn(t, dest_rel)
share = t.get('smb_share', '')
dest_label = _join_remote_path(share, dest_rel) or '/'
add_log(f'Upload {name}: Ziel {dest_label}')
# Quellverzeichnis prüfen
if not local_dir.exists():
err = f'Quellverzeichnis nicht gefunden: {local_dir}'
add_log(f'Upload {name}: ✗ {err}')
with upload_lock:
upload_state['results'].append({'name': name, 'ok': False, 'msg': err})
continue
# 1. Verbindung prüfen
conn = _rclone('lsd', root, timeout=15)
add_log(f'Upload {name}: Verbindung rc={conn.returncode}')
if conn.returncode != 0:
err = (conn.stderr.strip().splitlines()[-1] if conn.stderr.strip()
else 'NAS nicht erreichbar')
add_log(f'Upload {name}: ✗ {err}')
with upload_lock:
upload_state['results'].append({'name': name, 'ok': False, 'msg': err})
continue
# 2. Zielordner anlegen
mk = _rclone('mkdir', dest, timeout=30)
add_log(f'Upload {name}: mkdir rc={mk.returncode}'
+ (f' err={mk.stderr.strip()[:100]}' if mk.returncode != 0 else ''))
# 3. Kopieren mit Fortschritt
add_log(f'Upload {name}: starte copy von {local_dir}')
dup_mode = cfg.get('duplicate_handling', 'skip')
if upload_files is None:
files = sorted(f for f in local_dir.rglob('*') if f.is_file())
else:
files = []
for f in upload_files:
f = Path(f)
try:
f.relative_to(local_dir)
except ValueError:
continue
if f.is_file():
files.append(f)
files = sorted(files)
dirs = sorted({p for f in files for p in f.relative_to(local_dir).parents
if str(p) != '.'})
bytes_total = sum(f.stat().st_size for f in files)
with upload_lock:
upload_state.update(total=len(files), bytes_total=bytes_total,
progress=100 if not files else 0)
for d in dirs:
_rclone('mkdir', _smb_conn(t, _join_remote_path(dest_rel, d.as_posix())), timeout=30)
errors = []
skipped = 0
start_ts = time.time()
for idx, f in enumerate(files, start=1):
rel = f.relative_to(local_dir).as_posix()
fsize = f.stat().st_size
remote_rel = _join_remote_path(dest_rel, rel)
with upload_lock:
upload_state.update(done=idx, current_file=rel,
progress=int(idx / len(files) * 100) if files else 100)
if dup_mode == 'skip':
remote_size = _remote_size(_smb_conn(t, remote_rel))
if remote_size == fsize:
skipped += 1
with upload_lock:
bd = upload_state['bytes_done'] + fsize
elapsed = time.time() - start_ts
speed = bd / elapsed if elapsed > 1 else 0
eta = int((bytes_total - bd) / speed) if speed > 0 and bytes_total > bd else 0
upload_state.update(bytes_done=bd,
progress=int(bd / bytes_total * 100) if bytes_total else 100,
speed_bps=int(speed), eta_sec=eta)
continue
elif dup_mode == 'rename':
remote_rel = _remote_unique_rel_path(t, remote_rel)
with upload_lock:
base_done = upload_state['bytes_done']
rr = _rclone_copyto_progress(f, _smb_conn(t, remote_rel),
base_done, fsize, bytes_total, start_ts)
if rr.returncode != 0:
errors.append(rr.stderr.strip() or f'{rel}: unbekannter Fehler')
if len(errors) >= 5:
break
with upload_lock:
bd = base_done + fsize
elapsed = time.time() - start_ts
speed = bd / elapsed if elapsed > 1 else 0
eta = int((bytes_total - bd) / speed) if speed > 0 and bytes_total > bd else 0
upload_state.update(bytes_done=bd,
progress=int(bd / bytes_total * 100) if bytes_total else 100,
speed_bps=int(speed), eta_sec=eta)
r = subprocess.CompletedProcess(
args=['rclone', 'copyto'],
returncode=1 if errors else 0,
stdout='',
stderr='\n'.join(errors),
)
ok = r.returncode == 0
err = ''
if not ok:
err = r.stderr.strip() or 'Unbekannter Fehler'
add_log(f'Upload {name}: rclone stderr: {err[:300]}')
elif skipped:
add_log(f'Upload {name}: {skipped} Dateien übersprungen')
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'] = ''
upload_state['current_file'] = ''
# -- 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)
share = internal_share_update_state()
return jsonify(copy=cs, wifi=ws, vpn=wgs, internal_share=share)
@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 = _configured_destination(cfg, devs)
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/internal-share/status')
def r_internal_share_status():
return jsonify(internal_share_update_state())
@app.route('/api/internal-share', methods=['POST'])
def r_internal_share_set():
data = request.get_json(force=True) or {}
enabled = bool(data.get('enabled'))
ok, err = set_internal_share_enabled(enabled)
if not ok:
return jsonify(error=err), 500
return jsonify(ok=True, status=internal_share_update_state())
@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
# Credentials direkt im Entry speichern (für Connection-String bei Upload)
obscured_pw = _rclone_obscure(data.get('pass', '')) if data.get('pass') else ''
entry = {
'id': tid, 'type': ctype,
'name': data.get('name', ctype),
'dest_path': data.get('dest_path', 'PiCopy'),
'enabled': True,
'smb_host': data.get('host', ''),
'smb_share': data.get('share', ''),
'smb_user': data.get('user', ''),
'smb_pass': obscured_pw,
}
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):
if dev.get('internal'):
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
return str(INTERNAL_DEST_DIR)
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 = internal_dest_device(load_cfg()) if port == '__internal__' else None
if dev is None:
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}
.topbar-wifi~.topbar-wifi,.topbar-wifi~.lang-toggle{margin-left:0}
.lang-toggle{display:inline-flex;border:1px solid var(--brd);border-radius:9999px;overflow:hidden;background:var(--surf);flex-shrink:0}
.lang-toggle button{border:0;background:transparent;color:var(--sub);font-size:.74rem;font-weight:700;padding:.32rem .55rem;cursor:pointer}
.lang-toggle button.on{background:var(--acc);color:#fff}
.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}
.qr-box{display:none;margin-top:.75rem;padding:.65rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem}
.qr-row{display:flex;gap:.75rem;align-items:center}
.qr-row canvas{width:112px;height:112px;background:#fff;border-radius:.35rem;padding:.35rem;flex-shrink:0}
.qr-title{font-size:.83rem;font-weight:700}
.qr-url{font-family:ui-monospace,monospace;font-size:.76rem;color:var(--acc);margin-top:.2rem;word-break:break-all}
.qr-sub{font-size:.72rem;color:var(--sub);margin-top:.25rem;line-height:1.4}
@media(max-width:430px){.qr-row{align-items:flex-start}.qr-row canvas{width:96px;height:96px}}
/* -- 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" data-title-i18n="title.install">
&#8593; <span id="upd-version"></span> verfügbar
</div>
<div class="topbar-wifi">
<div class="wdot d" id="wdot"></div>
<span id="wifi-label" data-i18n="wifi.connecting">Verbinde...</span>
<span id="wifi-ip"></span>
</div>
<div class="lang-toggle" title="Language">
<button id="lang-de" onclick="setLang('de')">DE</button>
<button id="lang-en" onclick="setLang('en')">EN</button>
</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" data-title-i18n="title.dismiss" 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-prog" style="display:none;margin-top:.45rem">
<div class="prog-track"><div class="prog-fill" id="upload-fill" style="width:0%"></div></div>
<div class="meta-row">
<span class="pill acc" id="upload-pct"></span>
<span class="pill" id="upload-files"></span>
<span class="pill" id="upload-bytes"></span>
<span class="pill acc" id="upload-eta" style="display:none"></span>
<span class="pill" id="upload-speed" style="display:none"></span>
</div>
<div id="upload-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="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. Zielplatte oder Interner Speicher">
</div>
<div class="field">
<label>Zieltyp</label>
<select id="dst-type" onchange="onDestTypeChange()">
<option value="usb">USB-Laufwerk</option>
<option value="internal">Interner Speicher vom Raspberry Pi</option>
</select>
</div>
<div class="field" id="dst-usb-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" id="dst-hint">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 id="internal-share-box" style="display:none;margin-top:.75rem;padding:.65rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem">
<div style="display:flex;align-items:center;gap:.55rem;justify-content:space-between">
<div style="min-width:0">
<div style="font-weight:700;font-size:.83rem">SMB-Freigabe</div>
<div id="internal-share-detail" style="font-size:.72rem;color:var(--sub);margin-top:.15rem"></div>
</div>
<button class="btn sm" id="internal-share-btn" onclick="toggleInternalShare()">Freigeben</button>
</div>
<div id="internal-share-flash" class="flash" style="margin-top:.35rem"></div>
</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" data-title-i18n="title.reload">&#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" data-tab="tc" onclick="swTab('tc','ta')" data-i18n="Heimnetz">Heimnetz</div>
<div class="tab" data-tab="ta" onclick="swTab('ta','tc')" data-i18n="Hotspot (AP)">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" data-title-i18n="title.scan_nets">🔍</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 id="hotspot-qr-box" class="qr-box">
<div class="qr-row">
<canvas id="hotspot-qr" width="116" height="116"></canvas>
<div>
<div class="qr-title" data-i18n="qr.title">Direkt öffnen</div>
<div class="qr-url" id="hotspot-qr-url">http://10.42.0.1:8080</div>
<div class="qr-sub" data-i18n="qr.sub">Im PiCopy-Hotspot mit dem Handy scannen und die Oberfläche öffnen.</div>
</div>
</div>
</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" data-i18n="wg.apt_running">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" data-title-i18n="title.wg_rm">✕&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();
};
const I18N = {
de: {
'wifi.connecting':'Verbinde...', 'wifi.none':'Kein WLAN', 'wifi.connected':'Verbunden',
'wifi.hotspot':'Hotspot: ', 'vpn.active':'VPN aktiv', 'vpn.disconnected':'Getrennt',
'copy.ready':'Bereit', 'copy.copying':'Kopiert... ', 'copy.verify':'Verifiziere... ',
'copy.delete':'Quelle wird geleert...', 'copy.done':'✓ Abgeschlossen', 'copy.files':' Dateien',
'copy.checked':' geprüft', 'qr.title':'Direkt öffnen',
'qr.sub':'Im PiCopy-Hotspot mit dem Handy scannen und die Oberfläche öffnen.',
'share.on':'Freigabe stoppen', 'share.off':'Freigeben', 'share.inactive':'Nicht freigegeben',
'share.install':'Installiere...', 'share.installing':'Samba wird installiert. ',
'flash.saving':'Speichere...', 'flash.testing':'Teste Verbindung Schreibzugriff wird geprüft...',
'flash.ok_test':'✓ Verbindung OK Lesen & Schreiben erfolgreich',
'flash.fail_timeout':'✗ Test fehlgeschlagen (Server-Timeout)',
'flash.no_source':'Keine Quelle ausgewählt bitte mindestens eine Quelle anhaken.',
'flash.no_device':'Bitte zuerst ein Gerät wählen.',
'flash.port_as_target':'Port bereits als Ziel konfiguriert!',
'flash.port_as_source':'Port bereits als Quelle konfiguriert!',
'flash.port_src_dup':'Port bereits als Quelle hinzugefügt!',
'flash.no_server':'Server-Adresse fehlt',
'flash.no_shares':'Verbunden, aber keine Freigaben gefunden',
'flash.no_name':'Name fehlt', 'flash.no_share':'Bitte eine Freigabe wählen',
'flash.wifi_connecting':'Verbinde... (bis 30s)',
'flash.wifi_started':'Gestartet. Neue IP erscheint oben.',
'flash.ap_saved':'Gespeichert! Hotspot startet neu.',
'flash.connect_btn':'Verbinde...',
'flash.src_added':'Quelle Port ${p} hinzugefügt.',
'flash.dst_saved':'Port ${p} als Ziel gespeichert.',
'flash.int_saved':'Interner Speicher als Ziel gespeichert.',
'flash.no_ssid':'Bitte SSID eingeben',
'flash.pw_short':'Min. 8 Zeichen',
'btn.testing':'Teste...', 'btn.checking':'Prüfe...',
'src.none':'Noch keine Quelle konfiguriert.',
'src.copy_cb':'Kopieren',
'ut.none':'Noch keine Fernziele konfiguriert',
'expl.no_port':'Kein Port konfiguriert', 'expl.no_dev':'Gerät nicht verbunden',
'expl.loading':'Lade...', 'expl.empty_drive':'Laufwerk leer', 'expl.empty_folder':'Ordner leer',
'expl.conn_err':'Verbindungsfehler',
'confirm.del_target':'"${n}" wirklich löschen?',
'confirm.wg_install':'wireguard + wireguard-tools + openresolv jetzt per apt-get installieren?\n\nDauer: ca. 3090 Sekunden.',
'confirm.wg_uninstall':'WireGuard wirklich deinstallieren?\n\nDer aktive VPN-Tunnel wird vorher getrennt.\nDie Konfigurationsdatei bleibt erhalten.',
'confirm.reboot':'Gerät jetzt neu starten?\n\nDas Web-Interface ist für ca. 30 Sekunden nicht erreichbar.',
'confirm.update':'Update auf v${v} installieren?\n\nDas Web-Interface ist für ca. 10 Sekunden nicht erreichbar.',
'reboot.wait':'&#8634; Gerät startet neu bitte warten...',
'wg.installing':'Starte Installation...', 'wg.removing':'Deinstalliere...',
'wg.connecting':'Verbinde VPN...', 'wg.disconnecting':'Trenne VPN...',
'wg.disc_failed':'Trennen fehlgeschlagen', 'wg.cfg_empty':'Konfiguration ist leer',
'wg.saving':'Speichere...', 'wg.saved':'✓ Konfiguration gespeichert',
'wg.invalid_cfg':'[Interface] fehlt',
'wg.action_install':'Installiere', 'wg.action_remove':'Deinstalliere',
'copy.cfg_saved':'Gespeichert!',
'copy.error_prefix':'Fehler: ',
'status.active':'Aktiv', 'status.inactive':'Inaktiv',
'status.configured':'Konfiguriert', 'status.not_cfg':'Nicht konfiguriert',
'size.free':' frei',
'confirm.samba':'Samba installieren und /opt/picopy/internal als Netzwerkfreigabe PiCopy bereitstellen?\n\nDie Freigabe ist im Netzwerk lesbar erreichbar.',
'share.activating':'Aktiviere Freigabe...', 'share.deactivating':'Deaktiviere Freigabe...',
'share.active_ok':'✓ Freigabe aktiv', 'share.deactive_ok':'✓ Freigabe deaktiviert',
'update.available':'Update v${v} verfügbar über das Badge oben installieren.',
'update.error_prefix':'Fehler: ', 'update.current':'PiCopy ist aktuell.',
'update.installing':'&#8595; Installiere...',
'btn.connect_load':'&#128279;&nbsp;Verbinden &amp; Freigaben laden',
'ut.toggle_close':'✕ Abbrechen', 'ut.toggle_open':'+ NAS-Ziel hinzufügen',
'check_update':'Nach Update suchen',
'title.install':'Klicken zum Installieren', 'title.dismiss':'Meldung schließen',
'title.reload':'Neu laden', 'title.scan_nets':'Netzwerke suchen',
'title.wg_rm':'wireguard-Paket entfernen',
'wg.apt_running':'apt-get läuft bitte warten (bis 60 s)',
'wg.not_installed':'Nicht installiert',
'Heimnetz':'Heimnetz',
},
en: {
'wifi.connecting':'Connecting...', 'wifi.none':'No Wi-Fi', 'wifi.connected':'Connected',
'wifi.hotspot':'Hotspot: ', 'vpn.active':'VPN active', 'vpn.disconnected':'Disconnected',
'copy.ready':'Ready', 'copy.copying':'Copying... ', 'copy.verify':'Verifying... ',
'copy.delete':'Clearing source...', 'copy.done':'✓ Complete', 'copy.files':' files',
'copy.checked':' checked', 'qr.title':'Open directly',
'qr.sub':'Scan while connected to the PiCopy hotspot to open the interface.',
'share.on':'Stop share', 'share.off':'Share', 'share.inactive':'Not shared',
'share.install':'Installing...', 'share.installing':'Installing Samba. ',
'flash.saving':'Saving...', 'flash.testing':'Testing connection checking write access...',
'flash.ok_test':'✓ Connection OK Read & Write successful',
'flash.fail_timeout':'✗ Test failed (server timeout)',
'flash.no_source':'No source selected please check at least one source.',
'flash.no_device':'Please select a device first.',
'flash.port_as_target':'Port already configured as target!',
'flash.port_as_source':'Port already configured as source!',
'flash.port_src_dup':'Port already added as source!',
'flash.no_server':'Server address missing',
'flash.no_shares':'Connected, but no shares found',
'flash.no_name':'Name missing', 'flash.no_share':'Please select a share',
'flash.wifi_connecting':'Connecting... (up to 30s)',
'flash.wifi_started':'Started. New IP will appear above.',
'flash.ap_saved':'Saved! Hotspot restarting.',
'flash.connect_btn':'Connecting...',
'flash.src_added':'Source port ${p} added.',
'flash.dst_saved':'Port ${p} saved as target.',
'flash.int_saved':'Internal storage saved as target.',
'flash.no_ssid':'Please enter SSID',
'flash.pw_short':'Min. 8 characters',
'btn.testing':'Testing...', 'btn.checking':'Checking...',
'src.none':'No sources configured yet.',
'src.copy_cb':'Copy',
'ut.none':'No remote targets configured',
'expl.no_port':'No port configured', 'expl.no_dev':'Device not connected',
'expl.loading':'Loading...', 'expl.empty_drive':'Drive empty', 'expl.empty_folder':'Folder empty',
'expl.conn_err':'Connection error',
'confirm.del_target':'Really delete "${n}"?',
'confirm.wg_install':'Install wireguard + wireguard-tools + openresolv via apt-get?\n\nDuration: approx. 3090 seconds.',
'confirm.wg_uninstall':'Really uninstall WireGuard?\n\nThe active VPN tunnel will be disconnected first.\nThe configuration file will be kept.',
'confirm.reboot':'Restart device now?\n\nThe web interface will be unavailable for approx. 30 seconds.',
'confirm.update':'Install update v${v}?\n\nThe web interface will be unavailable for approx. 10 seconds.',
'reboot.wait':'&#8634; Device restarting please wait...',
'wg.installing':'Starting installation...', 'wg.removing':'Uninstalling...',
'wg.connecting':'Connecting VPN...', 'wg.disconnecting':'Disconnecting VPN...',
'wg.disc_failed':'Disconnect failed', 'wg.cfg_empty':'Configuration is empty',
'wg.saving':'Saving...', 'wg.saved':'✓ Configuration saved',
'wg.invalid_cfg':'[Interface] missing',
'wg.action_install':'Installing', 'wg.action_remove':'Uninstalling',
'copy.cfg_saved':'Saved!',
'copy.error_prefix':'Error: ',
'status.active':'Active', 'status.inactive':'Inactive',
'status.configured':'Configured', 'status.not_cfg':'Not configured',
'size.free':' free',
'confirm.samba':'Install Samba and share /opt/picopy/internal as network share PiCopy?\n\nThe share will be accessible read-only on the network.',
'share.activating':'Activating share...', 'share.deactivating':'Deactivating share...',
'share.active_ok':'✓ Share active', 'share.deactive_ok':'✓ Share deactivated',
'update.available':'Update v${v} available install via the badge above.',
'update.error_prefix':'Error: ', 'update.current':'PiCopy is up to date.',
'update.installing':'&#8595; Installing...',
'btn.connect_load':'&#128279;&nbsp;Connect &amp; Load Shares',
'ut.toggle_close':'✕ Cancel', 'ut.toggle_open':'+ Add NAS Target',
'check_update':'Check For Update',
'title.install':'Click to install', 'title.dismiss':'Close message',
'title.reload':'Reload', 'title.scan_nets':'Scan networks',
'title.wg_rm':'Remove WireGuard package',
'wg.apt_running':'apt-get running please wait (up to 60 s)',
'wg.not_installed':'Not installed',
'Heimnetz':'Home Network',
}
};
const STATIC_EN = {
// Card titles
'Kopierstatus':'Copy Status', 'USB Ports & Datei-Explorer':'USB Ports & File Explorer',
'Kopier-Einstellungen':'Copy Settings', 'Fernkopie - NAS / SMB':'Remote Copy - NAS / SMB',
'WiFi-Einstellungen':'Wi-Fi Settings', 'WireGuard VPN':'WireGuard VPN',
'System':'System', 'Logs':'Logs',
// Section headers
'Ordnerstruktur':'Folder Structure', 'Dateifilter':'File Filter',
'Duplikate':'Duplicates', 'Integrität & Aufräumen':'Integrity & Cleanup',
'Heimnetz':'Home Network', 'Hotspot (AP)':'Hotspot (AP)',
'Konfiguration':'Configuration', 'Fernkopie':'Remote Copy',
'Weitere verbundene Geräte':'Other Connected Devices',
'Schritt 1 Server-Verbindung':'Step 1 Server Connection',
'Schritt 2 Freigabe & Details':'Step 2 Share & Details',
// Labels
'Bezeichnung':'Label', 'Zieltyp':'Target Type', 'Datumsformat':'Date Format',
'Uhrzeit im Ordnernamen':'Add Time To Folder Name',
'Unterordner pro Quelle':'Subfolder Per Source', 'Automatisch kopieren':'Auto Copy',
'Nur diese Typen kopieren (leer = alle)':'Only These Types (empty = all)',
'Systemdateien ausschließen':'Exclude System Files',
'Wenn Zieldatei bereits existiert':'When Target File Exists',
'Dateien nach Kopieren per MD5 verifizieren':'Verify Files With MD5 After Copy',
'Quelldateien nach Kopieren löschen':'Delete Source Files After Copy',
'Netzwerk (SSID)':'Network (SSID)', 'Passwort':'Password',
'Hotspot-Name (SSID)':'Hotspot Name (SSID)', 'Passwort (min. 8 Zeichen)':'Password (min. 8 chars)',
'Port lernen - Gerät wählen':'Learn Port - Select Device',
'Beim Start automatisch verbinden':'Connect Automatically On Startup',
'WireGuard Konfiguration (.conf)':'WireGuard Configuration (.conf)',
'Server (IP / Hostname)':'Server (IP / Hostname)', 'Benutzer':'User',
'Freigabe wählen':'Select Share', 'Name (Anzeigename)':'Name (display name)',
'Ziel-Ordner auf dem NAS':'Destination Folder on NAS',
'Als festes Ziel speichern':'Save As Fixed Target', 'SMB-Freigabe':'SMB Share',
'Interner Speicher vom Raspberry Pi':'Raspberry Pi Internal Storage',
// Buttons
'Kopieren starten':'Start Copy', 'Abbrechen':'Cancel', 'Speichern':'Save',
'Speichern & Neustart':'Save & Restart', 'Verbinden & Speichern':'Connect & Save',
'NAS-Ziel hinzufügen':'Add NAS Target', 'Quelle hinzufügen':'Add Source',
'Nach Update suchen':'Check For Update', 'Gerät neu starten':'Restart Device',
'Deinstallieren':'Uninstall', 'Installieren':'Install',
'Freigeben':'Share', 'Freigabe stoppen':'Stop Share',
'Alle':'All', '✕ Alle':'✕ All', '📷 Fotos':'📷 Photos', '🎬 Videos':'🎬 Videos',
'Konfiguration speichern':'Save Configuration',
// Options
'- Gerät einstecken, dann hier wählen -':'- Insert device, then select here -',
'USB-Laufwerk':'USB Drive', 'Interner Speicher':'Internal Storage',
'Überspringen (empfohlen)':'Skip (recommended)', 'Überschreiben':'Overwrite',
'Umbenennen (_1, _2 ...)':'Rename (_1, _2 ...)',
'JJJJ-MM-TT (2024-01-15)':'YYYY-MM-DD (2024-01-15)',
'JJJJ/MM/TT (Unterordner)':'YYYY/MM/DD (Subfolder)',
'JJJJMMTT (20240115)':'YYYYMMDD (20240115)',
'TT-MM-JJJJ (15-01-2024)':'DD-MM-YYYY (15-01-2024)',
// States / info
'Kein Port konfiguriert':'No port configured', 'Gerät nicht verbunden':'Device not connected',
'Noch keine Einträge':'No entries yet',
'Port konfigurieren und Gerät verbinden':'Configure port and connect device',
'Nicht freigegeben':'Not shared',
// Role tags & misc
'▼ Ziel':'▼ Target', '⬇ Ziel':'▼ Target', 'Ziel':'Target',
'Fotos':'Photos', 'Videos':'Videos',
};
function t(k){return (I18N[cfg.ui_lang||'de']&&I18N[cfg.ui_lang||'de'][k])||I18N.de[k]||k;}
function applyLang(){
const lang=cfg.ui_lang||'de';
document.documentElement.lang=lang;
$('lang-de').classList.toggle('on',lang==='de');
$('lang-en').classList.toggle('on',lang==='en');
document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n);});
document.querySelectorAll('button,label,span,.card-title,.sec,.hint-box,.expl-empty,option').forEach(el=>{
if(el.children.length>0) return;
if(!el.dataset.deText) el.dataset.deText=el.textContent.trim();
const de=el.dataset.deText;
el.textContent=lang==='en'?(STATIC_EN[de]||de):de;
});
const ph = {
'src-label':['z.B. Kamera 1 / linker Port','e.g. Camera 1 / left port'],
'dst-label':['z.B. Zielplatte oder Interner Speicher','e.g. target drive or internal storage'],
'w-ssid':['WLAN-Name','Wi-Fi name'],
'w-pw':['WLAN-Passwort','Wi-Fi password'],
'ut-host':['192.168.1.100 oder nas.local','192.168.1.100 or nas.local'],
'ut-user':['(leer = anonym)','(empty = anonymous)'],
'ut-pass':['(leer = kein Passwort)','(empty = no password)'],
'ut-name':['z.B. Heimserver NAS','e.g. Home server NAS'],
'ut-dest':['PiCopy','PiCopy'],
'c-fmt':['%Y-%m-%d','%Y-%m-%d'],
'c-filter':['jpg, raw, mp4, mov ...','jpg, raw, mp4, mov ...'],
'ap-ssid':['PiCopy','PiCopy'],
'ap-pw':['PiCopy,','PiCopy,'],
};
Object.entries(ph).forEach(([id,v])=>{const el=$(id); if(el) el.placeholder=lang==='en'?v[1]:v[0];});
document.querySelectorAll('[data-title-i18n]').forEach(el=>{el.title=t(el.dataset.titleI18n);});
}
async function setLang(lang){
cfg.ui_lang=lang;
await api('/config','POST',cfg);
applyLang();
poll();
}
function drawHotspotQR(){
const url='http://10.42.0.1:8080';
const c=$('hotspot-qr'); if(!c)return;
$('hotspot-qr-url').textContent=url;
drawQR(c,url);
}
function drawQR(canvas,text){
const version=2,size=25,dataCw=34,ecCw=10;
const bytes=[...new TextEncoder().encode(text)];
const bits=[];
const put=(val,len)=>{for(let i=len-1;i>=0;i--)bits.push((val>>>i)&1);};
put(4,4); put(bytes.length,8); bytes.forEach(b=>put(b,8)); put(0,Math.min(4,dataCw*8-bits.length));
while(bits.length%8)bits.push(0);
const data=[];
for(let i=0;i<bits.length;i+=8)data.push(bits.slice(i,i+8).reduce((a,b)=>a*2+b,0));
for(let p=0xec;data.length<dataCw;p=p===0xec?0x11:0xec)data.push(p);
const gfMul=(x,y)=>{let z=0;for(let i=7;i>=0;i--){z=(z<<1)^((z>>>7)*0x11d);if((y>>>i)&1)z^=x;}return z&255;};
const gen=Array(ecCw).fill(0); gen[ecCw-1]=1;
for(let i=0,root=1;i<ecCw;i++,root=gfMul(root,2)){
for(let j=0;j<ecCw;j++){
gen[j]=gfMul(gen[j],root);
if(j+1<ecCw)gen[j]^=gen[j+1];
}
}
const rem=Array(ecCw).fill(0);
data.forEach(d=>{
const factor=d^rem.shift(); rem.push(0);
for(let i=0;i<ecCw;i++)rem[i]^=gfMul(gen[i],factor);
});
const code=data.concat(rem);
const m=Array.from({length:size},()=>Array(size).fill(null));
const set=(x,y,v)=>{if(x>=0&&y>=0&&x<size&&y<size)m[y][x]=v;};
const finder=(x,y)=>{
for(let dy=-1;dy<=7;dy++)for(let dx=-1;dx<=7;dx++){
const xx=x+dx,yy=y+dy;
const on=(dx>=0&&dx<=6&&dy>=0&&dy<=6&&(dx===0||dx===6||dy===0||dy===6||(dx>=2&&dx<=4&&dy>=2&&dy<=4)));
set(xx,yy,on?1:0);
}
};
finder(0,0); finder(size-7,0); finder(0,size-7);
for(let i=8;i<size-8;i++){set(i,6,i%2?0:1);set(6,i,i%2?0:1);}
for(let y=16;y<=20;y++)for(let x=16;x<=20;x++)set(x,y,(x===16||x===20||y===16||y===20||(x===18&&y===18))?1:0);
set(8,size-8,1);
for(let i=0;i<9;i++){if(m[8][i]===null)set(8,i,0);if(m[i][8]===null)set(i,8,0);}
for(let i=0;i<8;i++){if(m[8][size-1-i]===null)set(8,size-1-i,0);if(m[size-1-i][8]===null)set(size-1-i,8,0);}
const dataBits=code.flatMap(b=>Array.from({length:8},(_,i)=>(b>>>(7-i))&1));
let bi=0,up=true;
for(let x=size-1;x>0;x-=2){
if(x===6)x--;
for(let yi=0;yi<size;yi++){
const y=up?size-1-yi:yi;
for(let dx=0;dx<2;dx++){
const xx=x-dx;
if(m[y][xx]!==null)continue;
const mask=(x+y)%2===0;
m[y][xx]=(dataBits[bi++]||0)^(mask?1:0);
}
}
up=!up;
}
const fmt=qrFormatBits(1,0);
for(let i=0;i<=5;i++)set(8,i,(fmt>>i)&1); set(8,7,(fmt>>6)&1); set(8,8,(fmt>>7)&1); set(7,8,(fmt>>8)&1);
for(let i=9;i<15;i++)set(14-i,8,(fmt>>i)&1);
for(let i=0;i<8;i++)set(size-1-i,8,(fmt>>i)&1);
for(let i=8;i<15;i++)set(8,size-15+i,(fmt>>i)&1);
const ctx=canvas.getContext('2d'),scale=Math.floor(canvas.width/(size+8)),off=Math.floor((canvas.width-scale*size)/2);
ctx.fillStyle='#fff';ctx.fillRect(0,0,canvas.width,canvas.height);
ctx.fillStyle='#0a0f1e';
for(let y=0;y<size;y++)for(let x=0;x<size;x++)if(m[y][x])ctx.fillRect(off+x*scale,off+y*scale,scale,scale);
}
function qrFormatBits(ecl,mask){
let data=(ecl<<3)|mask, rem=data;
for(let i=0;i<10;i++)rem=(rem<<1)^(((rem>>>9)&1)?0x537:0);
return ((data<<10)|rem)^0x5412;
}
// -- Tabs ---------------------------------------------------------------------
function swTab(show,hide){
$(show).classList.add('on'); $(hide).classList.remove('on');
document.querySelectorAll('.tab').forEach(tab=>
tab.classList.toggle('on', tab.dataset.tab===show)
);
}
// -- Port Slots ----------------------------------------------------------------
async function refreshDevices(){
devs = await api('/devices');
renderSources();
renderSlot('dst', cfg.dest_port, cfg.dest_label);
renderUnassigned();
populateSel();
applyLang();
}
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)">
${t('src.copy_cb')}
</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">'+t('src.none')+'</div>'
: '');
renderExplorerTabs();
}
function toggleSrc(port, on){
if(on) selectedPortSet.add(port); else selectedPortSet.delete(port);
}
function renderExplorerTabs(){
const ports = cfg.source_ports || [];
let tabs = 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('');
if((cfg.dest_type||'usb')==='internal'){
tabs += `<button class="etab ${expl.role==='dst'?'on':''}" id="etab-dst"
onclick="expl.switchRole('dst')">&#9660; Intern</button>`;
}
$('src-tabs').innerHTML = tabs;
// 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){
if(r==='dst' && (cfg.dest_type||'usb')==='internal'){
const dot=$('dst-dot'), pp=$('dst-port-path'), pi=$('dst-dev-info');
const sl=$('slot-dst'), lb=$('dst-label');
sl.classList.add('dst-on');
dot.className='dot on';
pp.textContent='Interner Speicher';
pi.textContent=(label||cfg.internal_dest_label||'Interner Speicher')+' | /opt/picopy/internal';
if(lb && !lb.dataset.dirty) lb.value=label||cfg.internal_dest_label||'Interner Speicher';
return;
}
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=t('expl.no_dev'); }
} else {
dot.className='dot off'; pp.textContent='-'; pi.textContent=t('expl.no_port');
}
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) && ((cfg.dest_type||'usb')==='internal' || 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 onDestTypeChange(markDirty=true){
const type=$('dst-type').value;
$('dst-usb-field').style.display=type==='internal'?'none':'';
$('internal-share-box').style.display=type==='internal'?'block':'none';
$('dst-hint').textContent=type==='internal'
? 'Kopiert auf /opt/picopy/internal. Die Daten können optional als SMB-Freigabe im Netzwerk bereitgestellt werden.'
: 'Gerät in den gewünschten Port → aus Liste wählen → Speichern. Ab dann wird dieser Port immer als Ziel verwendet.';
if(type==='internal' && !$('dst-label').value) $('dst-label').value='Interner Speicher';
if(markDirty) $('dst-label').dataset.dirty='1';
updateInternalShareBox();
renderSlot('dst',cfg.dest_port,cfg.dest_label);
renderExplorerTabs();
}
function renderUnassigned(){
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
const list=devs.filter(d=>!srcSet.has(d.usb_port)&&(((cfg.dest_type||'usb')==='internal')||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',t('flash.no_device'));return;}
if((cfg.dest_type||'usb')!=='internal' && port===cfg.dest_port){flash('src-flash','err',t('flash.port_as_target'));return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash('src-flash','err',t('flash.port_src_dup'));return;}
cfg.source_ports = [...(cfg.source_ports||[]), {port, label}];
selectedPortSet.add(port);
await api('/config','POST',cfg);
$('src-label').value='';
flash('src-flash','ok',''+t('flash.src_added').replace('${p}',port));
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 type=$('dst-type').value;
const port=$(sid).value, label=$(lid).value.trim();
if(type==='internal'){
cfg.dest_type='internal';
cfg.internal_dest_label=label||'Interner Speicher';
cfg[lk]=cfg.internal_dest_label;
$(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok',''+t('flash.int_saved'));
renderSlot('dst',cfg.dest_port,cfg.dest_label);
renderExplorerTabs(); expl.reload();
return;
}
if(!port){flash(fid,'err',t('flash.no_device'));return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err',t('flash.port_as_source'));return;}
cfg.dest_type='usb';
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok',''+t('flash.dst_saved').replace('${p}',port));
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',t('flash.no_source'));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';
$('dst-type').value=cfg.dest_type||'usb';
onDestTypeChange(false);
applyLang();
}
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.textContent=t('copy.cfg_saved'); 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',t('flash.no_ssid'));return;}
flash('wifi-flash','ok',t('flash.wifi_connecting'));
const r=await api('/wifi/connect','POST',{ssid,password:pw});
if(r.error) flash('wifi-flash','err',r.error);
else flash('wifi-flash','ok',t('flash.wifi_started'));
}
async function saveAP(){
const s=$('ap-ssid').value.trim(),p=$('ap-pw').value;
if(!s){flash('ap-flash','err',t('flash.no_name'));return;}
if(p.length<8){flash('ap-flash','err',t('flash.pw_short'));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',t('flash.ap_saved'));
}
// -- Upload-Ziele --------------------------------------------------------------
let utTargets=[], _utConn={};
async function loadUTs(){utTargets=await api('/upload/targets');renderUTs();applyLang();}
function renderUTs(){
const el=$('ut-list');
if(!utTargets.length){el.innerHTML='<div class="empty">'+t('ut.none')+'</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.smb_share||'?')}${t.dest_path?'/'+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?t('status.active'):t('status.inactive')}</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?t('ut.toggle_close'):t('ut.toggle_open');
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',t('flash.no_server'));return;}
const btn=$('ut-connect-btn');
btn.disabled=true; btn.textContent=t('flash.connect_btn');
$('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=t('btn.connect_load');
if(r.error){flash('ut-form-flash','err',''+r.error);return;}
if(!r.shares||!r.shares.length){flash('ut-form-flash','warn',t('flash.no_shares'));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',t('flash.no_name'));return;}
if(!share){flash('ut-form-flash','err',t('flash.no_share'));return;}
const body={type:'smb',name,dest_path:dest,share,
host:_utConn.host, user:_utConn.user, pass:_utConn.pass};
flash('ut-form-flash','warn',t('flash.saving'));
const r=await api('/upload/targets','POST',body);
if(r.error){flash('ut-form-flash','err',r.error);return;}
flash('ut-form-flash','warn',t('flash.testing'));
try{
const tr=await api('/upload/targets/'+r.id+'/test','POST');
if(tr.ok){flash('ut-form-flash','ok',t('flash.ok_test'));utToggleForm();await loadUTs();}
else flash('ut-form-flash','err',''+(tr.error||t('flash.fail_timeout').slice(2)));
}catch(e){flash('ut-form-flash','err',t('flash.fail_timeout'));}
}
async function utTest(id){
const btn=$('ut-test-'+id), res=$('ut-test-result-'+id);
btn.disabled=true; btn.textContent=t('btn.testing');
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=t('flash.ok_test');
} else {
res.style.background='rgba(248,113,113,.1)'; res.style.color='var(--red)';
res.textContent='' + (r.error||t('flash.fail_timeout').slice(2));
}
}
async function utToggle(id){await api('/upload/targets/'+id+'/toggle','POST');await loadUTs();}
async function utDel(id,name){
if(!confirm(t('confirm.del_target').replace('${n}',name)))return;
await api('/upload/targets/'+id,'DELETE');await loadUTs();
}
async function updateInternalShareBox(state=null){
if(!$('internal-share-box'))return;
const s=state||await api('/internal-share/status');
const btn=$('internal-share-btn'), detail=$('internal-share-detail');
const free=s.free!=null?fmtBytes(s.free)+t('size.free'):'';
if(s.pkg_running){
btn.disabled=true; btn.textContent=t('share.install');
detail.textContent=t('share.installing')+free;
return;
}
btn.disabled=false;
btn.textContent=s.enabled?t('share.on'):t('share.off');
const status=s.enabled
? ((s.active?t('status.active'):t('status.configured'))+' | \\\\'+(location.hostname||'picopy')+'\\PiCopy')
: t('share.inactive');
detail.textContent=status+(free?' | '+free:'');
}
async function toggleInternalShare(){
const current=await api('/internal-share/status');
const enable=!current.enabled;
if(enable && !current.installed){
if(!confirm(t('confirm.samba')))return;
}
flash('internal-share-flash','ok',enable?t('share.activating'):t('share.deactivating'));
const r=await api('/internal-share','POST',{enabled:enable});
if(r.error){flash('internal-share-flash','err',r.error);return;}
flash('internal-share-flash','ok',enable?t('share.active_ok'):t('share.deactive_ok'));
updateInternalShareBox(r.status);
}
// -- 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_type||'usb')==='internal'?'__internal__':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">'+t('expl.no_port')+'</div>';bread.innerHTML='';return;}
const dev=port==='__internal__'
? {usb_port:'__internal__',label:t('Interner Speicher')||'Interner Speicher',device:'internal'}
: devs.find(d=>d.usb_port===port);
if(!dev){body.innerHTML='<div class="expl-empty">'+t('expl.no_dev')+'</div>';bread.innerHTML='<span style="color:var(--sub)">Port '+port+'</span>';return;}
body.innerHTML='<div class="expl-empty">'+t('expl.loading')+'</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">'+t('expl.conn_err')+'</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">'+t('expl.empty_drive')+'</div>';return;}
if(!entries.length){body.innerHTML=h+'<div class="expl-empty">'+t('expl.empty_folder')+'</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==null)return'';
if(b===0)return'0 B';
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,internal_share:is}=await api('/status');
if(is) updateInternalShareBox(is);
// 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=t(v.pkg_action==='remove'?'wg.action_remove':'wg.action_install');
$('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=t('wg.not_installed')||'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=t('vpn.active'); vi.textContent=v.ip||'';
wgd.className='wdot c'; wgl.textContent=t('wifi.connected');
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=t('vpn.disconnected');
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?t('status.configured'):t('status.not_cfg');
}
}
}
// WiFi
const wd=$('wdot'),wl=$('wifi-label'),wi=$('wifi-ip');
if(w.mode==='client'){wd.className='wdot c';wl.textContent=w.ssid||t('wifi.connected');wi.textContent=w.ip||'';}
else if(w.mode==='ap'){wd.className='wdot a';wl.textContent=t('wifi.hotspot')+(w.ssid||'PiCopy');wi.textContent='10.42.0.1';}
else{wd.className='wdot d';wl.textContent=t('wifi.none');wi.textContent='';}
const qrBox=$('hotspot-qr-box');
if(qrBox){qrBox.style.display=w.mode==='ap'?'block':'none'; if(w.mode==='ap')drawHotspotQR();}
// 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=t('copy.verify')+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+t('copy.checked');
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=t('copy.delete');
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=t('copy.copying')+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+t('copy.files');
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=t('copy.error_prefix')+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=t('copy.done');
pf.className='prog-fill done'; pw.style.display='block'; pf.style.width='100%';
sum.textContent=c.total+t('copy.files')+' | '+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=t('copy.ready');
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+'...':'';
const up=$('upload-prog'),uf=$('upload-fill');
const pct=Math.max(0,Math.min(100,u.progress||0));
if(u.running){
up.style.display='block'; uf.style.width=pct+'%';
$('upload-pct').textContent=pct+'%';
$('upload-files').textContent=(u.done||0)+' / '+(u.total||0)+' Dateien';
$('upload-bytes').textContent=fmtBytes(u.bytes_done||0)+' / '+fmtBytes(u.bytes_total||0);
const ue=fmtETA(u.eta_sec); $('upload-eta').style.display=ue?'':'none'; $('upload-eta').textContent=ue?''+ue:'';
const us=fmtSpd(u.speed_bps); $('upload-speed').style.display=us?'':'none'; $('upload-speed').textContent=us?''+us:'';
$('upload-file').textContent=u.current_file||'';
}else{
up.style.display='none';
}
$('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=t('copy.ready');
$('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(t('confirm.update').replace('${v}',latest))) return;
$('upd-badge').innerHTML = t('update.installing');
$('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;'+t('btn.checking');
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 = t('update.available').replace('${v}',u.latest);
} else if (u.error) {
fl.className = 'flash err'; fl.textContent = t('update.error_prefix') + u.error;
} else {
fl.className = 'flash ok'; fl.textContent = t('update.current');
}
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 = t('expl.conn_err'); fl.style.display = 'block';
} finally {
btn.disabled = false; btn.innerHTML = '🔍&nbsp;'+t('check_update');
}
}
async function rebootDevice() {
if (!confirm(t('confirm.reboot'))) 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">'+t('reboot.wait')+'</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(t('confirm.wg_install')))return;
flash('wg-flash','ok',t('wg.installing'));
const r=await api('/wireguard/install','POST');
if(r.error) flash('wg-flash','err',r.error);
}
async function wgUninstall(){
if(!confirm(t('confirm.wg_uninstall')))return;
flash('wg-flash','ok',t('wg.removing'));
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',t('wg.connecting'));
await api('/wireguard/connect','POST');
}
async function wgDisconnect(){
$('wg-btn-disconnect').disabled=true;
flash('wg-flash','ok',t('wg.disconnecting'));
const r=await api('/wireguard/disconnect','POST');
if(!r.ok) flash('wg-flash','err',t('wg.disc_failed'));
}
async function wgSaveConfig(){
const content=$('wg-config').value.trim();
if(!content){flash('wg-flash','err',t('wg.cfg_empty'));return;}
if(!content.includes('[Interface]')){flash('wg-flash','err',t('wg.invalid_cfg'));return;}
const auto=$('wg-auto').checked;
flash('wg-flash','ok',t('wg.saving'));
const r=await api('/wireguard/config','POST',{content,auto});
if(r.error){flash('wg-flash','err',r.error);return;}
flash('wg-flash','ok',t('wg.saved'));
}
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)