Files
PiCopy/app.py

954 lines
36 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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, 'dest_port': None,
'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 = 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:940px;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}
/* Buttons */
.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}
/* Progress */
.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}
/* Status */
.st-ok{color:var(--grn)}.st-run{color:var(--acc)}.st-err{color:var(--red)}.st-idle{color:var(--mut)}
/* Devices */
.dev-list{display:flex;flex-direction:column;gap:.5rem}
.dev{padding:.65rem .85rem;border:1px solid var(--brd);border-radius:.5rem;transition:.15s}
.dev:hover{border-color:var(--acc)}
.dev.src{border-color:var(--grn);background:rgba(34,197,94,.06)}
.dev.dst{border-color:var(--acc);background:rgba(59,130,246,.06)}
.dev-top{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.dev-name{font-weight:600;font-size:.9rem}
.badge{font-size:.65rem;font-weight:700;text-transform:uppercase;padding:.12rem .42rem;border-radius:.25rem}
.b-src{background:rgba(34,197,94,.15);color:var(--grn)}
.b-dst{background:rgba(59,130,246,.15);color:var(--acc)}
.dev-meta{font-size:.74rem;color:var(--mut);display:flex;gap:.4rem;flex-wrap:wrap;margin:.25rem 0}
.dev-meta span{background:var(--bg);padding:.1rem .35rem;border-radius:.2rem}
.dev-acts{display:flex;gap:.4rem;margin-top:.35rem}
/* Form fields */
.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 */
.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 status bar */
.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-info{flex:1;min-width:0}
.wifi-ip{font-family:monospace;font-size:.8rem;color:var(--mut)}
/* Tab strip for config */
.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}
/* Network list */
.net-list{display:flex;flex-direction:column;gap:.35rem;max-height:220px;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)}
</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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
PiCopy
</h1>
<div id="wifi-bar" class="wifi-bar" style="max-width:340px">
<div class="wifi-dot grey" id="wifi-dot"></div>
<div class="wifi-info">
<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 Geräte -->
<div class="card span2">
<h2>Verbundene USB-Geräte</h2>
<div id="dev-list" class="dev-list"><div class="empty">Lade…</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 (2024-01-15)</option>
<option value="%Y%m%d">JJJJMMTT (20240115)</option>
<option value="%d-%m-%Y">TT-MM-JJJJ (15-01-2024)</option>
<option value="%Y/%m/%d">JJJJ/MM/TT (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 bei USB-Verbindung</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="showTab('tab-client')">Heimnetz</div>
<div class="tab" onclick="showTab('tab-ap')">Hotspot (AP)</div>
</div>
<!-- Client WiFi Tab -->
<div id="tab-client" class="tab-pane active">
<div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem">
WLAN für die Verbindung mit deinem Router. Wenn nicht erreichbar, 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">
<button class="btn sm" onclick="scanNetworks()" title="Netzwerke suchen">&#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>
<!-- AP Tab -->
<div id="tab-ap" class="tab-pane">
<div style="font-size:.8rem;color:var(--mut);margin-bottom:.75rem">
Der Hotspot wird automatisch gestartet wenn kein Heimnetz erreichbar ist.<br>
IP: <b>10.42.0.1</b> &nbsp;·&nbsp; Port: <b>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>
<!-- Log -->
<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><!-- /wrap -->
<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);
const r = await fetch('/api'+path, o);
return r.json();
};
// ── Tab navigation ───────────────────────────────────────────────────────
function showTab(id) {
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
$(id).classList.add('active');
const idx = ['tab-client','tab-ap'].indexOf(id);
document.querySelectorAll('.tab')[idx]?.classList.add('active');
}
// ── Devices ──────────────────────────────────────────────────────────────
async function refreshDevices() {
devs = await api('/devices');
renderDevs();
}
function renderDevs() {
const el = $('dev-list');
if (!devs.length) {
el.innerHTML = '<div class="empty">Keine USB-Speichergeräte gefunden. Gerät einstecken und "Neu laden" klicken.</div>';
return;
}
el.innerHTML = devs.map(d => {
const isSrc = d.usb_port === cfg.source_port;
const isDst = d.usb_port === cfg.dest_port;
const cls = isSrc ? 'src' : isDst ? 'dst' : '';
const badge = isSrc ? '<span class="badge b-src">&#10003; Quelle</span>'
: isDst ? '<span class="badge b-dst">&#10003; Ziel</span>' : '';
return `<div class="dev ${cls}">
<div class="dev-top">
<span class="dev-name">${d.label||d.device}</span>${badge}
</div>
<div class="dev-meta">
<span>${d.device}</span>
<span>Port: <b>${d.usb_port||'?'}</b></span>
<span>${d.size}</span>
${d.mount?'<span>'+d.mount+'</span>':''}
</div>
<div class="dev-acts">
<button class="btn sm sec" onclick="assign('${d.usb_port}','source')">Als Quelle</button>
<button class="btn sm" onclick="assign('${d.usb_port}','dest')">Als Ziel</button>
</div>
</div>`;
}).join('');
}
async function assign(port, role) {
if (role === 'source') cfg.source_port = port;
else cfg.dest_port = port;
await api('/config', 'POST', cfg);
renderDevs();
}
// ── 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'); }
// ── 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;
$('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 bars = 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">${bars} ${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();
const 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','Verbindungsversuch gestartet. Bei Erfolg erscheint neue IP oben.');
}
async function saveAP() {
const ssid = $('ap-ssid').value.trim();
const 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 status ────────────────────────────────────────────────────────────
async function poll() {
try {
const s = await api('/status');
const c = s.copy, w = s.wifi;
// WiFi bar
const dot = $('wifi-dot');
const modeTxt = $('wifi-mode-txt');
const ipTxt = $('wifi-ip');
if (w.mode === 'client') {
dot.className = 'wifi-dot green';
modeTxt.textContent = '&#128268; ' + (w.ssid || 'Verbunden');
modeTxt.innerHTML = '&#128268; ' + (w.ssid || 'Verbunden');
ipTxt.textContent = w.ip || '';
} else if (w.mode === 'ap') {
dot.className = 'wifi-dot blue';
modeTxt.innerHTML = '&#128246; Hotspot: ' + (w.ssid || 'PiCopy');
ipTxt.textContent = '10.42.0.1 (Direkt verbinden)';
} else {
dot.className = 'wifi-dot grey';
modeTxt.textContent = 'Kein WLAN';
ipTxt.textContent = '';
}
// Copy status
const txt = $('st-text'), bar = $('prog-bar'), wrap = $('prog-wrap');
const info = $('prog-info'), sum = $('st-summary');
const bStart = $('btn-start'), bCancel = $('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 = '';
bStart.style.display = 'none';
bCancel.style.display = '';
} else {
bStart.style.display = '';
bCancel.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 kopiert · ' + 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, 12000);
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)