Files
PiCopy/app.py
Tobias Leuschner e96ce8a7d3 Release v1.0.2 – Stromausfall-Schutz
Atomare Schreibvorgänge (schützt vor Dateikorruption durch Stromausfall):
- _atomic_write(): schreibt erst .tmp, sync auf Disk, dann os.replace() (POSIX-atomar)
- save_cfg() / save_state() verwenden _atomic_write statt write_text()
- Update-Install schreibt app.py.tmp, fsync, dann atomares Umbenennen

Korruptionsschutz beim Laden:
- load_cfg() / load_state(): bei JSON-Fehler Warnung loggen, .corrupt-Backup anlegen,
  sicher mit Standardwerten weiterlaufen statt zu crashen

Schutz vor unvollständigen Kopien:
- Jede Datei wird als .picopy_tmp kopiert, erst nach Abschluss atomar umbenannt
- Duplikat-Skip prüft Dateigröße: stimmt sie nicht überein, war die Datei abgeschnitten
  und wird automatisch neu kopiert
- .picopy_incomplete Marker-Datei im Zielordner während des Kopiervorgangs
- Veraltete .picopy_tmp-Dateien werden beim Kopierstart bereinigt
- subprocess.run(['sync']) vor dem Unmounten der Laufwerke

Startup-Bereinigung:
- cleanup_stale_mounts() beim Start: hängende /mnt/picopy-Mounts aus vorherigen
  Abstürzen werden sauber per umount -l entfernt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 02:11:18 +02:00

2067 lines
86 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""PiCopy v2 - USB Copy Service mit WiFi-Fallback AP"""
import os
import re
import json
import shutil
import logging
import threading
import subprocess
import time
import uuid as _uuid_mod
import urllib.request as _urlreq
import urllib.error as _urlerr
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, request
app = Flask(__name__)
VERSION = '1.0.2'
RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main'
BASE_DIR = Path('/opt/picopy')
CONFIG_FILE = BASE_DIR / 'config.json'
STATE_FILE = BASE_DIR / 'state.json'
LOG_DIR = BASE_DIR / 'logs'
LOG_FILE = LOG_DIR / 'picopy.log'
LOG_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s %(levelname)s %(message)s',
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()]
)
log = logging.getLogger('picopy')
NM_AP_CON = 'PiCopy-AP'
NM_CLIENT_CON = 'PiCopy-WiFi'
WIFI_BOOT_WAIT = 25 # Sekunden warten beim Start bevor AP gestartet wird
DEFAULT_CONFIG = {
# USB
'source_port': None, 'source_label': '',
'dest_port': None, 'dest_label': '',
'folder_format': '%Y-%m-%d', 'add_time': True,
'subfolder': True, 'auto_copy': True,
'file_filter': '', 'exclude_system': True,
'duplicate_handling': 'skip',
'verify_checksum': False, 'delete_source': False,
# WiFi
'wifi_ssid': '', 'wifi_password': '',
'ap_ssid': 'PiCopy', 'ap_password': 'PiCopy,',
}
# ── 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()
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)
# ── USB Geräteerkennung ───────────────────────────────────────────────────────
def usb_port_of(dev_name):
"""Gibt den physischen USB-Port-Pfad zurück (z.B. '2-2').
Primär via udevadm, Fallback via sysfs."""
# Primär: udevadm (zuverlässiger)
try:
r = subprocess.run(
['udevadm', 'info', '-q', 'path', '-n', f'/dev/{dev_name}'],
capture_output=True, text=True, timeout=5
)
if r.returncode == 0:
port = None
for seg in r.stdout.strip().split('/'):
if re.fullmatch(r'\d+-[\d.]+', seg):
port = seg
if port:
return port
except Exception:
pass
# Fallback: sysfs readlink
try:
real = Path(f'/sys/block/{dev_name}').resolve()
port = None
for seg in str(real).split('/'):
if re.fullmatch(r'\d+[\-\d.]+', seg) and ':' not in seg:
port = seg
return port
except Exception:
return None
def usb_devices():
try:
out = subprocess.check_output(
['lsblk', '-J', '-o', 'NAME,TRAN,MOUNTPOINT,LABEL,SIZE,MODEL'],
timeout=10, text=True
)
data = json.loads(out)
except Exception as e:
log.error(f'lsblk: {e}')
return []
result = []
for bd in data.get('blockdevices', []):
if bd.get('tran') != 'usb':
continue
name = bd['name']
port = usb_port_of(name)
model = (bd.get('label') or bd.get('model') or name).strip()
for child in (bd.get('children') or []):
result.append({
'device': f'/dev/{child["name"]}',
'usb_port': port,
'mount': child.get('mountpoint') or '',
'label': (child.get('label') or model).strip(),
'size': child.get('size') or bd.get('size') or '',
})
if not bd.get('children'):
result.append({
'device': f'/dev/{name}',
'usb_port': port,
'mount': bd.get('mountpoint') or '',
'label': model,
'size': bd.get('size') or '',
})
return result
def ensure_mount(dev_info):
mp = dev_info.get('mount')
if mp:
return mp, False
dev = dev_info['device']
mp = f'/mnt/picopy{dev.replace("/","_")}'
os.makedirs(mp, exist_ok=True)
r = subprocess.run(['mount', dev, mp], capture_output=True)
if r.returncode:
log.error(f'mount failed: {r.stderr.decode()}')
return None, False
return mp, True
# ── Kopier-Logik ──────────────────────────────────────────────────────────────
def add_log(msg):
log.info(msg)
with copy_lock:
copy_state['logs'].append({'t': datetime.now().strftime('%H:%M:%S'), 'm': msg})
copy_state['logs'] = copy_state['logs'][-200:]
import hashlib as _hashlib
SYSTEM_EXCLUDES = {
'.DS_Store', 'Thumbs.db', 'thumbs.db', 'desktop.ini',
'.Spotlight-V100', '.Trashes', '.fseventsd', '.TemporaryItems',
'.VolumeIcon.icns', 'RECYCLER', '$RECYCLE.BIN',
'System Volume Information', '.DocumentRevisions-V100',
}
def _should_copy(f: Path, cfg: dict) -> bool:
if cfg.get('exclude_system'):
for part in f.parts:
if part in SYSTEM_EXCLUDES:
return False
if f.name.startswith('._'):
return False
filt = cfg.get('file_filter', '').strip()
if filt:
allowed = {e.strip().lower().lstrip('.') for e in filt.split(',') if e.strip()}
if f.suffix.lower().lstrip('.') not in allowed:
return False
return True
def _unique_path(p: Path) -> Path:
stem, suffix, parent = p.stem, p.suffix, p.parent
i = 1
while True:
candidate = parent / f'{stem}_({i}){suffix}'
if not candidate.exists():
return candidate
i += 1
def _file_md5(p: Path) -> str:
h = _hashlib.md5()
with open(p, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
return h.hexdigest()
def _atomic_write(path: Path, content: str) -> None:
"""Schreibt atomar: erst .tmp, dann os.replace() sicher bei Stromausfall."""
tmp = path.with_suffix(path.suffix + '.tmp')
try:
tmp.write_text(content, encoding='utf-8')
with open(tmp, 'rb') as fh:
os.fsync(fh.fileno()) # Daten wirklich auf Datenträger schreiben
os.replace(str(tmp), str(path)) # Atomares Umbenennen (POSIX-Garantie)
except Exception:
try: tmp.unlink(missing_ok=True)
except Exception: pass
raise
def cleanup_stale_mounts() -> None:
"""Bereinigt beim Start hängen gebliebene PiCopy-Mounts (z.B. nach Stromausfall)."""
try:
with open('/proc/mounts') as fh:
mps = [line.split()[1] for line in fh if '/mnt/picopy' in line]
for mp in mps:
log.info(f'Bereinige veralteten Mount: {mp}')
subprocess.run(['umount', '-l', mp], capture_output=True)
except Exception as e:
log.warning(f'Stale-Mount-Bereinigung fehlgeschlagen: {e}')
def _fmt_bytes(b):
if b < 1024: return f'{b} B'
if b < 1024**2: return f'{b/1024:.1f} KB'
if b < 1024**3: return f'{b/1024**2:.1f} MB'
return f'{b/1024**3:.2f} GB'
def do_copy(src_dev, dst_dev, cfg):
src_mp = dst_mp = None
src_owned = dst_owned = False
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()
add_log('Kopiervorgang gestartet')
src_mp, src_owned = ensure_mount(src_dev)
if not src_mp:
raise RuntimeError(f'Quelle nicht mountbar: {src_dev["device"]}')
add_log(f'Quelle: {src_mp} ({src_dev["label"]})')
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')
label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source'))
dst_dir = Path(dst_mp) / date_str
if cfg.get('subfolder'):
dst_dir = dst_dir / label
dst_dir.mkdir(parents=True, exist_ok=True)
add_log(f'Zielordner: {dst_dir}')
# Halbkopierte .picopy_tmp-Dateien aus vorherigen Unterbrechungen entfernen
for stale in dst_dir.rglob('*.picopy_tmp'):
log.info(f'Bereinige Temp-Datei: {stale}')
stale.unlink(missing_ok=True)
# Incomplete-Marker: existiert dieser nach Neustart, war die letzte Kopie unterbrochen
incomplete_marker = dst_dir / '.picopy_incomplete'
incomplete_marker.write_text(json.dumps({
'started': datetime.now().isoformat(),
'source': src_dev.get('label', ''),
}))
# ── Dateien sammeln & filtern ──────────────────────────────────────
src_path = Path(src_mp)
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 durch Filter ausgeschlossen')
total = len(files)
bytes_total = sum(f.stat().st_size for f in files)
with copy_lock:
copy_state['total'] = total
copy_state['bytes_total'] = bytes_total
add_log(f'{total} Dateien ({_fmt_bytes(bytes_total)})')
save_state()
dup_mode = cfg.get('duplicate_handling', 'skip')
copied_pairs = [] # [(src, dst)] erfolgreich kopiert
skipped = 0
# ── Phase 1: Kopieren ──────────────────────────────────────────────
for i, f in enumerate(files):
with copy_lock:
if not copy_state['running']:
add_log('Abgebrochen')
return
rel = f.relative_to(src_path)
dst_f = dst_dir / rel
dst_f.parent.mkdir(parents=True, exist_ok=True)
if dst_f.exists():
if dup_mode == 'skip':
# Größenvergleich: stimmt die Größe nicht überein, war die Datei
# beim letzten Kopieren möglicherweise durch Stromausfall abgeschnitten
if dst_f.stat().st_size == f.stat().st_size:
skipped += 1
with copy_lock:
copy_state.update(done=i+1,
progress=int((i+1)/total*100) if total else 100,
current=str(f.name))
continue
else:
add_log(f'Unvollständige Datei gefunden, wird neu kopiert: {f.name}')
elif dup_mode == 'rename':
dst_f = _unique_path(dst_f)
# overwrite: einfach weitermachen
fsize = f.stat().st_size
tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp')
try:
shutil.copy2(f, tmp_f) # Erst in Temp-Datei kopieren
os.replace(str(tmp_f), str(dst_f)) # Dann atomar umbenennen
except Exception:
try: tmp_f.unlink(missing_ok=True)
except Exception: pass
raise
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=i+1,
progress=int((i+1)/total*100) if total else 100,
current=str(f.name), speed_bps=int(speed), eta_sec=eta)
if (i+1) % 20 == 0:
save_state()
msg_parts = [f'{len(copied_pairs)} kopiert']
if skipped:
msg_parts.append(f'{skipped} übersprungen')
# ── Phase 2: Verifizieren ──────────────────────────────────────────
verify_errors = 0
verified_pairs = list(copied_pairs)
if cfg.get('verify_checksum') and copied_pairs:
with copy_lock:
copy_state.update(phase='verify', progress=0, done=0,
total=len(copied_pairs), current='',
eta_sec=None, speed_bps=0)
add_log(f'Verifiziere {len(copied_pairs)} Dateien…')
verified_pairs = []
for i, (src_f, dst_f) in enumerate(copied_pairs):
with copy_lock:
if not copy_state['running']:
add_log('Abgebrochen')
return
copy_state.update(done=i+1,
progress=int((i+1)/len(copied_pairs)*100),
current=src_f.name)
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 ✓')
# Alle Daten auf den Datenträger schreiben bevor wir abmelden
subprocess.run(['sync'], capture_output=True)
try: incomplete_marker.unlink(missing_ok=True)
except Exception: pass
with copy_lock:
copy_state['last_copy'] = datetime.now().isoformat()
add_log('Fertig! ' + ', '.join(msg_parts))
threading.Thread(target=run_uploads, args=(dst_dir, cfg), daemon=True).start()
except Exception as e:
log.exception('Copy failed')
with copy_lock:
copy_state['error'] = str(e)
add_log(f'Fehler: {e}')
finally:
subprocess.run(['sync'], capture_output=True) # Sicherheits-Sync vor Unmount
if src_owned and src_mp:
subprocess.run(['umount', src_mp], 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()
if not cfg.get('auto_copy') or not cfg.get('source_port') or not cfg.get('dest_port'):
return
with copy_lock:
if copy_state['running']:
return
devs = usb_devices()
src = next((d for d in devs if d['usb_port'] == cfg['source_port']), None)
dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None)
if src and dst:
log.info('Auto-Copy: beide Geräte verbunden')
threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True).start()
def usb_monitor():
try:
import pyudev
ctx = pyudev.Context()
mon = pyudev.Monitor.from_netlink(ctx)
mon.filter_by(subsystem='block', device_type='disk')
for dev in iter(mon.poll, None):
if dev.action == 'add':
log.info(f'USB eingesteckt: {dev.device_node}')
threading.Timer(3.0, check_auto_copy).start()
except ImportError:
log.warning('pyudev nicht verfügbar')
# ── Upload-Ziele (rclone) ─────────────────────────────────────────────────────
RCLONE_CONF = BASE_DIR / 'rclone.conf'
upload_state = {
'running': False,
'current': '',
'results': [],
}
upload_lock = threading.Lock()
def _rclone(*args, timeout=60):
return subprocess.run(
['rclone', '--config', str(RCLONE_CONF)] + list(args),
capture_output=True, text=True, timeout=timeout
)
def _rclone_obscure(pw):
r = subprocess.run(['rclone', 'obscure', pw],
capture_output=True, text=True, timeout=10)
return r.stdout.strip()
def _remote_name(tid):
return f'picopy_{tid}'
def configure_smb_remote(tid, host, share, user, pw):
rn = _remote_name(tid)
_rclone('config', 'delete', rn)
args = ['config', 'create', rn, 'smb', f'host={host}', f'share={share}']
if user:
args += [f'user={user}']
if pw:
args += [f'pass={_rclone_obscure(pw)}']
r = _rclone(*args)
return r.returncode == 0, r.stderr.strip()
def delete_remote(tid):
_rclone('config', 'delete', _remote_name(tid))
def test_remote(tid):
r = _rclone('lsd', f'{_remote_name(tid)}:', timeout=20)
return r.returncode == 0, r.stderr.strip()
def run_uploads(local_dir: Path, cfg: dict):
"""Lädt local_dir zu allen aktiven Fernzielen hoch. Läuft im Background-Thread."""
targets = [t for t in cfg.get('upload_targets', []) if t.get('enabled')]
if not targets:
return
with upload_lock:
upload_state.update(running=True, results=[], current='')
for t in targets:
name = t.get('name', t['id'])
with upload_lock:
upload_state['current'] = name
add_log(f'Upload → {name}...')
dest_root = t.get('dest_path', 'PiCopy').strip('/')
dest = f'{_remote_name(t["id"])}:{dest_root}'
r = _rclone('copy', str(local_dir), dest,
'--create-empty-src-dirs',
'--retries', '3',
timeout=7200)
ok = r.returncode == 0
err = (r.stderr.strip().splitlines()[-1]
if r.stderr.strip() else '') if not ok else ''
with upload_lock:
upload_state['results'].append({'name': name, 'ok': ok, 'msg': err})
add_log(f'Upload {name}: {"✓ OK" if ok else "✗ Fehler " + err}')
with upload_lock:
upload_state['running'] = False
upload_state['current'] = ''
# ── Flask Routes ──────────────────────────────────────────────────────────────
@app.route('/')
def index():
return HTML
@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)
return jsonify(copy=cs, wifi=ws)
@app.route('/api/copy/start', methods=['POST'])
def r_start():
with copy_lock:
if copy_state['running']:
return jsonify(error='Bereits aktiv'), 400
cfg = load_cfg()
devs = usb_devices()
src = next((d for d in devs if d['usb_port'] == cfg.get('source_port')), None)
dst = next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None)
if not src: return jsonify(error='Quellgerät nicht gefunden'), 400
if not dst: return jsonify(error='Zielgerät nicht gefunden'), 400
threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True).start()
return jsonify(ok=True)
@app.route('/api/copy/cancel', methods=['POST'])
def r_cancel():
with copy_lock:
copy_state['running'] = False
return jsonify(ok=True)
@app.route('/api/wifi/scan')
def r_wifi_scan():
nets = scan_wifi_networks()
return jsonify(nets)
@app.route('/api/wifi/connect', methods=['POST'])
def r_wifi_connect():
data = request.get_json(force=True)
ssid = data.get('ssid', '').strip()
pw = data.get('password', '').strip()
if not ssid:
return jsonify(error='SSID fehlt'), 400
cfg = load_cfg()
cfg['wifi_ssid'] = ssid
cfg['wifi_password'] = pw
save_cfg(cfg)
def _connect():
ap_was_active = is_ap_active()
if ap_was_active:
stop_ap()
time.sleep(2)
ok = connect_client_wifi(ssid, pw)
if ok:
time.sleep(5)
update_wifi_state()
else:
if ap_was_active:
start_ap(cfg.get('ap_ssid', 'PiCopy'), cfg.get('ap_password', 'PiCopy,'))
update_wifi_state()
threading.Thread(target=_connect, daemon=True).start()
return jsonify(ok=True, msg='Verbindungsversuch gestartet')
@app.route('/api/wifi/ap', methods=['POST'])
def r_wifi_ap():
data = request.get_json(force=True)
ssid = data.get('ssid', '').strip()
pw = data.get('password', '').strip()
if not ssid or len(pw) < 8:
return jsonify(error='SSID fehlt oder Passwort zu kurz (min. 8 Zeichen)'), 400
cfg = load_cfg()
cfg['ap_ssid'] = ssid
cfg['ap_password'] = pw
save_cfg(cfg)
def _restart_ap():
if is_ap_active():
stop_ap()
time.sleep(2)
start_ap(ssid, pw)
time.sleep(3)
with wifi_lock:
wifi_state.update(mode='ap', ssid=ssid, ip='10.42.0.1')
threading.Thread(target=_restart_ap, daemon=True).start()
return jsonify(ok=True)
@app.route('/api/wifi/status')
def r_wifi_status():
with wifi_lock:
return jsonify(dict(wifi_state))
# ── Upload Routes ──────────────────────────────────────────────────────────────
@app.route('/api/upload/targets', methods=['GET'])
def r_upload_list():
return jsonify(load_cfg().get('upload_targets', []))
@app.route('/api/upload/targets', methods=['POST'])
def r_upload_add():
data = request.get_json(force=True)
cfg = load_cfg()
tid = data.get('id') or _uuid_mod.uuid4().hex[:8]
ctype = data.get('type', 'smb')
if ctype != 'smb':
return jsonify(error='Nur SMB/NAS wird unterstützt'), 400
ok, err = configure_smb_remote(
tid, data.get('host', ''), data.get('share', ''),
data.get('user', ''), data.get('pass', ''))
if not ok:
return jsonify(error=f'rclone: {err}'), 500
entry = {
'id': tid, 'type': ctype,
'name': data.get('name', ctype),
'dest_path': data.get('dest_path', 'PiCopy'),
'enabled': True,
}
targets = [t for t in cfg.get('upload_targets', []) if t['id'] != tid]
targets.append(entry)
cfg['upload_targets'] = targets
save_cfg(cfg)
return jsonify(ok=True, id=tid)
@app.route('/api/upload/targets/<tid>', methods=['DELETE'])
def r_upload_del(tid):
cfg = load_cfg()
cfg['upload_targets'] = [t for t in cfg.get('upload_targets', []) if t['id'] != tid]
save_cfg(cfg)
delete_remote(tid)
return jsonify(ok=True)
@app.route('/api/upload/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):
ok, err = test_remote(tid)
return jsonify(ok=ok, error=err)
@app.route('/api/upload/status')
def r_upload_status():
with upload_lock:
return jsonify(dict(upload_state))
# ── Browse (persistente Mounts für File-Explorer) ─────────────────────────────
_browse_mounts = {} # usb_port -> mount_point
def _mp_is_alive(mp):
"""Prüft ob ein Mount-Punkt wirklich aktiv und lesbar ist."""
try:
with open('/proc/mounts') as f:
mounted = any(mp in line.split() for line in f)
if not mounted:
return False
os.listdir(mp) # I/O-Test: schlägt fehl wenn Gerät entfernt wurde
return True
except Exception:
return False
def _drop_browse_mount(port):
"""Veralteten Mount bereinigen."""
mp = _browse_mounts.pop(port, None)
if mp:
subprocess.run(['umount', '-l', mp], capture_output=True)
log.info(f'Browse-Mount bereinigt: {mp}')
def get_browse_mp(dev):
port = dev.get('usb_port', '')
# Auto-mount vom System bevorzugen
if dev.get('mount') and _mp_is_alive(dev['mount']):
return dev['mount']
# Gecachten Mount prüfen
mp = _browse_mounts.get(port)
if mp:
if _mp_is_alive(mp):
return mp
_drop_browse_mount(port) # veraltet → aufräumen
# Frisch mounten
mp = f'/mnt/picopy_br_{port}'
os.makedirs(mp, exist_ok=True)
r = subprocess.run(['mount', dev['device'], mp], capture_output=True)
if r.returncode == 0:
_browse_mounts[port] = mp
return mp
return None
@app.route('/api/browse')
def r_browse():
port = request.args.get('port', '')
rpath = request.args.get('path', '').lstrip('/')
devs = usb_devices()
dev = next((d for d in devs if d['usb_port'] == port), None)
if not dev:
return jsonify(error='Gerät nicht verbunden bitte neu einstecken'), 404
mp = get_browse_mp(dev)
if not mp:
return jsonify(error='Gerät nicht lesbar bitte neu einstecken'), 500
try:
base = Path(mp).resolve()
target = (base / rpath).resolve()
if not str(target).startswith(str(base)):
return jsonify(error='Ungültiger Pfad'), 400
if not target.is_dir():
return jsonify(error='Kein Verzeichnis'), 400
entries = []
for item in sorted(target.iterdir(),
key=lambda x: (x.is_file(), x.name.lower())):
try:
s = item.stat()
entries.append({
'name': item.name,
'dir': item.is_dir(),
'size': s.st_size if item.is_file() else None,
'mtime': datetime.fromtimestamp(s.st_mtime).strftime('%d.%m.%y %H:%M'),
})
except OSError:
pass
rel = str(target.relative_to(base))
return jsonify(path='' if rel == '.' else rel, entries=entries)
except OSError as e:
import errno as _errno
if e.errno == _errno.EIO:
# I/O-Fehler = Gerät abgezogen, Mount bereinigen
_drop_browse_mount(port)
return jsonify(error='Gerät nicht mehr erreichbar bitte neu einstecken'), 503
return jsonify(error=str(e)), 500
except Exception as e:
return jsonify(error=str(e)), 500
# ── Update-System ─────────────────────────────────────────────────────────────
update_state = {
'current': VERSION,
'latest': None,
'available': False,
'checking': False,
'error': None,
'last_checked': None,
}
update_lock = threading.Lock()
def _vtuple(v):
try:
return tuple(int(x) for x in v.strip().lstrip('v').split('.'))
except Exception:
return (0,)
def check_for_updates():
with update_lock:
if update_state['checking']:
return
update_state['checking'] = True
update_state['error'] = None
try:
req = _urlreq.urlopen(f'{RAW_BASE}/version.txt', timeout=10)
latest = req.read().decode().strip()
avail = _vtuple(latest) > _vtuple(VERSION)
with update_lock:
update_state.update(latest=latest, available=avail,
last_checked=datetime.now().isoformat())
if avail:
log.info(f'Update verfügbar: {VERSION}{latest}')
except Exception as e:
with update_lock:
update_state['error'] = str(e)
log.warning(f'Update-Check fehlgeschlagen: {e}')
finally:
with update_lock:
update_state['checking'] = False
def update_check_loop():
time.sleep(30) # 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/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()
# 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
log.info('Update installiert starte Dienst neu…')
# Systemd startet den Dienst automatisch neu
subprocess.Popen(['systemctl', 'restart', 'picopy'])
return jsonify(ok=True)
except SyntaxError as e:
return jsonify(error=f'Update-Datei ungültig: {e}'), 500
except Exception as e:
log.exception('Update fehlgeschlagen')
return jsonify(error=str(e)), 500
# ── HTML Template ─────────────────────────────────────────────────────────────
HTML = r"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PiCopy</title>
<style>
/* ── Reset & Tokens ── */
:root {
--bg: #0a0f1e;
--bg2: #111827;
--surf: #1a2235;
--surf2: #1f2a40;
--brd: #2a3650;
--brd2: #374766;
--txt: #e8edf5;
--sub: #8b9ab5;
--acc: #4f8ef7;
--acc2: #3b7de8;
--grn: #34d399;
--grn2: #10b981;
--red: #f87171;
--ylw: #fbbf24;
--pur: #a78bfa;
--r: .6rem;
--r2: .9rem;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100vh;padding:0 0 4rem}
/* ── Topbar ── */
.topbar{background:var(--bg2);border-bottom:1px solid var(--brd);padding:.75rem 1.5rem;display:flex;align-items:center;gap:1rem;position:sticky;top:0;z-index:100;backdrop-filter:blur(8px)}
.logo{display:flex;align-items:center;gap:.55rem;font-size:1rem;font-weight:700;letter-spacing:-.02em;color:var(--txt)}
.logo-dot{width:8px;height:8px;border-radius:50%;background:var(--acc);box-shadow:0 0 8px var(--acc)}
.topbar-wifi{margin-left:auto;display:flex;align-items:center;gap:.6rem;font-size:.82rem;background:var(--surf);border:1px solid var(--brd);border-radius:9999px;padding:.3rem .75rem}
.wdot{width:7px;height:7px;border-radius:50%;transition:.3s;flex-shrink:0}
.wdot.c{background:var(--grn);box-shadow:0 0 6px var(--grn)}
.wdot.a{background:var(--pur)}
.wdot.d{background:var(--brd2)}
.upd-badge{display:none;align-items:center;gap:.4rem;font-size:.78rem;font-weight:600;background:rgba(251,191,36,.12);border:1px solid rgba(251,191,36,.4);color:var(--ylw);border-radius:9999px;padding:.28rem .75rem;cursor:pointer;transition:.15s;white-space:nowrap}
.upd-badge:hover{background:rgba(251,191,36,.22)}
#wifi-label{font-weight:600;color:var(--txt)}
#wifi-ip{color:var(--sub);font-family:monospace;font-size:.76rem}
/* ── Layout ── */
.page{max-width:1120px;margin:0 auto;padding:1.25rem 1.25rem 0;display:grid;gap:1rem;grid-template-columns:1fr}
@media(min-width:640px){.page{grid-template-columns:1fr 1fr}}
.col2{grid-column:1/-1}
/* ── 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)}
/* ── Port Slots ── */
/* port-pair: immer echtes 1fr 1fr, unabhängig vom Explorer */
.port-pair{display:grid;grid-template-columns:1fr 1fr;gap:.85rem;align-items:start}
@media(max-width:500px){.port-pair{grid-template-columns:1fr}}
.port-slot{border:1.5px solid var(--brd);border-radius:var(--r);padding:.85rem;transition:border-color .2s}
.port-slot.src-on{border-color:var(--grn2)}
.port-slot.dst-on{border-color:var(--acc)}
.role-tag{display:inline-flex;align-items:center;gap:.3rem;font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;padding:.14rem .45rem;border-radius:9999px;margin-bottom:.6rem}
.role-tag.src{background:rgba(52,211,153,.12);color:var(--grn)}
.role-tag.dst{background:rgba(79,142,247,.12);color:var(--acc)}
.port-display{display:flex;align-items:center;gap:.6rem;padding:.6rem .75rem;background:var(--bg2);border-radius:.5rem;margin-bottom:.75rem}
.dot{width:9px;height:9px;border-radius:50%;flex-shrink:0;transition:.3s}
.dot.on{background:var(--grn);box-shadow:0 0 7px var(--grn)}
.dot.off{background:var(--brd2)}
.port-path{font-family:ui-monospace,monospace;font-size:.92rem;font-weight:700;line-height:1.2}
.port-info{font-size:.72rem;color:var(--sub);margin-top:.1rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hint-box{font-size:.72rem;color:var(--sub);margin-top:.65rem;padding:.45rem .65rem;background:var(--bg2);border-radius:.4rem;border-left:3px solid var(--brd2);line-height:1.5}
/* ── Port+Explorer grid ── */
/* pex-grid: port-pair links, explorer rechts */
.pex-grid{display:grid;gap:.85rem;grid-template-columns:1fr}
@media(min-width:960px){.pex-grid{grid-template-columns:1fr auto}.pex-grid .expl-wrap{width:320px}}
.expl-wrap{border:1px solid var(--brd);border-radius:var(--r);overflow:hidden;display:flex;flex-direction:column}
/* ── File Explorer ── */
.expl-bar{display:flex;align-items:center;gap:.4rem;padding:.55rem .8rem;background:var(--surf2);border-bottom:1px solid var(--brd);flex-shrink:0}
.etab{padding:.22rem .6rem;border-radius:.35rem;font-size:.76rem;font-weight:600;cursor:pointer;border:1px solid transparent;color:var(--sub);transition:.15s}
.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;background:transparent;border:1px solid transparent;color:var(--sub);border-radius:.35rem;padding:.2rem .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}
</style>
</head>
<body>
<!-- Topbar -->
<header class="topbar">
<div class="logo">
<div class="logo-dot"></div>
PiCopy
</div>
<div class="topbar-wifi">
<div class="wdot d" id="wdot"></div>
<span id="wifi-label">Verbinde…</span>
<span id="wifi-ip"></span>
</div>
</header>
<main class="page">
<!-- ── Kopierstatus ── -->
<div class="card col2">
<div class="card-head">
<div class="card-icon blue">▶</div>
<span class="card-title">Kopierstatus</span>
<span class="card-sub" id="st-time"></span>
<button id="st-dismiss" onclick="dismissStatus()" title="Meldung schließen" style="display:none;margin-left:.5rem;background:transparent;border:1px solid var(--brd2);color:var(--sub);border-radius:.35rem;padding:.18rem .45rem;cursor:pointer;font-size:.8rem;line-height:1;transition:.15s" onmouseover="this.style.color='var(--txt)'" onmouseout="this.style.color='var(--sub)'">✕</button>
</div>
<div class="card-body">
<div class="st-headline st-idle" id="st-text">Bereit</div>
<div id="prog-wrap" style="display:none">
<div class="prog-track"><div class="prog-fill" id="prog-fill" style="width:0%"></div></div>
<div class="meta-row">
<span class="pill acc" id="prog-pct" style="display:none"></span>
<span class="pill" id="prog-files" style="display:none"></span>
<span class="pill" id="prog-bytes" style="display:none"></span>
<span class="pill acc" id="eta-pill" style="display:none"></span>
<span class="pill" id="speed-pill" style="display:none"></span>
</div>
<div id="cur-file" style="font-size:.74rem;color:var(--sub);margin-top:.3rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:monospace"></div>
</div>
<div id="st-summary" style="font-size:.81rem;color:var(--sub);margin-top:.4rem"></div>
<!-- Upload-Status -->
<div id="upload-block" style="display:none;margin-top:.75rem;padding:.65rem .85rem;background:var(--bg2);border-radius:.5rem;border:1px solid var(--brd)">
<div class="sec" style="margin-top:0">Fernkopie</div>
<div id="upload-current" style="font-size:.83rem;color:var(--acc)"></div>
<div id="upload-results" style="margin-top:.3rem;display:flex;flex-direction:column;gap:.2rem"></div>
</div>
<div class="btn-row">
<button id="btn-start" class="btn pri" onclick="startCopy()">▶&nbsp;Kopieren starten</button>
<button id="btn-cancel" class="btn danger" onclick="cancelCopy()" style="display:none">■&nbsp;Abbrechen</button>
<button class="btn ghost" onclick="refreshDevices()">↻&nbsp;Geräte neu laden</button>
</div>
</div>
</div>
<!-- ── USB Ports + Explorer ── -->
<div class="card col2">
<div class="card-head">
<div class="card-icon green">⇄</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">
<!-- Quelle -->
<div class="port-slot" id="slot-src">
<div class="role-tag src">▲ Quelle</div>
<div class="port-display">
<div class="dot off" id="src-dot"></div>
<div style="min-width:0">
<div class="port-path" id="src-port-path">—</div>
<div class="port-info" id="src-dev-info">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung</label>
<input type="text" id="src-label" placeholder="z.B. linker blauer Port">
</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>
<button class="btn grn" style="width:100%" onclick="assignPort('source')">✓&nbsp;Als feste Quelle speichern</button>
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
<div class="hint-box">Gerät in den gewünschten Port → aus Liste wählen → Speichern. PiCopy merkt sich den physischen Port dauerhaft.</div>
</div>
<!-- Ziel -->
<div class="port-slot" id="slot-dst">
<div class="role-tag dst">▼ Ziel</div>
<div class="port-display">
<div class="dot off" id="dst-dot"></div>
<div style="min-width:0">
<div class="port-path" id="dst-port-path">—</div>
<div class="port-info" id="dst-dev-info">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung</label>
<input type="text" id="dst-label" placeholder="z.B. rechter schwarzer Port">
</div>
<div class="field">
<label>Port lernen — Gerät wählen</label>
<select id="dst-select">
<option value="">— Gerät einstecken, dann hier wählen —</option>
</select>
</div>
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">✓&nbsp;Als festes Ziel speichern</button>
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
<div class="hint-box">Gerät in den gewünschten Port → aus Liste wählen → Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
</div>
</div><!-- /port-pair -->
<!-- File Explorer -->
<div class="expl-wrap">
<div class="expl-bar">
<button class="etab on" id="etab-src" onclick="expl.switchRole('src')">⬆ Quelle</button>
<button class="etab" id="etab-dst" onclick="expl.switchRole('dst')">⬇ Ziel</button>
<button class="expl-reload" onclick="expl.reload()" title="Neu laden">↻</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">
<div class="sec" style="margin-top:0">SMB / Netzlaufwerk</div>
<div class="field"><label>Name</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" value="PiCopy" placeholder="PiCopy"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
<div class="field"><label>Server (IP / Hostname)</label><input type="text" id="ut-host" placeholder="192.168.1.100"></div>
<div class="field"><label>Freigabe-Name</label><input type="text" id="ut-share" placeholder="backup"></div>
<div class="field"><label>Benutzer</label><input type="text" id="ut-user" placeholder="tobias"></div>
<div class="field"><label>Passwort</label><input type="password" id="ut-pass" placeholder="(leer = kein Passwort)"></div>
</div>
<div class="btn-row">
<button class="btn pri" onclick="utSave()">✓&nbsp;Speichern &amp; Verbindung testen</button>
<button class="btn ghost" onclick="utToggleForm()">Abbrechen</button>
</div>
<div id="ut-form-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
</div>
<!-- ── WiFi + Log nebeneinander ── -->
<div class="card">
<div class="card-head">
<div class="card-icon acc" style="background:rgba(79,142,247,.15);color:var(--acc)">⌘</div>
<span class="card-title">WiFi-Einstellungen</span>
</div>
<div class="card-body">
<div class="tab-strip">
<div class="tab on" onclick="swTab('tc','ta')">Heimnetz</div>
<div class="tab" onclick="swTab('ta','tc')">Hotspot (AP)</div>
</div>
<div id="tc" class="tpane on">
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.75rem;line-height:1.5">Heimnetz für die Router-Verbindung. Ohne Verbindung startet PiCopy automatisch einen eigenen Hotspot.</div>
<div class="field">
<label>Netzwerk (SSID)</label>
<div style="display:flex;gap:.4rem">
<input type="text" id="w-ssid" placeholder="WLAN-Name" style="flex:1">
<button class="btn ghost" onclick="scanNets()" title="Netzwerke suchen">🔍</button>
</div>
</div>
<div id="net-list" class="net-list" style="display:none"></div>
<div class="field"><label>Passwort</label><input type="password" id="w-pw" placeholder="WLAN-Passwort"></div>
<button class="btn pri" onclick="connectWifi()">🔌&nbsp;Verbinden &amp; Speichern</button>
<div id="wifi-flash" class="flash" style="margin-top:.4rem"></div>
</div>
<div id="ta" class="tpane">
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.75rem;line-height:1.5">Startet automatisch wenn kein Heimnetz erreichbar ist.<br>IP im Hotspot-Modus: <b style="color:var(--txt)">10.42.0.1:8080</b></div>
<div class="field"><label>Hotspot-Name (SSID)</label><input type="text" id="ap-ssid" placeholder="PiCopy"></div>
<div class="field"><label>Passwort (min. 8 Zeichen)</label><input type="password" id="ap-pw" placeholder="PiCopy,"></div>
<button class="btn pri" onclick="saveAP()">✓&nbsp;Speichern &amp; Neustart</button>
<div id="ap-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
</div>
<!-- ── Protokoll ── -->
<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">Protokoll</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>
<script>
let cfg = {}, devs = [];
const $ = id => document.getElementById(id);
const api = async (p, m='GET', b=null) => {
const o={method:m,headers:{'Content-Type':'application/json'}};
if(b) o.body=JSON.stringify(b);
return (await fetch('/api'+p,o)).json();
};
// ── Tabs ─────────────────────────────────────────────────────────────────────
function swTab(show,hide){
$(show).classList.add('on'); $(hide).classList.remove('on');
document.querySelectorAll('.tab').forEach(t=>
t.classList.toggle('on',t.textContent.trim().startsWith(show==='tc'?'Heim':'Hot'))
);
}
// ── Port Slots ────────────────────────────────────────────────────────────────
async function refreshDevices(){
devs = await api('/devices');
renderSlot('src', cfg.source_port, cfg.source_label);
renderSlot('dst', cfg.dest_port, cfg.dest_label);
renderUnassigned();
populateSel();
}
function renderSlot(r, port, label){
const isSrc=r==='src', 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('src-on', isSrc && !!port);
sl.classList.toggle('dst-on', !isSrc && !!port);
if(port){
pp.textContent='Port '+port+(label?' · '+label:'');
if(dev){ dot.className='dot on'; pi.textContent=(dev.label||dev.device)+(dev.size?' · '+dev.size:'')+(dev.mount?' · '+dev.mount:''); }
else { dot.className='dot off'; pi.textContent='Gerät nicht verbunden'; }
} else {
dot.className='dot off'; pp.textContent=''; pi.textContent='Kein Port konfiguriert';
}
if(lb && !lb.dataset.dirty) lb.value=label||'';
}
function populateSel(){
const opts=devs.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} — ${d.label||d.device} (${d.size})</option>`).join('');
['src-select','dst-select'].forEach(id=>{
const el=$(id),prev=el.value;
el.innerHTML='<option value="">— Gerät einstecken, dann hier wählen —</option>'+opts;
if(prev && devs.find(d=>d.usb_port===prev)) el.value=prev;
});
}
function renderUnassigned(){
const list=devs.filter(d=>d.usb_port!==cfg.source_port&&d.usb_port!==cfg.dest_port);
const w=$('unassigned-wrap');
if(!list.length){w.style.display='none';return;}
w.style.display='block';
$('unassigned-list').innerHTML=list.map(d=>`
<div style="display:flex;align-items:center;gap:.65rem;padding:.5rem .75rem;background:var(--bg2);border-radius:.45rem;font-size:.83rem">
<div style="width:8px;height:8px;border-radius:50%;background:var(--ylw);flex-shrink:0"></div>
<span style="font-weight:600">${d.label||d.device}</span>
<span style="color:var(--sub);font-size:.73rem">${d.device} · Port ${d.usb_port||'?'} · ${d.size}</span>
</div>`).join('');
}
async function assignPort(role){
const s=role==='source', sid=s?'src-select':'dst-select', lid=s?'src-label':'dst-label';
const fid=s?'src-flash':'dst-flash', pk=s?'source_port':'dest_port', lk=s?'source_label':'dest_label';
const port=$(sid).value, label=$(lid).value.trim();
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
const other=s?cfg.dest_port:cfg.source_port;
if(port===other){flash(fid,'err','Port bereits als '+(s?'Ziel':'Quelle')+' konfiguriert!');return;}
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok','✓ Port '+port+' als '+(s?'Quelle':'Ziel')+' gespeichert.');
renderSlot('src',cfg.source_port,cfg.source_label);
renderSlot('dst',cfg.dest_port,cfg.dest_label);
renderUnassigned();
}
['src-label','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 r=await api('/copy/start','POST'); if(r.error) alert('Fehler: '+r.error);
}
async function cancelCopy(){ await api('/copy/cancel','POST'); }
// ── Config ────────────────────────────────────────────────────────────────────
async function loadCfg(){
cfg=await api('/config');
$('c-fmt').value=cfg.folder_format||'%Y-%m-%d';
$('c-time').checked=!!cfg.add_time; $('c-sub').checked=!!cfg.subfolder; $('c-auto').checked=!!cfg.auto_copy;
$('c-filter').value=cfg.file_filter||'';
$('c-excl').checked=cfg.exclude_system!==false;
$('c-dup').value=cfg.duplicate_handling||'skip';
$('c-verify').checked=!!cfg.verify_checksum;
$('c-delsrc').checked=!!cfg.delete_source;
$('w-ssid').value=cfg.wifi_ssid||''; $('ap-ssid').value=cfg.ap_ssid||'PiCopy';
}
async function saveCopyCfg(){
cfg.folder_format=$('c-fmt').value; cfg.add_time=$('c-time').checked;
cfg.subfolder=$('c-sub').checked; cfg.auto_copy=$('c-auto').checked;
cfg.file_filter=$('c-filter').value.trim();
cfg.exclude_system=$('c-excl').checked;
cfg.duplicate_handling=$('c-dup').value;
cfg.verify_checksum=$('c-verify').checked;
cfg.delete_source=$('c-delsrc').checked;
await api('/config','POST',cfg);
const m=$('copy-cfg-msg'); m.style.display='block';
setTimeout(()=>m.style.display='none',2500);
}
function setFilter(v){ $('c-filter').value=v; }
// ── WiFi ──────────────────────────────────────────────────────────────────────
async function scanNets(){
$('net-list').style.display='flex'; $('net-list').innerHTML='<div class="expl-empty" style="padding:.5rem">Suche…</div>';
const nets=await api('/wifi/scan');
if(!nets.length){$('net-list').innerHTML='<div class="expl-empty" style="padding:.5rem">Keine Netzwerke</div>';return;}
$('net-list').innerHTML=nets.map(n=>{
const b=n.signal>66?'▂▄▆█':n.signal>33?'▂▄▆░':'▂▄░░';
return`<div class="net-row" onclick="pickNet('${n.ssid.replace(/'/g,"\\'")}')"><span>${n.ssid}</span><span class="net-sig">${b} ${n.signal}%</span></div>`;
}).join('');
}
function pickNet(s){$('w-ssid').value=s;$('net-list').style.display='none';$('w-pw').focus();}
async function connectWifi(){
const ssid=$('w-ssid').value.trim(),pw=$('w-pw').value;
if(!ssid){flash('wifi-flash','err','Bitte SSID eingeben');return;}
flash('wifi-flash','ok','Verbinde… (bis 30s)');
const r=await api('/wifi/connect','POST',{ssid,password:pw});
if(r.error) flash('wifi-flash','err',r.error);
else flash('wifi-flash','ok','Gestartet. Neue IP erscheint oben.');
}
async function saveAP(){
const s=$('ap-ssid').value.trim(),p=$('ap-pw').value;
if(!s){flash('ap-flash','err','Name fehlt');return;}
if(p.length<8){flash('ap-flash','err','Min. 8 Zeichen');return;}
const r=await api('/wifi/ap','POST',{ssid:s,password:p});
if(r.error) flash('ap-flash','err',r.error);
else flash('ap-flash','ok','Gespeichert! Hotspot startet neu.');
}
// ── Upload-Ziele ──────────────────────────────────────────────────────────────
const UT_ICONS={smb:'🖧',onedrive:'',drive:'📄',dropbox:'📦'};
const UT_LABELS={smb:'SMB/NAS',onedrive:'OneDrive',drive:'Google Drive',dropbox:'Dropbox'};
const UT_CMD={onedrive:'rclone authorize "onedrive"',drive:'rclone authorize "drive"',dropbox:'rclone authorize "dropbox"'};
let utType='smb', utTargets=[];
async function loadUTs(){utTargets=await api('/upload/targets');renderUTs();}
function renderUTs(){
const el=$('ut-list');
if(!utTargets.length){el.innerHTML='<div class="empty">Noch keine Fernziele konfiguriert</div>';return;}
el.innerHTML=utTargets.map(t=>`
<div class="ut-row ${t.enabled?'on':''}">
<span class="ut-ico">🖧</span>
<div style="flex:1;min-width:0">
<div class="ut-nm">${t.name}</div>
<div class="ut-meta">SMB/NAS · ${t.dest_path}</div>
</div>
<div class="ut-acts">
<button class="btn sm ghost" onclick="utTest('${t.id}','${t.name}')">Test</button>
<button class="btn sm ${t.enabled?'grn':'ghost'}" onclick="utToggle('${t.id}')">${t.enabled?'Aktiv':'Inaktiv'}</button>
<button class="btn sm danger" onclick="utDel('${t.id}','${t.name}')">✕</button>
</div>
</div>`).join('');
}
function utToggleForm(){
const f=$('ut-form'),b=$('ut-add-btn'),show=f.style.display==='none';
f.style.display=show?'block':'none';
b.innerHTML=show?'✕ Abbrechen':' Ziel hinzufügen';
if(show){utSelectType('smb',document.querySelector('.sel-opt'));}
}
function utSelectType(type,el){
utType=type;
document.querySelectorAll('.sel-opt').forEach(b=>b.classList.remove('on'));
el.classList.add('on');
$('ut-smb-fields').style.display=type==='smb'?'block':'none';
$('ut-cloud-fields').style.display=type!=='smb'?'block':'none';
if(UT_CMD[type]) $('ut-auth-cmd').textContent=UT_CMD[type];
}
function utCopyCmd(){
navigator.clipboard?.writeText($('ut-auth-cmd').textContent);
const b=document.querySelector('[onclick="utCopyCmd()"]');
b.textContent=''; setTimeout(()=>b.textContent='📋',1500);
}
async function utSave(){
const name=$('ut-name').value.trim(), dest=$('ut-dest').value.trim()||'PiCopy';
if(!name){flash('ut-form-flash','err','Name fehlt');return;}
let body={type:utType,name,dest_path:dest};
if(utType==='smb'){
body.host=$('ut-host').value.trim(); body.share=$('ut-share').value.trim();
body.user=$('ut-user').value.trim(); body.pass=$('ut-pass').value;
if(!body.host||!body.share){flash('ut-form-flash','err','Server und Freigabe sind Pflicht');return;}
} else {
body.token=$('ut-token').value.trim();
if(!body.token){flash('ut-form-flash','err','Token fehlt');return;}
}
flash('ut-form-flash','ok','Speichere…');
const r=await api('/upload/targets','POST',body);
if(r.error){flash('ut-form-flash','err',r.error);return;}
const t=await api('/upload/targets/'+r.id+'/test','POST');
if(t.ok){flash('ut-form-flash','ok','✓ Verbindung OK!');utToggleForm();await loadUTs();}
else flash('ut-form-flash','err','Test fehlgeschlagen: '+t.error);
}
async function utTest(id,name){
const r=await api('/upload/targets/'+id+'/test','POST');
alert(r.ok?'✓ Verbindung zu "'+name+'" erfolgreich!':'✗ Fehler: '+r.error);
}
async function utToggle(id){await api('/upload/targets/'+id+'/toggle','POST');await loadUTs();}
async function utDel(id,name){
if(!confirm('"'+name+'" wirklich löschen?'))return;
await api('/upload/targets/'+id,'DELETE');await loadUTs();
}
// ── File Explorer ─────────────────────────────────────────────────────────────
const expl={
role:'src', paths:{src:'',dst:''},
switchRole(r){
this.role=r;
$('etab-src').classList.toggle('on',r==='src');
$('etab-dst').classList.toggle('on',r==='dst');
this.load(this.paths[r]);
},
reload(){this.load(this.paths[this.role]);},
navigate(p){this.load(p);},
async load(path=''){
const port=this.role==='src'?cfg.source_port:cfg.dest_port;
const body=$('expl-body'), bread=$('expl-bread');
if(!port){body.innerHTML='<div class="expl-empty">Kein Port konfiguriert</div>';bread.innerHTML='';return;}
const dev=devs.find(d=>d.usb_port===port);
if(!dev){body.innerHTML='<div class="expl-empty">Gerät nicht verbunden</div>';bread.innerHTML='<span style="color:var(--sub)">Port '+port+' — nicht verbunden</span>';return;}
body.innerHTML='<div class="expl-empty">Lade…</div>';
try{
const data=await api('/browse?port='+encodeURIComponent(port)+'&path='+encodeURIComponent(path));
if(data.error){body.innerHTML='<div class="expl-empty">⚠ '+data.error+'</div>';return;}
this.paths[this.role]=data.path||'';
this._bread(data.path||'',dev.label||dev.device);
this._list(data.entries||[],data.path||'');
}catch(e){body.innerHTML='<div class="expl-empty">Verbindungsfehler</div>';}
},
_bread(path,label){
const el=$('expl-bread');
let h=`<span class="bseg" onclick="expl.navigate('')" title="${label}">⌂ ${label}</span>`;
if(path){
let acc='';
path.split('/').filter(Boolean).forEach(p=>{
acc+=(acc?'/':'')+p;const a=acc;
h+=`<span class="bsep"> </span><span class="bseg" onclick="expl.navigate('${a.replace(/'/g,"\\'")}')">${p}</span>`;
});
}
el.innerHTML=h;
},
_list(entries,cur){
const body=$('expl-body');
let h='';
if(cur){
const par=cur.includes('/')?cur.substring(0,cur.lastIndexOf('/')):'';
h+=`<div class="expl-row up" onclick="expl.navigate('${par}')"><span class="expl-ico">↩</span><span class="expl-nm" style="color:var(--sub)">..</span><span></span><span></span></div>`;
}
if(!entries.length&&!cur){body.innerHTML='<div class="expl-empty">Laufwerk leer</div>';return;}
if(!entries.length){body.innerHTML=h+'<div class="expl-empty">Ordner leer</div>';return;}
entries.forEach(e=>{
const ico=e.dir?'📁':fileIcon(e.name);
const np=(cur?cur+'/':'')+e.name;
const click=e.dir?`onclick="expl.navigate('${np.replace(/'/g,"\\'")}')" `:'';
h+=`<div class="expl-row ${e.dir?'dir':''}" ${click}>
<span class="expl-ico">${ico}</span>
<span class="expl-nm">${e.name}</span>
<span class="expl-sz">${e.size!=null?fmtBytes(e.size):''}</span>
<span class="expl-dt">${e.mtime||''}</span>
</div>`;
});
body.innerHTML=h;
}
};
function fileIcon(n){
const e=(n.split('.').pop()||'').toLowerCase();
if(['jpg','jpeg','png','gif','bmp','raw','cr2','nef','arw','heic','webp','dng'].includes(e))return'🖼';
if(['mp4','mov','avi','mkv','mts','m2ts','wmv','3gp'].includes(e))return'🎬';
if(['mp3','wav','flac','aac','m4a','ogg'].includes(e))return'🎵';
if(['pdf','doc','docx','txt','xls','xlsx'].includes(e))return'📄';
if(['zip','rar','7z','tar','gz'].includes(e))return'🗜';
return'📄';
}
function fmtBytes(b){
if(!b)return'';
if(b<1024)return b+' B';
if(b<1048576)return(b/1024).toFixed(1)+' KB';
if(b<1073741824)return(b/1048576).toFixed(1)+' MB';
return(b/1073741824).toFixed(2)+' GB';
}
function fmtETA(s){
if(!s||s<=0)return'';
if(s<60)return'<1 Min.';
const m=Math.round(s/60);
if(m<60)return'~'+m+' Min.';
const h=Math.floor(m/60),rm=m%60;
return'~'+h+'h'+(rm?' '+rm+'m':'');
}
function fmtSpd(bps){
if(!bps||bps<=0)return'';
if(bps<1048576)return(bps/1024).toFixed(0)+' KB/s';
return(bps/1048576).toFixed(1)+' MB/s';
}
// ── Poll ──────────────────────────────────────────────────────────────────────
async function poll(){
try{
const {copy:c,wifi:w}=await api('/status');
// WiFi
const wd=$('wdot'),wl=$('wifi-label'),wi=$('wifi-ip');
if(w.mode==='client'){wd.className='wdot c';wl.textContent=w.ssid||'Verbunden';wi.textContent=w.ip||'';}
else if(w.mode==='ap'){wd.className='wdot a';wl.textContent='Hotspot: '+(w.ssid||'PiCopy');wi.textContent='10.42.0.1';}
else{wd.className='wdot d';wl.textContent='Kein WLAN';wi.textContent='';}
// Copy
const tx=$('st-text'),pf=$('prog-fill'),pw=$('prog-wrap'),pp=$('prog-pct');
const pfiles=$('prog-files'),pbytes=$('prog-bytes'),eta=$('eta-pill'),spd=$('speed-pill');
const cf=$('cur-file'),sum=$('st-summary'),time=$('st-time');
const bS=$('btn-start'),bC=$('btn-cancel');
if(c.running){
const ph=c.phase||'copy';
if(ph==='verify'){
tx.className='st-headline st-run'; tx.textContent='Verifiziere… '+c.progress+'%';
pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%';
pp.style.display=''; pp.textContent=c.progress+'%';
pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' geprüft';
pbytes.style.display='none'; eta.style.display='none'; spd.style.display='none';
cf.textContent=c.current||'';
} else if(ph==='delete'){
tx.className='st-headline st-run'; tx.textContent='Quelle wird geleert…';
pw.style.display='none'; pp.style.display='none'; pfiles.style.display='none';
pbytes.style.display='none'; eta.style.display='none'; spd.style.display='none';
cf.textContent='';
} else {
tx.className='st-headline st-run'; tx.textContent='Kopiert… '+c.progress+'%';
pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%';
pp.style.display=''; pp.textContent=c.progress+'%';
pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' Dateien';
if(c.bytes_total>0){pbytes.style.display='';pbytes.textContent=fmtBytes(c.bytes_done)+' / '+fmtBytes(c.bytes_total);}else pbytes.style.display='none';
const e=fmtETA(c.eta_sec); eta.style.display=e?'':'none'; eta.textContent=e?''+e:'';
const s=fmtSpd(c.speed_bps); spd.style.display=s?'':'none'; spd.textContent=s?''+s:'';
cf.textContent=c.current||'';
}
sum.textContent=''; bS.style.display='none'; bC.style.display=''; time.textContent='';
}else{
bS.style.display=''; bC.style.display=''; cf.textContent='';
eta.style.display='none'; spd.style.display='none'; pfiles.style.display='none'; pbytes.style.display='none'; pp.style.display='none';
if(c.error){
tx.className='st-headline st-err'; tx.textContent='Fehler: '+c.error;
pf.className='prog-fill err'; pw.style.display='block'; pf.style.width='100%';
sum.textContent=''; time.textContent='';
}else if(c.last_copy && !_dismissed){
tx.className='st-headline st-ok'; tx.textContent='✓ Abgeschlossen';
pf.className='prog-fill done'; pw.style.display='block'; pf.style.width='100%';
sum.textContent=c.total+' Dateien · '+fmtBytes(c.bytes_total);
time.textContent=new Date(c.last_copy).toLocaleString('de-DE');
$('st-dismiss').style.display='';
// Auto-dismiss nach 5 Minuten
if(!_autoDismissTimer && c.last_copy){
const age=(Date.now()-new Date(c.last_copy).getTime())/1000;
const remaining=Math.max(0,300-age);
_autoDismissTimer=setTimeout(dismissStatus, remaining*1000);
}
}else{
tx.className='st-headline st-idle'; tx.textContent='Bereit';
pw.style.display='none'; sum.textContent=''; time.textContent='';
$('st-dismiss').style.display='none';
}
}
// Log
if(c.logs&&c.logs.length)
$('log-box').innerHTML=c.logs.slice().reverse().map(l=>`<div class="log-row"><span class="log-t">${l.t}</span><span class="log-m">${l.m}</span></div>`).join('');
}catch(e){}
// Upload status
try{
const u=await api('/upload/status');
const ub=$('upload-block');
if(u.running||u.results.length){
ub.style.display='block';
$('upload-current').innerHTML=u.running?''+u.current+'':'';
$('upload-results').innerHTML=u.results.map(r=>`<div style="font-size:.79rem;color:${r.ok?'var(--grn)':'var(--red)'}">${r.ok?'':''} ${r.name}${r.msg?''+r.msg:''}</div>`).join('');
}else ub.style.display='none';
}catch(e){}
}
let _dismissed = false, _autoDismissTimer = null;
function dismissStatus(){
_dismissed = true;
if(_autoDismissTimer){ clearTimeout(_autoDismissTimer); _autoDismissTimer=null; }
$('st-text').className='st-headline st-idle'; $('st-text').textContent='Bereit';
$('prog-wrap').style.display='none';
$('st-summary').textContent=''; $('st-time').textContent='';
$('st-dismiss').style.display='none';
}
// ── Update ────────────────────────────────────────────────────────────────────
async function pollUpdate() {
try {
const u = await api('/update/status');
const badge = $('upd-badge'), vEl = $('upd-version');
if (u.available && u.latest) {
vEl.textContent = 'v' + u.latest;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
} catch(e) {}
}
async function installUpdate() {
const u = await api('/update/status');
const latest = (u.latest || '?');
if (!confirm(
'Update auf v' + latest + ' installieren?\n\n' +
'PiCopy lädt die neue Version herunter und startet neu.\n' +
'Das Web-Interface ist für ca. 10 Sekunden nicht erreichbar.'
)) return;
$('upd-badge').innerHTML = '↻ Installiere…';
$('upd-badge').style.pointerEvents = 'none';
try {
await api('/update/install', 'POST');
} catch(e) {}
// Warte bis der Dienst wieder läuft, dann reload
setTimeout(async function waitForRestart() {
try {
await fetch('/api/update/status');
location.reload();
} catch(e) {
setTimeout(waitForRestart, 2000);
}
}, 5000);
}
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();
expl.load('');
setInterval(poll,1500);
setInterval(refreshDevices,8000);
setInterval(pollUpdate,60000);
poll();
pollUpdate();
})();
</script>
</body>
</html>"""
if __name__ == '__main__':
cleanup_stale_mounts()
load_state()
threading.Thread(target=usb_monitor, daemon=True).start()
threading.Thread(target=wifi_monitor, daemon=True).start()
threading.Thread(target=update_check_loop, 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)