Files
PiCopy/app.py

1015 lines
41 KiB
Python

#!/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))
# ── HTML Template ─────────────────────────────────────────────────────────────
# ── 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:960px;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)}
.field input[type=password]{letter-spacing:.05em}
.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-grid{display:grid;grid-template-columns:1fr 1fr;gap:.85rem}
@media(max-width:599px){.port-grid{grid-template-columns:1fr}}
.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)}
</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 -->
<div class="card span2">
<h2>USB Port Konfiguration</h2>
<div class="port-grid">
<!-- 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">Stecke den USB-Stick in den gewünschten Port, wähle ihn hier aus und klicke 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">Stecke das Ziel-Laufwerk in den gewünschten Port, wähle es aus und klicke Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
</div>
</div>
<!-- Weitere verbundene Geräte (nicht zugewiesen) -->
<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">Dieser Hotspot startet automatisch wenn kein Heimnetz erreichbar ist.<br>IP des Pi 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() === (show==='tab-client' ? 'Heimnetz' : 'Hotspot (AP)'))
);
}
// ── 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');
const nameEl = $(role+'-dev-name');
const subEl = $(role+'-dev-sub');
const slotEl = $('slot-'+role);
const 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 = 'Konfigurierter 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('');
}
// ── Assign port ───────────────────────────────────────────────────────────
async function assignPort(role) {
const isSrc = role === 'source';
const selId = isSrc ? 'src-select' : 'dst-select';
const lblId = isSrc ? 'src-label' : 'dst-label';
const flashId = isSrc ? 'src-flash' : 'dst-flash';
const portKey = isSrc ? 'source_port' : 'dest_port';
const labelKey = isSrc ? 'source_label': 'dest_label';
const port = $(selId).value;
const label = $(lblId).value.trim();
if (!port) { flash(flashId,'err','Bitte zuerst ein Gerät aus der Liste wählen.'); return; }
const otherPort = isSrc ? cfg.dest_port : cfg.source_port;
if (port === otherPort) {
flash(flashId,'err','Dieser Port ist bereits als '+(isSrc?'Ziel':'Quelle')+' konfiguriert!'); return;
}
cfg[portKey] = port;
cfg[labelKey] = label;
$(lblId).dataset.dirty = '';
await api('/config','POST',cfg);
flash(flashId,'ok','Gespeichert — Port '+port+' ist jetzt feste '+(isSrc?'Quelle':'Ziel')+'.');
renderPortSlots();
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() {
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 = 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 Netzwerke…</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.');
}
// ── Poll ──────────────────────────────────────────────────────────────────
async function poll() {
try {
const {copy:c, wifi:w} = await api('/status');
// WiFi bar
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='';
}
// Copy status
const txt=$('st-text'), bar=$('prog-bar'), wrap=$('prog-wrap');
const 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='&#10003; 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='';
}
}
// Log
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();
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)