Files
PiCopy/app.py

1168 lines
49 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
from datetime import datetime
from pathlib import Path
from flask import Flask, jsonify, request
app = Flask(__name__)
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,
# 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': [],
}
copy_lock = threading.Lock()
def load_state():
global copy_state
try:
if STATE_FILE.exists():
saved = json.loads(STATE_FILE.read_text())
# Nur nicht-laufende Daten wiederherstellen
saved['running'] = False
saved['current'] = ''
copy_state.update(saved)
except Exception:
pass
def save_state():
try:
with copy_lock:
data = dict(copy_state)
STATE_FILE.write_text(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()))
except Exception:
pass
return cfg
def save_cfg(cfg):
CONFIG_FILE.write_text(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):
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:]
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='')
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}')
src_path = Path(src_mp)
files = [f for f in src_path.rglob('*') if f.is_file()]
total = len(files)
with copy_lock:
copy_state['total'] = total
add_log(f'{total} Dateien gefunden')
save_state()
for i, f in enumerate(files):
with copy_lock:
if not copy_state['running']:
add_log('Abgebrochen')
return
dst_f = dst_dir / f.relative_to(src_path)
dst_f.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(f, dst_f)
with copy_lock:
copy_state.update(done=i+1,
progress=int((i+1)/total*100) if total else 100,
current=str(f.name))
if (i+1) % 20 == 0:
save_state()
with copy_lock:
copy_state['last_copy'] = datetime.now().isoformat()
add_log(f'Fertig! {total} Dateien kopiert nach {dst_dir.name}')
except Exception as e:
log.exception('Copy failed')
with copy_lock:
copy_state['error'] = str(e)
add_log(f'Fehler: {e}')
finally:
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'] = ''
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')
# ── 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))
# ── Browse (persistente Mounts für File-Explorer) ─────────────────────────────
_browse_mounts = {} # usb_port -> mount_point
def get_browse_mp(dev):
port = dev.get('usb_port', '')
if dev.get('mount'):
return dev['mount']
mp = _browse_mounts.get(port)
if mp and Path(mp).is_dir():
return mp
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'), 404
mp = get_browse_mp(dev)
if not mp:
return jsonify(error='Gerät nicht mountbar'), 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 Exception:
pass
rel = str(target.relative_to(base))
return jsonify(path='' if rel == '.' else rel, entries=entries)
except Exception as e:
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>
:root{--bg:#0f172a;--s1:#1e293b;--s2:#243044;--brd:#334155;--txt:#e2e8f0;--mut:#94a3b8;--acc:#3b82f6;--grn:#22c55e;--red:#ef4444;--ylw:#f59e0b;--pur:#a78bfa}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--txt);font-family:system-ui,sans-serif;padding:1rem 1rem 4rem;min-height:100vh}
h1{font-size:1.35rem;font-weight:700;display:flex;align-items:center;gap:.5rem;margin-bottom:1.25rem}
h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:.8rem}
.wrap{max-width:1100px;margin:0 auto;display:grid;gap:.85rem;grid-template-columns:1fr}
@media(min-width:600px){.wrap{grid-template-columns:1fr 1fr}}
.card{background:var(--s1);border:1px solid var(--brd);border-radius:.8rem;padding:1.1rem}
.span2{grid-column:1/-1}
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.38rem .82rem;border:1px solid var(--brd);border-radius:.4rem;background:transparent;color:var(--txt);font-size:.84rem;cursor:pointer;transition:.15s;white-space:nowrap}
.btn:hover{border-color:var(--acc);color:var(--acc)}
.btn:disabled{opacity:.4;cursor:default}
.btn.pri{background:var(--acc);border-color:var(--acc);color:#fff}
.btn.pri:hover{background:#2563eb;border-color:#2563eb}
.btn.sec{border-color:var(--grn);color:var(--grn)}
.btn.sec:hover{background:rgba(34,197,94,.1)}
.btn.danger{border-color:var(--red);color:var(--red)}
.btn.danger:hover{background:rgba(239,68,68,.1)}
.btn.sm{padding:.25rem .55rem;font-size:.75rem}
.btn-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.8rem}
.prog-wrap{margin:.6rem 0 .3rem;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.prog-bar{height:100%;background:var(--acc);border-radius:3px;transition:width .4s ease}
.prog-info{font-size:.78rem;color:var(--mut);min-height:1.1rem}
.st-ok{color:var(--grn)}.st-run{color:var(--acc)}.st-err{color:var(--red)}.st-idle{color:var(--mut)}
.field{margin-bottom:.8rem}
.field label{display:block;font-size:.81rem;color:var(--mut);margin-bottom:.3rem}
.field input,.field select{width:100%;padding:.44rem .65rem;background:var(--bg);border:1px solid var(--brd);border-radius:.4rem;color:var(--txt);font-size:.88rem}
.field input:focus,.field select:focus{outline:none;border-color:var(--acc)}
.tog{display:flex;align-items:center;gap:.5rem;margin-bottom:.65rem;cursor:pointer;user-select:none;font-size:.88rem}
.tog input{accent-color:var(--acc);width:16px;height:16px;cursor:pointer}
.log-box{font-family:ui-monospace,monospace;font-size:.76rem;max-height:220px;overflow-y:auto}
.log-entry{display:flex;gap:.5rem;padding:.2rem 0;border-bottom:1px solid rgba(51,65,85,.5)}
.log-t{color:var(--mut);flex-shrink:0}
.empty{color:var(--mut);font-size:.86rem;padding:.25rem 0}
.wifi-bar{display:flex;align-items:center;gap:.6rem;padding:.55rem .85rem;border-radius:.5rem;border:1px solid var(--brd);background:var(--s2);font-size:.84rem;flex-wrap:wrap}
.wifi-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.wifi-dot.green{background:var(--grn)}.wifi-dot.blue{background:var(--pur)}.wifi-dot.grey{background:var(--mut)}
.wifi-ip{font-family:monospace;font-size:.8rem;color:var(--mut)}
.tabs{display:flex;gap:.25rem;margin-bottom:.9rem;border-bottom:1px solid var(--brd);padding-bottom:.5rem}
.tab{padding:.3rem .7rem;border-radius:.35rem;font-size:.82rem;cursor:pointer;color:var(--mut);transition:.15s}
.tab.active{background:var(--acc);color:#fff}
.tab-pane{display:none}.tab-pane.active{display:block}
.net-list{display:flex;flex-direction:column;gap:.35rem;max-height:200px;overflow-y:auto;margin-top:.5rem}
.net-item{display:flex;align-items:center;gap:.5rem;padding:.35rem .55rem;border:1px solid var(--brd);border-radius:.4rem;cursor:pointer;transition:.15s;font-size:.84rem}
.net-item:hover{border-color:var(--acc);background:rgba(59,130,246,.05)}
.net-signal{font-size:.72rem;color:var(--mut);margin-left:auto}
.flash{font-size:.78rem;padding:.25rem 0;min-height:1.2rem}
.flash.ok{color:var(--grn)}.flash.err{color:var(--red)}
/* ── Port Slots ── */
.port-slot{border:2px solid var(--brd);border-radius:.7rem;padding:1rem;transition:border-color .2s}
.port-slot.has-src{border-color:var(--grn)}
.port-slot.has-dst{border-color:var(--acc)}
.port-role{font-size:.65rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;padding:.18rem .5rem;border-radius:.25rem;display:inline-block;margin-bottom:.65rem}
.port-role.src{background:rgba(34,197,94,.15);color:var(--grn)}
.port-role.dst{background:rgba(59,130,246,.15);color:var(--acc)}
.port-status{display:flex;align-items:center;gap:.65rem;padding:.65rem .8rem;background:var(--bg);border-radius:.5rem;margin-bottom:.8rem;min-height:54px}
.pdot{width:10px;height:10px;border-radius:50%;flex-shrink:0;transition:.3s}
.pdot.on{background:var(--grn);box-shadow:0 0 6px var(--grn)}
.pdot.off{background:var(--brd)}
.port-dev-name{font-weight:600;font-size:.9rem;line-height:1.3}
.port-dev-sub{font-size:.73rem;color:var(--mut);font-family:monospace;margin-top:.1rem}
.port-hint{font-size:.73rem;color:var(--mut);margin-top:.65rem;padding:.5rem .65rem;background:var(--bg);border-radius:.4rem;border-left:3px solid var(--brd)}
/* ── Port + Explorer grid ── */
.port-and-expl{display:grid;gap:.85rem;grid-template-columns:1fr 1fr}
@media(max-width:599px){.port-and-expl{grid-template-columns:1fr}}
@media(min-width:900px){.port-and-expl{grid-template-columns:1fr 1fr 1.3fr}}
.expl-col{border:1px solid var(--brd);border-radius:.7rem;overflow:hidden;display:flex;flex-direction:column}
@media(max-width:899px){.expl-col{grid-column:1/-1}}
@media(min-width:900px){.expl-col{grid-column:3;grid-row:1}}
/* ── File Explorer ── */
.expl-header{padding:.6rem .8rem;background:var(--s2);border-bottom:1px solid var(--brd);display:flex;align-items:center;gap:.4rem;flex-shrink:0}
.expl-tab-btn{padding:.25rem .65rem;border-radius:.35rem;font-size:.78rem;cursor:pointer;border:1px solid var(--brd);background:transparent;color:var(--mut);transition:.15s}
.expl-tab-btn.active{background:var(--acc);border-color:var(--acc);color:#fff}
.expl-tab-btn:hover:not(.active){border-color:var(--acc);color:var(--acc)}
.expl-refresh{margin-left:auto;background:transparent;border:1px solid var(--brd);color:var(--mut);border-radius:.35rem;padding:.22rem .5rem;cursor:pointer;font-size:.85rem;transition:.15s}
.expl-refresh:hover{border-color:var(--acc);color:var(--acc)}
.expl-bread{padding:.45rem .8rem;font-size:.76rem;background:var(--bg);border-bottom:1px solid var(--brd);color:var(--mut);display:flex;align-items:center;gap:.25rem;flex-wrap:wrap;flex-shrink:0;min-height:32px}
.bread-seg{cursor:pointer;color:var(--acc);transition:.1s}
.bread-seg:hover{text-decoration:underline}
.bread-sep{color:var(--brd)}
.expl-body{flex:1;overflow-y:auto;max-height:380px}
@media(min-width:900px){.expl-body{max-height:420px}}
.expl-empty{padding:1.2rem .8rem;color:var(--mut);font-size:.84rem;text-align:center}
.expl-item{display:grid;grid-template-columns:1.6rem 1fr auto auto;align-items:center;gap:.35rem;padding:.38rem .75rem;border-bottom:1px solid rgba(51,65,85,.4);font-size:.82rem;transition:background .1s;cursor:default}
.expl-item:last-child{border-bottom:none}
.expl-item.is-dir{cursor:pointer}
.expl-item.is-dir:hover{background:rgba(59,130,246,.07)}
.expl-item.is-up{cursor:pointer;color:var(--mut)}
.expl-item.is-up:hover{background:rgba(148,163,184,.07)}
.expl-icon{font-size:1rem;text-align:center;line-height:1}
.expl-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.expl-item.is-dir .expl-name{font-weight:500}
.expl-size{font-size:.72rem;color:var(--mut);text-align:right;white-space:nowrap;font-family:monospace}
.expl-date{font-size:.7rem;color:var(--brd);white-space:nowrap;margin-left:.2rem}
@media(max-width:400px){.expl-date{display:none}}
.expl-arrow{color:var(--brd);font-size:.7rem}
</style>
</head>
<body>
<div class="wrap">
<!-- Header -->
<div class="span2" style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem">
<h1 style="margin:0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
PiCopy
</h1>
<div class="wifi-bar" style="max-width:340px">
<div class="wifi-dot grey" id="wifi-dot"></div>
<div style="flex:1;min-width:0">
<div id="wifi-mode-txt" style="font-weight:600">Verbinde…</div>
<div id="wifi-ip" class="wifi-ip"></div>
</div>
</div>
</div>
<!-- Kopierstatus -->
<div class="card span2">
<h2>Kopierstatus</h2>
<div id="st-text" class="st-idle" style="font-size:1rem;font-weight:600">Bereit</div>
<div class="prog-wrap" id="prog-wrap" style="display:none">
<div class="prog-bar" id="prog-bar" style="width:0%"></div>
</div>
<div id="prog-info" class="prog-info"></div>
<div id="st-summary" style="font-size:.82rem;color:var(--mut);margin-top:.3rem"></div>
<div class="btn-row">
<button id="btn-start" class="btn pri" onclick="startCopy()">&#9654;&nbsp;Kopieren starten</button>
<button id="btn-cancel" class="btn danger" onclick="cancelCopy()" style="display:none">&#9632;&nbsp;Abbrechen</button>
<button class="btn" onclick="refreshDevices()">&#8635;&nbsp;Geräte neu laden</button>
</div>
</div>
<!-- USB Port Konfiguration + File Explorer -->
<div class="card span2">
<h2>USB Port Konfiguration &amp; Datei-Explorer</h2>
<div class="port-and-expl">
<!-- QUELLE -->
<div class="port-slot" id="slot-src">
<div class="port-role src">Quelle</div>
<div class="port-status">
<div class="pdot off" id="src-dot"></div>
<div style="min-width:0">
<div class="port-dev-name" id="src-dev-name">Nicht verbunden</div>
<div class="port-dev-sub" id="src-dev-sub">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung (frei wählbar)</label>
<input type="text" id="src-label" placeholder="z.B. Kamera-Stick">
</div>
<div class="field">
<label>Port zuweisen — verbundenes Gerät wählen</label>
<select id="src-select">
<option value="">— Gerät einstecken &amp; hier wählen —</option>
</select>
</div>
<button class="btn sec" style="width:100%" onclick="assignPort('source')">&#10003;&nbsp;Als feste Quelle speichern</button>
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
<div class="port-hint">Gerät einstecken → aus Liste wählen → Speichern. PiCopy merkt sich diesen physischen Port dauerhaft.</div>
</div>
<!-- ZIEL -->
<div class="port-slot" id="slot-dst">
<div class="port-role dst">Ziel</div>
<div class="port-status">
<div class="pdot off" id="dst-dot"></div>
<div style="min-width:0">
<div class="port-dev-name" id="dst-dev-name">Nicht verbunden</div>
<div class="port-dev-sub" id="dst-dev-sub">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung (frei wählbar)</label>
<input type="text" id="dst-label" placeholder="z.B. Backup-Laufwerk">
</div>
<div class="field">
<label>Port zuweisen — verbundenes Gerät wählen</label>
<select id="dst-select">
<option value="">— Gerät einstecken &amp; hier wählen —</option>
</select>
</div>
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">&#10003;&nbsp;Als festes Ziel speichern</button>
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
<div class="port-hint">Gerät einstecken → aus Liste wählen → Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
</div>
<!-- FILE EXPLORER -->
<div class="expl-col" id="expl-col">
<div class="expl-header">
<button class="expl-tab-btn active" id="expl-tab-src" onclick="expl.switchRole('src')">&#128190;&nbsp;Quelle</button>
<button class="expl-tab-btn" id="expl-tab-dst" onclick="expl.switchRole('dst')">&#128190;&nbsp;Ziel</button>
<button class="expl-refresh" onclick="expl.reload()" title="Neu laden">&#8635;</button>
</div>
<div class="expl-bread" id="expl-bread">
<span class="bread-seg" onclick="expl.navigate('')">&#8962;</span>
</div>
<div class="expl-body" id="expl-body">
<div class="expl-empty">Gerät verbinden und Port konfigurieren</div>
</div>
</div>
</div>
<!-- Nicht zugewiesene Geräte -->
<div id="unassigned-wrap" style="margin-top:.85rem;display:none">
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:.5rem">Weitere verbundene Geräte (noch nicht zugewiesen)</div>
<div id="unassigned-list" style="display:flex;flex-direction:column;gap:.35rem"></div>
</div>
</div>
<!-- Kopier-Einstellungen -->
<div class="card">
<h2>Kopier-Einstellungen</h2>
<div class="field">
<label>Ordner-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 (nach Gerätebezeichnung)</span></label>
<label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren wenn Quelle &amp; Ziel verbunden</span></label>
<button class="btn pri" onclick="saveCopyCfg()">&#10003;&nbsp;Speichern</button>
<div id="copy-cfg-msg" class="flash ok" style="display:none">Gespeichert!</div>
</div>
<!-- WiFi-Einstellungen -->
<div class="card">
<h2>WiFi-Einstellungen</h2>
<div class="tabs">
<div class="tab active" onclick="switchTab('tab-client','tab-ap')">Heimnetz</div>
<div class="tab" onclick="switchTab('tab-ap','tab-client')">Hotspot (AP)</div>
</div>
<div id="tab-client" class="tab-pane active">
<div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem">Heimnetz für die Router-Verbindung. Wenn nicht erreichbar, startet PiCopy automatisch einen Hotspot.</div>
<div class="field">
<label>Netzwerk (SSID)</label>
<div style="display:flex;gap:.4rem">
<input type="text" id="w-ssid" placeholder="WLAN-Name">
<button class="btn sm" onclick="scanNetworks()">&#128268;</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()">&#128268;&nbsp;Verbinden &amp; Speichern</button>
<div id="wifi-flash" class="flash" style="margin-top:.4rem"></div>
</div>
<div id="tab-ap" class="tab-pane">
<div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem">Startet automatisch wenn kein Heimnetz erreichbar ist.<br>IP im Hotspot-Modus: <b>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>Hotspot-Passwort (min. 8 Zeichen)</label><input type="password" id="ap-pw" placeholder="PiCopy,"></div>
<button class="btn pri" onclick="saveAP()">&#10003;&nbsp;Speichern &amp; Neustart</button>
<div id="ap-flash" class="flash" style="margin-top:.4rem"></div>
</div>
</div>
<!-- Protokoll -->
<div class="card span2">
<h2>Protokoll</h2>
<div id="log-box" class="log-box"><div class="empty">Noch keine Einträge</div></div>
</div>
</div>
<script>
let cfg = {}, devs = [];
const $ = id => document.getElementById(id);
const api = async (path, method='GET', body=null) => {
const o = {method, headers:{'Content-Type':'application/json'}};
if (body) o.body = JSON.stringify(body);
return (await fetch('/api'+path, o)).json();
};
// ── Tabs ──────────────────────────────────────────────────────────────────
function switchTab(show, hide) {
$(show).classList.add('active'); $(hide).classList.remove('active');
document.querySelectorAll('.tab').forEach(t =>
t.classList.toggle('active', t.textContent.trim().startsWith(show==='tab-client'?'Heim':'Hot'))
);
}
// ── Port Slots ────────────────────────────────────────────────────────────
async function refreshDevices() {
devs = await api('/devices');
renderPortSlots();
renderUnassigned();
populateSelects();
}
function renderPortSlots() {
renderSlot('src', cfg.source_port, cfg.source_label);
renderSlot('dst', cfg.dest_port, cfg.dest_label);
}
function renderSlot(role, port, label) {
const isSrc = role === 'src';
const dev = devs.find(d => d.usb_port === port);
const dot = $(role+'-dot'), nameEl=$(role+'-dev-name'), subEl=$(role+'-dev-sub');
const slotEl = $('slot-'+role), lblEl=$(role+'-label');
slotEl.classList.toggle('has-src', isSrc && !!port);
slotEl.classList.toggle('has-dst', !isSrc && !!port);
if (dev) {
dot.className = 'pdot on';
nameEl.textContent = dev.label || dev.device;
subEl.textContent = 'Port '+port+(dev.size?' · '+dev.size:'')+(dev.mount?' · '+dev.mount:'');
} else if (port) {
dot.className = 'pdot off';
nameEl.textContent = label || 'Nicht verbunden';
subEl.textContent = 'Konfiguriert: Port '+port+(label?' · '+label:'');
} else {
dot.className = 'pdot off';
nameEl.textContent = 'Nicht verbunden';
subEl.textContent = 'Kein Port konfiguriert';
}
if (lblEl && !lblEl.dataset.dirty) lblEl.value = label || '';
}
function populateSelects() {
const opts = devs.map(d =>
`<option value="${d.usb_port}">${d.label||d.device} — Port ${d.usb_port||'?'} (${d.size})</option>`
).join('');
['src-select','dst-select'].forEach(id => {
const el=$(id), prev=el.value;
el.innerHTML='<option value="">— Gerät einstecken &amp; 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 wrap = $('unassigned-wrap');
if (!list.length) { wrap.style.display='none'; return; }
wrap.style.display='block';
$('unassigned-list').innerHTML = list.map(d=>`
<div style="display:flex;align-items:center;gap:.65rem;padding:.5rem .75rem;background:var(--bg);border-radius:.45rem;font-size:.84rem">
<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(--mut);font-size:.74rem">${d.device} · Port ${d.usb_port||'?'} · ${d.size}</span>
</div>`).join('');
}
async function assignPort(role) {
const isSrc=role==='source', selId=isSrc?'src-select':'dst-select';
const lblId=isSrc?'src-label':'dst-label', fId=isSrc?'src-flash':'dst-flash';
const port=$(selId).value, label=$(lblId).value.trim();
if (!port) { flash(fId,'err','Bitte zuerst ein Gerät wählen.'); return; }
const other = isSrc ? cfg.dest_port : cfg.source_port;
if (port===other) { flash(fId,'err','Dieser Port ist bereits als '+(isSrc?'Ziel':'Quelle')+' konfiguriert!'); return; }
cfg[isSrc?'source_port':'dest_port'] = port;
cfg[isSrc?'source_label':'dest_label'] = label;
$(lblId).dataset.dirty='';
await api('/config','POST',cfg);
flash(fId,'ok','Gespeichert — Port '+port+' ist jetzt feste '+(isSrc?'Quelle':'Ziel')+'.');
renderPortSlots(); renderUnassigned();
expl.reload();
}
['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() { const r=await api('/copy/start','POST'); if(r.error) alert('Fehler: '+r.error); }
async function cancelCopy() { await api('/copy/cancel','POST'); }
// ── Settings ──────────────────────────────────────────────────────────────
async function loadCfg() {
cfg=await api('/config');
$('c-fmt').value=$('c-fmt').querySelector(`[value="${cfg.folder_format||'%Y-%m-%d'}"]`)?.value||'%Y-%m-%d';
$('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;
$('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;
await api('/config','POST',cfg); flash('copy-cfg-msg','ok','Gespeichert!');
}
// ── WiFi ──────────────────────────────────────────────────────────────────
async function scanNetworks() {
$('net-list').style.display='flex'; $('net-list').innerHTML='<div class="empty">Suche…</div>';
const nets=await api('/wifi/scan');
if(!nets.length){$('net-list').innerHTML='<div class="empty">Keine Netzwerke gefunden</div>';return;}
$('net-list').innerHTML=nets.map(n=>{
const b=n.signal>66?'▂▄▆█':n.signal>33?'▂▄▆░':'▂▄░░';
return`<div class="net-item" onclick="selectNet('${n.ssid.replace(/'/g,"\\'")}')"><span>${n.ssid}</span><span class="net-signal">${b} ${n.signal}%</span></div>`;
}).join('');
}
function selectNet(ssid){$('w-ssid').value=ssid;$('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… (kann 30s dauern)');
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. Bei Erfolg erscheint oben die neue IP.');
}
async function saveAP(){
const ssid=$('ap-ssid').value.trim(),pw=$('ap-pw').value;
if(!ssid){flash('ap-flash','err','SSID fehlt');return;}
if(pw.length<8){flash('ap-flash','err','Passwort min. 8 Zeichen');return;}
const r=await api('/wifi/ap','POST',{ssid,password:pw});
if(r.error)flash('ap-flash','err',r.error);else flash('ap-flash','ok','Gespeichert! Hotspot wird neu gestartet.');
}
// ── File Explorer ─────────────────────────────────────────────────────────
const expl = {
role: 'src',
paths: {src:'', dst:''},
switchRole(role) {
this.role = role;
$('expl-tab-src').classList.toggle('active', role==='src');
$('expl-tab-dst').classList.toggle('active', role==='dst');
this.load(this.paths[role]);
},
reload() { this.load(this.paths[this.role]); },
navigate(path) { this.load(path); },
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='<span style="color:var(--mut)">—</span>';
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(--mut)">—</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._renderBread(data.path||'', dev.label||dev.device);
this._renderList(data.entries||[], data.path||'');
} catch(e) {
body.innerHTML='<div class="expl-empty">Verbindungsfehler</div>';
}
},
_renderBread(path, devLabel) {
const bread=$('expl-bread');
let html=`<span class="bread-seg" onclick="expl.navigate('')" title="${devLabel}">&#8962; ${devLabel}</span>`;
if (path) {
const parts=path.split('/').filter(Boolean);
let acc='';
parts.forEach(p=>{
acc+=(acc?'/':'')+p;
const a=acc;
html+=`<span class="bread-sep"> </span><span class="bread-seg" onclick="expl.navigate('${a.replace(/'/g,"\\'")}') ">${p}</span>`;
});
}
bread.innerHTML=html;
},
_renderList(entries, curPath) {
const body=$('expl-body');
if (!entries.length && !curPath) { body.innerHTML='<div class="expl-empty">Laufwerk ist leer</div>'; return; }
let html='';
if (curPath) {
const parent=curPath.includes('/') ? curPath.substring(0,curPath.lastIndexOf('/')) : '';
html+=`<div class="expl-item is-up" onclick="expl.navigate('${parent}')">
<span class="expl-icon">↩</span>
<span class="expl-name" style="color:var(--mut)">..</span>
<span class="expl-size"></span><span class="expl-date"></span>
</div>`;
}
if (!entries.length) { body.innerHTML=html+'<div class="expl-empty">Ordner ist leer</div>'; return; }
entries.forEach(e=>{
const icon=e.dir ? '📁' : fileIcon(e.name);
const size=e.size!=null ? fmtSize(e.size) : '';
const newPath=(curPath?curPath+'/':'')+e.name;
const click=e.dir ? `onclick="expl.navigate('${newPath.replace(/'/g,"\\'")}') "` : '';
html+=`<div class="expl-item ${e.dir?'is-dir':''}" ${click}>
<span class="expl-icon">${icon}</span>
<span class="expl-name">${e.name}</span>
<span class="expl-size">${size}</span>
<span class="expl-date">${e.mtime||''}</span>
</div>`;
});
body.innerHTML=html;
}
};
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','wma'].includes(e)) return '🎵';
if(['pdf','doc','docx','txt','xls','xlsx','ppt','pptx'].includes(e)) return '📄';
if(['zip','rar','7z','tar','gz','bz2'].includes(e)) return '🗜';
return '📄';
}
function fmtSize(b) {
if(b==null) 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';
}
// ── Poll ──────────────────────────────────────────────────────────────────
async function poll() {
try {
const {copy:c,wifi:w}=await api('/status');
const dot=$('wifi-dot'),mTxt=$('wifi-mode-txt'),ip=$('wifi-ip');
if(w.mode==='client'){dot.className='wifi-dot green';mTxt.innerHTML='&#128268; '+(w.ssid||'Verbunden');ip.textContent=w.ip||'';}
else if(w.mode==='ap'){dot.className='wifi-dot blue';mTxt.innerHTML='&#128246; Hotspot: '+(w.ssid||'PiCopy');ip.textContent='10.42.0.1 · Port 8080';}
else{dot.className='wifi-dot grey';mTxt.textContent='Kein WLAN';ip.textContent='';}
const txt=$('st-text'),bar=$('prog-bar'),wrap=$('prog-wrap'),info=$('prog-info'),sum=$('st-summary');
const bS=$('btn-start'),bC=$('btn-cancel');
if(c.running){
txt.className='st-run';txt.textContent='Kopiert… '+c.progress+'%';
wrap.style.display='block';bar.style.width=c.progress+'%';
info.textContent=c.current?c.done+' / '+c.total+''+c.current:'';
sum.textContent='';bS.style.display='none';bC.style.display='';
} else {
bS.style.display='';bC.style.display='none';info.textContent='';
if(c.error){txt.className='st-err';txt.textContent='Fehler: '+c.error;wrap.style.display='none';sum.textContent='';}
else if(c.last_copy){txt.className='st-ok';txt.textContent='✓ Abgeschlossen';wrap.style.display='block';bar.style.width='100%';sum.textContent=c.total+' Dateien · '+new Date(c.last_copy).toLocaleString('de-DE');}
else{txt.className='st-idle';txt.textContent='Bereit';wrap.style.display='none';sum.textContent='';}
}
if(c.logs&&c.logs.length)
$('log-box').innerHTML=c.logs.slice().reverse().map(l=>`<div class="log-entry"><span class="log-t">${l.t}</span><span>${l.m}</span></div>`).join('');
} 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();
expl.load('');
setInterval(poll,1500);
setInterval(refreshDevices,8000);
poll();
})();
</script>
</body>
</html>"""
if __name__ == '__main__':
load_state()
threading.Thread(target=usb_monitor, daemon=True).start()
threading.Thread(target=wifi_monitor, daemon=True).start()
log.info('PiCopy v2 läuft auf http://0.0.0.0:8080')
app.run(host='0.0.0.0', port=8080, debug=False, use_reloader=False)