1168 lines
49 KiB
Python
1168 lines
49 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))
|
||
|
||
|
||
# ── 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()">▶ Kopieren starten</button>
|
||
<button id="btn-cancel" class="btn danger" onclick="cancelCopy()" style="display:none">■ Abbrechen</button>
|
||
<button class="btn" onclick="refreshDevices()">↻ Geräte neu laden</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- USB Port Konfiguration + File Explorer -->
|
||
<div class="card span2">
|
||
<h2>USB Port Konfiguration & 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 & hier wählen —</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn sec" style="width:100%" onclick="assignPort('source')">✓ 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 & hier wählen —</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">✓ 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')">💾 Quelle</button>
|
||
<button class="expl-tab-btn" id="expl-tab-dst" onclick="expl.switchRole('dst')">💾 Ziel</button>
|
||
<button class="expl-refresh" onclick="expl.reload()" title="Neu laden">↻</button>
|
||
</div>
|
||
<div class="expl-bread" id="expl-bread">
|
||
<span class="bread-seg" onclick="expl.navigate('')">⌂</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 (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 (nach Gerätebezeichnung)</span></label>
|
||
<label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren wenn Quelle & Ziel verbunden</span></label>
|
||
<button class="btn pri" onclick="saveCopyCfg()">✓ 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()">🔌</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()">🔌 Verbinden & 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()">✓ Speichern & 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 & 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}">⌂ ${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='🔌 '+(w.ssid||'Verbunden');ip.textContent=w.ip||'';}
|
||
else if(w.mode==='ap'){dot.className='wifi-dot blue';mTxt.innerHTML='📶 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)
|