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