4019 lines
164 KiB
Python
4019 lines
164 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 posixpath
|
||
import select
|
||
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, send_file
|
||
|
||
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'
|
||
INTERNAL_DEST_DIR = BASE_DIR / 'internal'
|
||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
HISTORY_FILE = BASE_DIR / 'history.json'
|
||
MAX_HISTORY = 100
|
||
|
||
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_ports': [], # [{port, label}, ...]
|
||
'source_port': None, 'source_label': '', # Migration legacy
|
||
'dest_port': None, 'dest_label': '',
|
||
'dest_type': 'usb', 'internal_dest_label': 'Interner Speicher',
|
||
'internal_share_enabled': False,
|
||
'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,',
|
||
# WireGuard
|
||
'wireguard_auto': False,
|
||
}
|
||
|
||
# -- 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()
|
||
_copy_thread: threading.Thread | None = None
|
||
|
||
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
|
||
|
||
# -- Kopier-Verlauf ----------------------------------------------------------
|
||
|
||
def load_history() -> list:
|
||
try:
|
||
if HISTORY_FILE.exists():
|
||
return json.loads(HISTORY_FILE.read_text(encoding='utf-8'))
|
||
except Exception:
|
||
pass
|
||
return []
|
||
|
||
def append_history(entry: dict):
|
||
h = load_history()
|
||
h.insert(0, entry)
|
||
try:
|
||
_atomic_write(HISTORY_FILE, json.dumps(h[:MAX_HISTORY]))
|
||
except Exception as e:
|
||
log.warning(f'Verlauf speichern fehlgeschlagen: {e}')
|
||
|
||
|
||
# -- Systeminfo --------------------------------------------------------------
|
||
|
||
def get_sysinfo() -> dict:
|
||
info: dict = {}
|
||
# CPU-Temperatur (Raspberry Pi)
|
||
for zone in ('/sys/class/thermal/thermal_zone0/temp',
|
||
'/sys/class/thermal/thermal_zone1/temp'):
|
||
try:
|
||
raw = Path(zone).read_text().strip()
|
||
info['cpu_temp'] = round(int(raw) / 1000, 1)
|
||
break
|
||
except Exception:
|
||
info['cpu_temp'] = None
|
||
# RAM
|
||
try:
|
||
mem: dict = {}
|
||
for line in Path('/proc/meminfo').read_text().splitlines():
|
||
parts = line.split()
|
||
if len(parts) >= 2:
|
||
mem[parts[0].rstrip(':')] = int(parts[1])
|
||
total = mem.get('MemTotal', 0)
|
||
avail = mem.get('MemAvailable', 0)
|
||
used = total - avail
|
||
info['ram_total'] = round(total / 1024)
|
||
info['ram_used'] = round(used / 1024)
|
||
info['ram_pct'] = round(used / total * 100) if total else 0
|
||
except Exception:
|
||
info['ram_total'] = info['ram_used'] = info['ram_pct'] = None
|
||
# SD-Karte (root-Dateisystem)
|
||
try:
|
||
du = shutil.disk_usage('/')
|
||
info['disk_total'] = round(du.total / 1e9, 1)
|
||
info['disk_used'] = round(du.used / 1e9, 1)
|
||
info['disk_pct'] = round(du.used / du.total * 100) if du.total else 0
|
||
except Exception:
|
||
info['disk_total'] = info['disk_used'] = info['disk_pct'] = None
|
||
return info
|
||
|
||
|
||
# -- 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)
|
||
|
||
# -- WireGuard VPN -------------------------------------------------------------
|
||
|
||
WG_CONF = Path('/etc/wireguard/picopy.conf')
|
||
WG_IFACE = 'picopy'
|
||
|
||
def wg_is_installed():
|
||
return shutil.which('wg-quick') is not None
|
||
|
||
|
||
wg_state = {
|
||
'connected': False,
|
||
'ip': '',
|
||
'peer': '',
|
||
'error': None,
|
||
'has_config': False,
|
||
'installed': False,
|
||
'pkg_running': False,
|
||
'pkg_action': '',
|
||
'pkg_error': None,
|
||
}
|
||
wg_lock = threading.Lock()
|
||
|
||
|
||
def wg_update_state():
|
||
inst = wg_is_installed()
|
||
has_conf = WG_CONF.exists()
|
||
if not inst:
|
||
with wg_lock:
|
||
wg_state.update(installed=False, connected=False, ip='', peer='',
|
||
has_config=has_conf)
|
||
return
|
||
r = subprocess.run(['wg', 'show', WG_IFACE],
|
||
capture_output=True, text=True, timeout=5)
|
||
if r.returncode != 0:
|
||
with wg_lock:
|
||
wg_state.update(installed=True, connected=False, ip='', peer='',
|
||
has_config=has_conf)
|
||
return
|
||
ip_r = subprocess.run(['ip', '-4', 'addr', 'show', WG_IFACE],
|
||
capture_output=True, text=True, timeout=5)
|
||
ip = ''
|
||
for line in ip_r.stdout.splitlines():
|
||
if line.strip().startswith('inet '):
|
||
ip = line.strip().split()[1].split('/')[0]
|
||
break
|
||
peer = ''
|
||
for line in r.stdout.splitlines():
|
||
if line.startswith('peer:'):
|
||
peer = line.split(':', 1)[-1].strip()
|
||
break
|
||
with wg_lock:
|
||
wg_state.update(installed=True, connected=True, ip=ip, peer=peer,
|
||
error=None, has_config=has_conf)
|
||
|
||
|
||
def wg_connect():
|
||
if not WG_CONF.exists():
|
||
with wg_lock:
|
||
wg_state['error'] = 'Keine Konfiguration vorhanden'
|
||
return False
|
||
r = subprocess.run(['wg-quick', 'up', WG_IFACE],
|
||
capture_output=True, text=True, timeout=30)
|
||
if r.returncode == 0:
|
||
time.sleep(1)
|
||
wg_update_state()
|
||
log.info('WireGuard verbunden')
|
||
return True
|
||
lines = r.stderr.strip().splitlines() if r.stderr.strip() else []
|
||
real_errors = [l for l in lines if not l.strip().startswith('[#]')]
|
||
err = (real_errors[-1] if real_errors else lines[-1] if lines else 'Unbekannter Fehler')
|
||
if 'resolvconf' in err and 'not found' in err:
|
||
err = 'resolvconf fehlt - bitte WireGuard deinstallieren und neu installieren (openresolv wird dann mitinstalliert)'
|
||
with wg_lock:
|
||
wg_state.update(connected=False, error=err)
|
||
log.error(f'WireGuard Fehler: {err}')
|
||
return False
|
||
|
||
|
||
def wg_disconnect():
|
||
r = subprocess.run(['wg-quick', 'down', WG_IFACE],
|
||
capture_output=True, text=True, timeout=15)
|
||
with wg_lock:
|
||
wg_state.update(connected=False, ip='', peer='', error=None)
|
||
log.info('WireGuard getrennt')
|
||
return r.returncode == 0
|
||
|
||
|
||
def _wg_apt(action: str, packages: list):
|
||
"""Führt apt-get install/remove aus und aktualisiert pkg_state."""
|
||
with wg_lock:
|
||
if wg_state['pkg_running']:
|
||
return
|
||
wg_state.update(pkg_running=True, pkg_action=action, pkg_error=None)
|
||
try:
|
||
cmd = ['apt-get', action, '-y'] + packages
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=300,
|
||
env={**os.environ, 'DEBIAN_FRONTEND': 'noninteractive'})
|
||
if r.returncode != 0:
|
||
err = (r.stderr.strip().splitlines()[-1]
|
||
if r.stderr.strip() else f'apt-get {action} fehlgeschlagen')
|
||
log.error(f'WireGuard apt {action}: {err}')
|
||
with wg_lock:
|
||
wg_state['pkg_error'] = err
|
||
else:
|
||
log.info(f'WireGuard apt {action} abgeschlossen')
|
||
except Exception as e:
|
||
with wg_lock:
|
||
wg_state['pkg_error'] = str(e)
|
||
finally:
|
||
with wg_lock:
|
||
wg_state['pkg_running'] = False
|
||
wg_state['pkg_action'] = ''
|
||
wg_update_state()
|
||
|
||
|
||
def wg_install():
|
||
_wg_apt('install', ['wireguard', 'wireguard-tools', 'openresolv'])
|
||
|
||
|
||
def wg_uninstall():
|
||
wg_disconnect()
|
||
_wg_apt('remove', ['wireguard', 'wireguard-tools'])
|
||
|
||
|
||
def wg_save_config(content: str):
|
||
try:
|
||
WG_CONF.parent.mkdir(parents=True, exist_ok=True)
|
||
WG_CONF.write_text(content, encoding='utf-8')
|
||
WG_CONF.chmod(0o600)
|
||
return True, ''
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
# -- Interner Speicher / SMB-Freigabe -----------------------------------------
|
||
|
||
SAMBA_CONF = Path('/etc/samba/smb.conf')
|
||
SAMBA_BEGIN = '# BEGIN PICOPY INTERNAL SHARE'
|
||
SAMBA_END = '# END PICOPY INTERNAL SHARE'
|
||
|
||
internal_share_state = {
|
||
'installed': False,
|
||
'enabled': False,
|
||
'active': False,
|
||
'path': str(INTERNAL_DEST_DIR),
|
||
'share': 'PiCopy',
|
||
'pkg_running': False,
|
||
'pkg_error': None,
|
||
'error': None,
|
||
}
|
||
internal_share_lock = threading.Lock()
|
||
|
||
|
||
def _internal_usage():
|
||
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
|
||
usage = shutil.disk_usage(INTERNAL_DEST_DIR)
|
||
return {
|
||
'path': str(INTERNAL_DEST_DIR),
|
||
'total': usage.total,
|
||
'used': usage.used,
|
||
'free': usage.free,
|
||
}
|
||
|
||
|
||
def internal_dest_device(cfg=None):
|
||
cfg = cfg or load_cfg()
|
||
usage = _internal_usage()
|
||
return {
|
||
'device': 'internal',
|
||
'usb_port': '__internal__',
|
||
'mount': str(INTERNAL_DEST_DIR),
|
||
'label': cfg.get('internal_dest_label') or 'Interner Speicher',
|
||
'size': _fmt_bytes(usage['free']) + ' frei',
|
||
'internal': True,
|
||
}
|
||
|
||
|
||
def smbd_installed():
|
||
return shutil.which('smbd') is not None
|
||
|
||
|
||
def _systemctl(*args, timeout=20):
|
||
try:
|
||
return subprocess.run(['systemctl'] + list(args), capture_output=True,
|
||
text=True, timeout=timeout)
|
||
except Exception as e:
|
||
return subprocess.CompletedProcess(['systemctl'] + list(args), 1,
|
||
stdout='', stderr=str(e))
|
||
|
||
|
||
def _smbd_active():
|
||
if not smbd_installed():
|
||
return False
|
||
r = _systemctl('is-active', 'smbd', timeout=5)
|
||
return r.returncode == 0 and r.stdout.strip() == 'active'
|
||
|
||
|
||
def internal_share_update_state():
|
||
cfg = load_cfg()
|
||
usage = _internal_usage()
|
||
with internal_share_lock:
|
||
internal_share_state.update(
|
||
installed=smbd_installed(),
|
||
enabled=bool(cfg.get('internal_share_enabled')),
|
||
active=_smbd_active(),
|
||
path=usage['path'],
|
||
total=usage['total'],
|
||
used=usage['used'],
|
||
free=usage['free'],
|
||
)
|
||
return dict(internal_share_state)
|
||
|
||
|
||
def _write_samba_share(enabled: bool):
|
||
old = SAMBA_CONF.read_text(encoding='utf-8') if SAMBA_CONF.exists() else ''
|
||
pattern = re.compile(rf'\n?{re.escape(SAMBA_BEGIN)}.*?{re.escape(SAMBA_END)}\n?', re.S)
|
||
cleaned = pattern.sub('\n', old).rstrip() + '\n'
|
||
if enabled:
|
||
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
|
||
INTERNAL_DEST_DIR.chmod(0o755)
|
||
block = f"""
|
||
{SAMBA_BEGIN}
|
||
[PiCopy]
|
||
path = {INTERNAL_DEST_DIR}
|
||
browseable = yes
|
||
read only = yes
|
||
guest ok = yes
|
||
force user = root
|
||
{SAMBA_END}
|
||
"""
|
||
cleaned += block
|
||
tmp = SAMBA_CONF.with_suffix('.conf.picopy_tmp')
|
||
tmp.write_text(cleaned, encoding='utf-8')
|
||
os.replace(str(tmp), str(SAMBA_CONF))
|
||
|
||
|
||
def _install_samba_if_needed():
|
||
if smbd_installed():
|
||
return True, ''
|
||
with internal_share_lock:
|
||
internal_share_state.update(pkg_running=True, pkg_error=None)
|
||
try:
|
||
r = subprocess.run(['apt-get', 'install', '-y', 'samba'],
|
||
capture_output=True, text=True, timeout=300,
|
||
env={**os.environ, 'DEBIAN_FRONTEND': 'noninteractive'})
|
||
if r.returncode != 0:
|
||
err = (r.stderr.strip().splitlines()[-1]
|
||
if r.stderr.strip() else 'samba-Installation fehlgeschlagen')
|
||
with internal_share_lock:
|
||
internal_share_state['pkg_error'] = err
|
||
return False, err
|
||
return True, ''
|
||
except Exception as e:
|
||
with internal_share_lock:
|
||
internal_share_state['pkg_error'] = str(e)
|
||
return False, str(e)
|
||
finally:
|
||
with internal_share_lock:
|
||
internal_share_state['pkg_running'] = False
|
||
|
||
|
||
def set_internal_share_enabled(enabled: bool):
|
||
ok, err = (True, '')
|
||
if enabled:
|
||
ok, err = _install_samba_if_needed()
|
||
if not ok:
|
||
return False, err
|
||
elif not smbd_installed():
|
||
cfg = load_cfg()
|
||
cfg['internal_share_enabled'] = False
|
||
save_cfg(cfg)
|
||
internal_share_update_state()
|
||
return True, ''
|
||
try:
|
||
_write_samba_share(enabled)
|
||
if enabled:
|
||
_systemctl('enable', '--now', 'smbd', timeout=60)
|
||
_systemctl('restart', 'smbd', timeout=60)
|
||
else:
|
||
_systemctl('restart', 'smbd', timeout=60)
|
||
cfg = load_cfg()
|
||
cfg['internal_share_enabled'] = enabled
|
||
save_cfg(cfg)
|
||
internal_share_update_state()
|
||
return True, ''
|
||
except Exception as e:
|
||
with internal_share_lock:
|
||
internal_share_state['error'] = str(e)
|
||
return False, str(e)
|
||
|
||
|
||
def wg_monitor():
|
||
while True:
|
||
try:
|
||
wg_update_state()
|
||
except Exception:
|
||
pass
|
||
time.sleep(10)
|
||
|
||
|
||
# -- 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):
|
||
if dev_info.get('internal'):
|
||
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
|
||
return str(INTERNAL_DEST_DIR), False
|
||
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 _resolve_source_ports(cfg) -> list:
|
||
"""Gibt source_ports als [{port, label}]-Liste zurück. Migriert altes source_port-Feld."""
|
||
ports = cfg.get('source_ports') or []
|
||
if not ports and cfg.get('source_port'):
|
||
ports = [{'port': cfg['source_port'], 'label': cfg.get('source_label', '')}]
|
||
return ports
|
||
|
||
|
||
def _configured_destination(cfg, devs):
|
||
if cfg.get('dest_type') == 'internal':
|
||
return internal_dest_device(cfg)
|
||
return next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None)
|
||
|
||
|
||
def do_copy(src_devs, dst_dev, cfg):
|
||
"""Kopiert von einer oder mehreren Quellen auf ein Ziel."""
|
||
dst_mp = None
|
||
dst_owned = False
|
||
src_mounts = [] # [(src_dev, src_mp, src_owned)]
|
||
_upload_thread = None
|
||
_hist = {
|
||
'start': time.time(),
|
||
'ok': False, 'copied': 0, 'skipped': 0, 'errors': 0,
|
||
'bytes': 0, 'error_msg': '',
|
||
}
|
||
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()
|
||
n = len(src_devs)
|
||
add_log(f'Kopiervorgang gestartet ({n} Quelle{"n" if n != 1 else ""})')
|
||
|
||
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')
|
||
|
||
# -- Alle Quellen mounten & Dateien sammeln -------------------------
|
||
# source_data: [(src_dev, src_path, files, dst_dir, incomplete_marker)]
|
||
source_data = []
|
||
total = 0
|
||
bytes_total = 0
|
||
|
||
for src_dev in src_devs:
|
||
with copy_lock:
|
||
cancelled = not copy_state['running']
|
||
if cancelled:
|
||
add_log('Abgebrochen')
|
||
return
|
||
|
||
src_mp_i, src_owned_i = ensure_mount(src_dev)
|
||
src_mounts.append((src_dev, src_mp_i, src_owned_i))
|
||
if not src_mp_i:
|
||
add_log(f'Quelle nicht mountbar: {src_dev["device"]} - übersprungen')
|
||
continue
|
||
|
||
add_log(f'Quelle: {src_mp_i} ({src_dev["label"]})')
|
||
src_path = Path(src_mp_i)
|
||
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 gefiltert ({src_dev["label"]})')
|
||
|
||
label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source'))
|
||
dst_dir_i = Path(dst_mp) / date_str
|
||
if cfg.get('subfolder'):
|
||
dst_dir_i = dst_dir_i / label
|
||
dst_dir_i.mkdir(parents=True, exist_ok=True)
|
||
add_log(f'Zielordner: {dst_dir_i}')
|
||
|
||
for stale in dst_dir_i.rglob('*.picopy_tmp'):
|
||
stale.unlink(missing_ok=True)
|
||
|
||
incomplete_marker_i = dst_dir_i / '.picopy_incomplete'
|
||
incomplete_marker_i.write_text(json.dumps({
|
||
'started': datetime.now().isoformat(),
|
||
'source': src_dev.get('label', ''),
|
||
}))
|
||
|
||
total += len(files)
|
||
bytes_total += sum(f.stat().st_size for f in files)
|
||
source_data.append((src_dev, src_path, files, dst_dir_i, incomplete_marker_i))
|
||
|
||
with copy_lock:
|
||
copy_state['total'] = total
|
||
copy_state['bytes_total'] = bytes_total
|
||
add_log(f'{total} Dateien gesamt ({_fmt_bytes(bytes_total)})')
|
||
save_state()
|
||
|
||
# -- Phase 1: Kopieren (alle Quellen) --------------------------------
|
||
dup_mode = cfg.get('duplicate_handling', 'skip')
|
||
all_copied_pairs = []
|
||
skipped = 0
|
||
io_errors = 0
|
||
global_done = 0
|
||
|
||
for src_dev_i, src_path_i, files_i, dst_dir_i, _ in source_data:
|
||
if len(src_devs) > 1:
|
||
add_log(f'Kopiere: {src_dev_i["label"]}')
|
||
for f in files_i:
|
||
with copy_lock:
|
||
cancelled = not copy_state['running']
|
||
if cancelled:
|
||
add_log('Abgebrochen')
|
||
return
|
||
global_done += 1
|
||
rel = f.relative_to(src_path_i)
|
||
dst_f = dst_dir_i / rel
|
||
try:
|
||
dst_f.parent.mkdir(parents=True, exist_ok=True)
|
||
except OSError as mkdir_err:
|
||
io_errors += 1
|
||
add_log(f'⚠ Verzeichnis nicht erstellbar ({dst_f.parent.name}): {mkdir_err}')
|
||
with copy_lock:
|
||
copy_state.update(done=global_done,
|
||
progress=int(global_done/total*100) if total else 100,
|
||
current=str(f.name))
|
||
continue
|
||
|
||
if dst_f.exists():
|
||
if dup_mode == 'skip':
|
||
if dst_f.stat().st_size == f.stat().st_size:
|
||
skipped += 1
|
||
with copy_lock:
|
||
copy_state.update(done=global_done,
|
||
progress=int(global_done/total*100) if total else 100,
|
||
current=str(f.name))
|
||
continue
|
||
else:
|
||
add_log(f'Unvollständige Datei, wird neu kopiert: {f.name}')
|
||
elif dup_mode == 'rename':
|
||
dst_f = _unique_path(dst_f)
|
||
|
||
fsize = f.stat().st_size
|
||
tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp')
|
||
try:
|
||
shutil.copy2(f, tmp_f)
|
||
os.replace(str(tmp_f), str(dst_f))
|
||
except OSError as copy_err:
|
||
try: tmp_f.unlink(missing_ok=True)
|
||
except Exception: pass
|
||
io_errors += 1
|
||
add_log(f'⚠ Fehler bei {f.name}: {copy_err}')
|
||
with copy_lock:
|
||
copy_state.update(done=global_done,
|
||
progress=int(global_done/total*100) if total else 100,
|
||
current=str(f.name))
|
||
continue
|
||
all_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=global_done,
|
||
progress=int(global_done/total*100) if total else 100,
|
||
current=str(f.name), speed_bps=int(speed), eta_sec=eta)
|
||
if global_done % 20 == 0:
|
||
save_state()
|
||
|
||
msg_parts = [f'{len(all_copied_pairs)} kopiert']
|
||
if skipped:
|
||
msg_parts.append(f'{skipped} übersprungen')
|
||
if io_errors:
|
||
msg_parts.append(f'{io_errors} Fehler (I/O)')
|
||
|
||
# -- Phase 2: Verifizieren ------------------------------------------
|
||
verify_errors = 0
|
||
verified_pairs = list(all_copied_pairs)
|
||
|
||
if cfg.get('verify_checksum') and all_copied_pairs:
|
||
with copy_lock:
|
||
copy_state.update(phase='verify', progress=0, done=0,
|
||
total=len(all_copied_pairs), current='',
|
||
eta_sec=None, speed_bps=0)
|
||
add_log(f'Verifiziere {len(all_copied_pairs)} Dateien...')
|
||
verified_pairs = []
|
||
|
||
for i, (src_f, dst_f) in enumerate(all_copied_pairs):
|
||
with copy_lock:
|
||
cancelled = not copy_state['running']
|
||
if not cancelled:
|
||
copy_state.update(done=i+1,
|
||
progress=int((i+1)/len(all_copied_pairs)*100),
|
||
current=src_f.name)
|
||
if cancelled:
|
||
add_log('Abgebrochen')
|
||
return
|
||
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 ✓')
|
||
|
||
subprocess.run(['sync'], capture_output=True)
|
||
for _, _, _, _, incomplete_marker_i in source_data:
|
||
try: incomplete_marker_i.unlink(missing_ok=True)
|
||
except Exception: pass
|
||
|
||
with copy_lock:
|
||
copy_state['last_copy'] = datetime.now().isoformat()
|
||
_hist['bytes'] = copy_state['bytes_done']
|
||
_hist.update(ok=True, copied=len(all_copied_pairs),
|
||
skipped=skipped, errors=io_errors)
|
||
add_log('Fertig! ' + ', '.join(msg_parts))
|
||
|
||
dst_dir_root = Path(dst_mp) / date_str
|
||
upload_files = [dst_f for _, dst_f in verified_pairs if dst_f.exists()]
|
||
if upload_files:
|
||
_upload_thread = threading.Thread(
|
||
target=run_uploads,
|
||
args=(dst_dir_root, cfg, upload_files),
|
||
daemon=True
|
||
)
|
||
_upload_thread.start()
|
||
elif any(t.get('enabled') for t in cfg.get('upload_targets', [])):
|
||
add_log('NAS-Upload: keine neu auf das Ziel übertragenen Dateien')
|
||
|
||
except Exception as e:
|
||
log.exception('Copy failed')
|
||
with copy_lock:
|
||
copy_state['error'] = str(e)
|
||
_hist['error_msg'] = str(e)
|
||
add_log(f'Fehler: {e}')
|
||
|
||
finally:
|
||
# Erst warten bis NAS-Upload fertig, dann erst unmounten
|
||
if _upload_thread is not None and _upload_thread.is_alive():
|
||
add_log('Warte auf NAS-Upload vor Unmount...')
|
||
_upload_thread.join()
|
||
subprocess.run(['sync'], capture_output=True)
|
||
for _, src_mp_i, src_owned_i in src_mounts:
|
||
if src_owned_i and src_mp_i:
|
||
subprocess.run(['umount', src_mp_i], 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()
|
||
# Verlaufseintrag speichern
|
||
append_history({
|
||
'ts': datetime.now().isoformat(),
|
||
'duration': int(time.time() - _hist['start']),
|
||
'sources': [d.get('label', d.get('device', '?')) for d in src_devs],
|
||
'dest': dst_dev.get('label', dst_dev.get('device', '?')) if dst_dev else '?',
|
||
'copied': _hist['copied'],
|
||
'skipped': _hist['skipped'],
|
||
'errors': _hist['errors'],
|
||
'bytes': _hist['bytes'],
|
||
'ok': _hist['ok'],
|
||
'error': _hist['error_msg'],
|
||
})
|
||
|
||
def check_auto_copy():
|
||
cfg = load_cfg()
|
||
src_ports = _resolve_source_ports(cfg)
|
||
if not cfg.get('auto_copy') or not src_ports:
|
||
return
|
||
if cfg.get('dest_type') != 'internal' and not cfg.get('dest_port'):
|
||
return
|
||
with copy_lock:
|
||
if copy_state['running'] or copy_state['error']:
|
||
return
|
||
devs = usb_devices()
|
||
srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports]
|
||
srcs = [s for s in srcs if s is not None]
|
||
dst = _configured_destination(cfg, devs)
|
||
if srcs and dst:
|
||
log.info(f'Auto-Copy: {len(srcs)} Quelle(n) und Ziel verbunden')
|
||
threading.Thread(target=do_copy, args=(srcs, 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': [],
|
||
'progress': 0,
|
||
'total': 0,
|
||
'done': 0,
|
||
'bytes_total': 0,
|
||
'bytes_done': 0,
|
||
'current_file': '',
|
||
'eta_sec': None,
|
||
'speed_bps': 0,
|
||
}
|
||
upload_lock = threading.Lock()
|
||
|
||
|
||
def _rclone(*args, timeout=60):
|
||
try:
|
||
return subprocess.run(
|
||
['rclone', '--config', str(RCLONE_CONF)] + list(args),
|
||
capture_output=True, text=True, timeout=timeout
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'Timeout nach {timeout}s')
|
||
except Exception as e:
|
||
return subprocess.CompletedProcess(args, 1, stdout='', stderr=str(e))
|
||
|
||
|
||
def _rclone_obscure(pw):
|
||
r = subprocess.run(['rclone', 'obscure', pw],
|
||
capture_output=True, text=True, timeout=10)
|
||
return r.stdout.strip()
|
||
|
||
|
||
def _parse_percent(text: str):
|
||
m = re.search(r'(\d+(?:\.\d+)?)%', text)
|
||
if not m:
|
||
return None
|
||
try:
|
||
return max(0.0, min(100.0, float(m.group(1))))
|
||
except ValueError:
|
||
return None
|
||
|
||
|
||
def _rclone_copyto_progress(src: Path, dest: str, base_done: int,
|
||
file_size: int, total_bytes: int, start_ts: float,
|
||
timeout: int = 7200):
|
||
args = [
|
||
'rclone', '--config', str(RCLONE_CONF),
|
||
'copyto', str(src), dest,
|
||
'--retries', '1',
|
||
'--progress',
|
||
'--stats', '1s',
|
||
'--stats-one-line',
|
||
]
|
||
try:
|
||
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||
text=True, bufsize=1)
|
||
started = time.time()
|
||
stderr_parts = []
|
||
buf = ''
|
||
while True:
|
||
if p.poll() is not None:
|
||
break
|
||
if time.time() - started > timeout:
|
||
p.kill()
|
||
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'Timeout nach {timeout}s')
|
||
|
||
ready, _, _ = select.select([p.stderr], [], [], 0.2) if p.stderr else ([], [], [])
|
||
if not ready:
|
||
time.sleep(0.1)
|
||
continue
|
||
chunk = p.stderr.read(1)
|
||
if not chunk:
|
||
continue
|
||
stderr_parts.append(chunk)
|
||
if chunk not in ('\r', '\n'):
|
||
buf += chunk
|
||
continue
|
||
|
||
pct = _parse_percent(buf)
|
||
buf = ''
|
||
if pct is not None:
|
||
transferred = int(file_size * pct / 100)
|
||
bytes_done = base_done + transferred
|
||
elapsed = time.time() - start_ts
|
||
speed = bytes_done / elapsed if elapsed > 1 else 0
|
||
eta = int((total_bytes - bytes_done) / speed) if speed > 0 and total_bytes > bytes_done else 0
|
||
with upload_lock:
|
||
upload_state.update(bytes_done=bytes_done,
|
||
progress=int(bytes_done / total_bytes * 100) if total_bytes else 100,
|
||
speed_bps=int(speed), eta_sec=eta)
|
||
|
||
stdout, stderr_tail = p.communicate(timeout=5)
|
||
if stderr_tail:
|
||
stderr_parts.append(stderr_tail)
|
||
return subprocess.CompletedProcess(args, p.returncode, stdout=stdout or '',
|
||
stderr=''.join(stderr_parts))
|
||
except subprocess.TimeoutExpired:
|
||
return subprocess.CompletedProcess(args, 1, stdout='', stderr=f'Timeout nach {timeout}s')
|
||
except Exception as e:
|
||
return subprocess.CompletedProcess(args, 1, stdout='', stderr=str(e))
|
||
|
||
|
||
def _remote_name(tid):
|
||
return f'picopy_{tid}'
|
||
|
||
|
||
def _join_remote_path(*parts) -> str:
|
||
return '/'.join(str(p).strip('/') for p in parts if str(p).strip('/'))
|
||
|
||
|
||
def _remote_exists(remote_path: str) -> bool:
|
||
return _remote_size(remote_path) is not None
|
||
|
||
|
||
def _remote_size(remote_path: str):
|
||
r = _rclone('lsjson', remote_path, timeout=20)
|
||
if r.returncode != 0:
|
||
return None
|
||
try:
|
||
data = json.loads(r.stdout or '[]')
|
||
if isinstance(data, dict):
|
||
return data.get('Size')
|
||
if isinstance(data, list) and data:
|
||
item = data[0]
|
||
return item.get('Size') if isinstance(item, dict) else None
|
||
return None
|
||
except (json.JSONDecodeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _remote_unique_rel_path(t: dict, rel_path: str) -> str:
|
||
if not _remote_exists(_smb_conn(t, rel_path)):
|
||
return rel_path
|
||
|
||
parent = posixpath.dirname(rel_path)
|
||
name = posixpath.basename(rel_path)
|
||
stem, suffix = posixpath.splitext(name)
|
||
i = 1
|
||
while True:
|
||
candidate_name = f'{stem}_({i}){suffix}'
|
||
candidate = _join_remote_path(parent, candidate_name)
|
||
if not _remote_exists(_smb_conn(t, candidate)):
|
||
return candidate
|
||
i += 1
|
||
|
||
|
||
def _smb_conn(t: dict, path: str = '') -> str:
|
||
"""Baut ein rclone-Ziel fuer gespeicherte SMB-Targets.
|
||
|
||
Bei rclone SMB ist die Freigabe der erste Pfadteil nach dem Remote:
|
||
remote:share/ordner. Die Remote-Konfiguration enthaelt Host und Login.
|
||
"""
|
||
share = t.get('smb_share', '')
|
||
remote_path = _join_remote_path(share, path)
|
||
if t.get('id'):
|
||
return f'{_remote_name(t["id"])}:{remote_path}'
|
||
|
||
host = t.get('smb_host', '')
|
||
if not host:
|
||
return f':{remote_path}'
|
||
conn = f':smb,host={host}'
|
||
if t.get('smb_user'):
|
||
conn += f',user={t["smb_user"]}'
|
||
if t.get('smb_pass'):
|
||
conn += f',pass={t["smb_pass"]}'
|
||
conn += f':{remote_path}'
|
||
return conn
|
||
|
||
|
||
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}']
|
||
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):
|
||
cfg = load_cfg()
|
||
targets = cfg.get('upload_targets', [])
|
||
t = next((x for x in targets if x['id'] == tid), {'id': tid})
|
||
dest_root = t.get('dest_path', 'PiCopy').strip('/')
|
||
root = _smb_conn(t)
|
||
dest = _smb_conn(t, dest_root)
|
||
test_dir_name = '.picopy_writetest'
|
||
test_dir = _smb_conn(t, f'{dest_root}/{test_dir_name}' if dest_root else test_dir_name)
|
||
# 1. Verbindung prüfen
|
||
r = _rclone('lsd', root, timeout=15)
|
||
if r.returncode != 0:
|
||
err = r.stderr.strip().splitlines()[-1] if r.stderr.strip() else 'Verbindung fehlgeschlagen'
|
||
return False, f'Verbindung: {err}'
|
||
# 2. Zielordner und Schreibzugriff prüfen: Ziel anlegen, Testverzeichnis anlegen + sofort löschen
|
||
mk = _rclone('mkdir', dest, timeout=15)
|
||
if mk.returncode != 0:
|
||
err = mk.stderr.strip().splitlines()[-1] if mk.stderr.strip() else 'Zielordner konnte nicht angelegt werden'
|
||
return False, f'Zielordner: {err}'
|
||
rw = _rclone('mkdir', test_dir, timeout=15)
|
||
if rw.returncode != 0:
|
||
err = rw.stderr.strip().splitlines()[-1] if rw.stderr.strip() else 'Schreiben fehlgeschlagen'
|
||
return False, f'Kein Schreibzugriff: {err}'
|
||
_rclone('rmdir', test_dir, timeout=10)
|
||
return True, ''
|
||
|
||
|
||
def run_uploads(local_dir: Path, cfg: dict, upload_files=None):
|
||
"""Lädt die zuletzt lokal geschriebenen Dateien zu allen aktiven Fernzielen hoch."""
|
||
# Frische Config laden damit zwischenzeitliche Änderungen (z.B. Deaktivierung) berücksichtigt werden
|
||
current_cfg = load_cfg()
|
||
targets = [t for t in current_cfg.get('upload_targets', []) if t.get('enabled')]
|
||
if not targets:
|
||
return
|
||
|
||
with upload_lock:
|
||
upload_state.update(running=True, results=[], current='',
|
||
progress=0, total=0, done=0,
|
||
bytes_total=0, bytes_done=0,
|
||
current_file='', eta_sec=None, speed_bps=0)
|
||
|
||
for t in targets:
|
||
name = t.get('name', t['id'])
|
||
with upload_lock:
|
||
upload_state.update(current=name, progress=0, total=0, done=0,
|
||
bytes_total=0, bytes_done=0,
|
||
current_file='', eta_sec=None, speed_bps=0)
|
||
|
||
add_log(f'Upload >> {name}...')
|
||
dest_root = t.get('dest_path', 'PiCopy').strip('/')
|
||
root = _smb_conn(t)
|
||
# local_dir ist der lokal erzeugte Datumsordner. Auf dem NAS soll die
|
||
# gleiche Struktur entstehen wie auf dem Ziellaufwerk: Ziel/Datum/...
|
||
dest_rel = _join_remote_path(dest_root, local_dir.name)
|
||
dest = _smb_conn(t, dest_rel)
|
||
share = t.get('smb_share', '')
|
||
dest_label = _join_remote_path(share, dest_rel) or '/'
|
||
add_log(f'Upload {name}: Ziel {dest_label}')
|
||
|
||
# Quellverzeichnis prüfen
|
||
if not local_dir.exists():
|
||
err = f'Quellverzeichnis nicht gefunden: {local_dir}'
|
||
add_log(f'Upload {name}: ✗ {err}')
|
||
with upload_lock:
|
||
upload_state['results'].append({'name': name, 'ok': False, 'msg': err})
|
||
continue
|
||
|
||
# 1. Verbindung prüfen
|
||
conn = _rclone('lsd', root, timeout=15)
|
||
add_log(f'Upload {name}: Verbindung rc={conn.returncode}')
|
||
if conn.returncode != 0:
|
||
err = (conn.stderr.strip().splitlines()[-1] if conn.stderr.strip()
|
||
else 'NAS nicht erreichbar')
|
||
add_log(f'Upload {name}: ✗ {err}')
|
||
with upload_lock:
|
||
upload_state['results'].append({'name': name, 'ok': False, 'msg': err})
|
||
continue
|
||
|
||
# 2. Zielordner anlegen
|
||
mk = _rclone('mkdir', dest, timeout=30)
|
||
add_log(f'Upload {name}: mkdir rc={mk.returncode}'
|
||
+ (f' err={mk.stderr.strip()[:100]}' if mk.returncode != 0 else ''))
|
||
|
||
# 3. Kopieren mit Fortschritt
|
||
add_log(f'Upload {name}: starte copy von {local_dir}')
|
||
dup_mode = cfg.get('duplicate_handling', 'skip')
|
||
if upload_files is None:
|
||
files = sorted(f for f in local_dir.rglob('*') if f.is_file())
|
||
else:
|
||
files = []
|
||
for f in upload_files:
|
||
f = Path(f)
|
||
try:
|
||
f.relative_to(local_dir)
|
||
except ValueError:
|
||
continue
|
||
if f.is_file():
|
||
files.append(f)
|
||
files = sorted(files)
|
||
dirs = sorted({p for f in files for p in f.relative_to(local_dir).parents
|
||
if str(p) != '.'})
|
||
bytes_total = sum(f.stat().st_size for f in files)
|
||
with upload_lock:
|
||
upload_state.update(total=len(files), bytes_total=bytes_total,
|
||
progress=100 if not files else 0)
|
||
|
||
for d in dirs:
|
||
_rclone('mkdir', _smb_conn(t, _join_remote_path(dest_rel, d.as_posix())), timeout=30)
|
||
|
||
errors = []
|
||
skipped = 0
|
||
start_ts = time.time()
|
||
for idx, f in enumerate(files, start=1):
|
||
rel = f.relative_to(local_dir).as_posix()
|
||
fsize = f.stat().st_size
|
||
remote_rel = _join_remote_path(dest_rel, rel)
|
||
with upload_lock:
|
||
upload_state.update(done=idx, current_file=rel,
|
||
progress=int(idx / len(files) * 100) if files else 100)
|
||
|
||
if dup_mode == 'skip':
|
||
remote_size = _remote_size(_smb_conn(t, remote_rel))
|
||
if remote_size == fsize:
|
||
skipped += 1
|
||
with upload_lock:
|
||
bd = upload_state['bytes_done'] + fsize
|
||
elapsed = time.time() - start_ts
|
||
speed = bd / elapsed if elapsed > 1 else 0
|
||
eta = int((bytes_total - bd) / speed) if speed > 0 and bytes_total > bd else 0
|
||
upload_state.update(bytes_done=bd,
|
||
progress=int(bd / bytes_total * 100) if bytes_total else 100,
|
||
speed_bps=int(speed), eta_sec=eta)
|
||
continue
|
||
elif dup_mode == 'rename':
|
||
remote_rel = _remote_unique_rel_path(t, remote_rel)
|
||
|
||
with upload_lock:
|
||
base_done = upload_state['bytes_done']
|
||
rr = _rclone_copyto_progress(f, _smb_conn(t, remote_rel),
|
||
base_done, fsize, bytes_total, start_ts)
|
||
if rr.returncode != 0:
|
||
errors.append(rr.stderr.strip() or f'{rel}: unbekannter Fehler')
|
||
if len(errors) >= 5:
|
||
break
|
||
|
||
with upload_lock:
|
||
bd = base_done + fsize
|
||
elapsed = time.time() - start_ts
|
||
speed = bd / elapsed if elapsed > 1 else 0
|
||
eta = int((bytes_total - bd) / speed) if speed > 0 and bytes_total > bd else 0
|
||
upload_state.update(bytes_done=bd,
|
||
progress=int(bd / bytes_total * 100) if bytes_total else 100,
|
||
speed_bps=int(speed), eta_sec=eta)
|
||
|
||
r = subprocess.CompletedProcess(
|
||
args=['rclone', 'copyto'],
|
||
returncode=1 if errors else 0,
|
||
stdout='',
|
||
stderr='\n'.join(errors),
|
||
)
|
||
ok = r.returncode == 0
|
||
err = ''
|
||
if not ok:
|
||
err = r.stderr.strip() or 'Unbekannter Fehler'
|
||
add_log(f'Upload {name}: rclone stderr: {err[:300]}')
|
||
elif skipped:
|
||
add_log(f'Upload {name}: {skipped} Dateien übersprungen')
|
||
|
||
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'] = ''
|
||
upload_state['current_file'] = ''
|
||
|
||
|
||
# -- Flask Routes --------------------------------------------------------------
|
||
|
||
@app.route('/')
|
||
def index():
|
||
return HTML.replace('__PICOPY_VERSION__', VERSION)
|
||
|
||
@app.route('/logo.png')
|
||
def r_logo():
|
||
logo = Path(__file__).with_name('PiCopy_Logo.png')
|
||
if not logo.exists():
|
||
try:
|
||
data = _urlreq.urlopen(f'{RAW_BASE}/PiCopy_Logo.png', timeout=15).read()
|
||
logo.write_bytes(data)
|
||
except Exception:
|
||
return '', 404
|
||
return send_file(logo, mimetype='image/png')
|
||
|
||
@app.route('/favicon.ico')
|
||
def r_favicon():
|
||
logo = Path(__file__).with_name('PiCopy_Logo.png')
|
||
if logo.exists():
|
||
return send_file(logo, mimetype='image/png')
|
||
return '', 404
|
||
|
||
@app.route('/api/devices')
|
||
def r_devices():
|
||
return jsonify(usb_devices())
|
||
|
||
@app.route('/api/storage-info')
|
||
def r_storage_info():
|
||
cfg = load_cfg()
|
||
devs = usb_devices()
|
||
result = []
|
||
|
||
def _du_for_dev(dev):
|
||
mp, owned = ensure_mount(dev)
|
||
if not mp:
|
||
return dict(mounted=False, total=None, used=None, free=None, pct=None)
|
||
try:
|
||
du = shutil.disk_usage(mp)
|
||
return dict(mounted=True, total=du.total, used=du.used, free=du.free,
|
||
pct=round(du.used / du.total * 100) if du.total else 0)
|
||
except Exception:
|
||
return dict(mounted=False, total=None, used=None, free=None, pct=None)
|
||
finally:
|
||
if owned:
|
||
subprocess.run(['umount', mp], capture_output=True)
|
||
|
||
for sp in _resolve_source_ports(cfg):
|
||
dev = next((d for d in devs if d['usb_port'] == sp['port']), None)
|
||
entry = dict(type='source', label=sp.get('label') or f"Port {sp['port']}",
|
||
port=sp['port'], mounted=False,
|
||
total=None, used=None, free=None, pct=None)
|
||
if dev:
|
||
entry.update(_du_for_dev(dev))
|
||
result.append(entry)
|
||
|
||
if cfg.get('dest_type') == 'internal':
|
||
entry = dict(type='dest',
|
||
label=cfg.get('internal_dest_label') or 'Interner Speicher',
|
||
port='__internal__')
|
||
entry.update(_du_for_dev({'internal': True}))
|
||
result.append(entry)
|
||
elif cfg.get('dest_port'):
|
||
dev = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None)
|
||
entry = dict(type='dest', label=cfg.get('dest_label') or f"Port {cfg['dest_port']}",
|
||
port=cfg['dest_port'], mounted=False,
|
||
total=None, used=None, free=None, pct=None)
|
||
if dev:
|
||
entry.update(_du_for_dev(dev))
|
||
result.append(entry)
|
||
|
||
return jsonify(result)
|
||
|
||
@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/config/ports/reset', methods=['POST'])
|
||
def r_ports_reset():
|
||
cfg = load_cfg()
|
||
cfg['source_ports'] = []
|
||
cfg['source_port'] = None
|
||
cfg['source_label'] = ''
|
||
cfg['dest_port'] = None
|
||
cfg['dest_label'] = ''
|
||
cfg['dest_type'] = 'usb'
|
||
save_cfg(cfg)
|
||
return jsonify(ok=True)
|
||
|
||
@app.route('/api/history')
|
||
def r_history():
|
||
return jsonify(load_history())
|
||
|
||
@app.route('/api/history', methods=['DELETE'])
|
||
def r_history_clear():
|
||
try:
|
||
HISTORY_FILE.write_text('[]', encoding='utf-8')
|
||
except Exception:
|
||
pass
|
||
return jsonify(ok=True)
|
||
|
||
@app.route('/api/sysinfo')
|
||
def r_sysinfo():
|
||
return jsonify(get_sysinfo())
|
||
|
||
@app.route('/api/status')
|
||
def r_status():
|
||
with copy_lock:
|
||
cs = dict(copy_state)
|
||
with wifi_lock:
|
||
ws = dict(wifi_state)
|
||
with wg_lock:
|
||
wgs = dict(wg_state)
|
||
share = internal_share_update_state()
|
||
return jsonify(copy=cs, wifi=ws, vpn=wgs, internal_share=share)
|
||
|
||
@app.route('/api/copy/start', methods=['POST'])
|
||
def r_start():
|
||
global _copy_thread
|
||
with copy_lock:
|
||
if copy_state['running']:
|
||
return jsonify(error='Bereits aktiv'), 400
|
||
if _copy_thread is not None and _copy_thread.is_alive():
|
||
return jsonify(error='Abbruch wird noch abgeschlossen - bitte kurz warten und erneut versuchen.'), 400
|
||
cfg = load_cfg()
|
||
devs = usb_devices()
|
||
body = request.get_json(force=True) or {}
|
||
wanted_ports = body.get('ports') # None = alle konfigurierten Quellen
|
||
src_ports = _resolve_source_ports(cfg)
|
||
srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports]
|
||
srcs = [s for s in srcs if s is not None]
|
||
if wanted_ports is not None:
|
||
srcs = [s for s in srcs if s['usb_port'] in wanted_ports]
|
||
if not srcs: return jsonify(error='Keine Quellgeräte gefunden (Ports nicht verbunden)'), 400
|
||
dst = _configured_destination(cfg, devs)
|
||
if not dst: return jsonify(error='Zielgerät nicht gefunden'), 400
|
||
_copy_thread = threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True)
|
||
_copy_thread.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/internal-share/status')
|
||
def r_internal_share_status():
|
||
return jsonify(internal_share_update_state())
|
||
|
||
|
||
@app.route('/api/internal-share', methods=['POST'])
|
||
def r_internal_share_set():
|
||
data = request.get_json(force=True) or {}
|
||
enabled = bool(data.get('enabled'))
|
||
ok, err = set_internal_share_enabled(enabled)
|
||
if not ok:
|
||
return jsonify(error=err), 500
|
||
return jsonify(ok=True, status=internal_share_update_state())
|
||
|
||
@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))
|
||
|
||
|
||
# -- WireGuard Routes ---------------------------------------------------------
|
||
|
||
@app.route('/api/wireguard/config', methods=['GET', 'POST'])
|
||
def r_wg_config():
|
||
if request.method == 'POST':
|
||
data = request.get_json(force=True)
|
||
content = data.get('content', '')
|
||
if not content.strip():
|
||
return jsonify(error='Konfiguration ist leer'), 400
|
||
ok, err = wg_save_config(content)
|
||
if not ok:
|
||
return jsonify(error=err), 500
|
||
auto = data.get('auto')
|
||
if auto is not None:
|
||
c = load_cfg()
|
||
c['wireguard_auto'] = bool(auto)
|
||
save_cfg(c)
|
||
with wg_lock:
|
||
wg_state['has_config'] = True
|
||
return jsonify(ok=True)
|
||
if WG_CONF.exists():
|
||
content = WG_CONF.read_text(encoding='utf-8')
|
||
masked = re.sub(r'(PrivateKey\s*=\s*)(.+)', r'\1****', content)
|
||
return jsonify(exists=True, config=masked)
|
||
return jsonify(exists=False, config='')
|
||
|
||
|
||
@app.route('/api/wireguard/connect', methods=['POST'])
|
||
def r_wg_connect():
|
||
threading.Thread(target=wg_connect, daemon=True).start()
|
||
return jsonify(ok=True, msg='Verbindungsversuch gestartet')
|
||
|
||
|
||
@app.route('/api/wireguard/disconnect', methods=['POST'])
|
||
def r_wg_disconnect():
|
||
ok = wg_disconnect()
|
||
return jsonify(ok=ok)
|
||
|
||
|
||
@app.route('/api/wireguard/install', methods=['POST'])
|
||
def r_wg_install():
|
||
with wg_lock:
|
||
if wg_state['pkg_running']:
|
||
return jsonify(error='Bereits aktiv'), 400
|
||
threading.Thread(target=wg_install, daemon=True).start()
|
||
return jsonify(ok=True)
|
||
|
||
|
||
@app.route('/api/wireguard/uninstall', methods=['POST'])
|
||
def r_wg_uninstall():
|
||
with wg_lock:
|
||
if wg_state['pkg_running']:
|
||
return jsonify(error='Bereits aktiv'), 400
|
||
threading.Thread(target=wg_uninstall, daemon=True).start()
|
||
return jsonify(ok=True)
|
||
|
||
|
||
# -- 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
|
||
|
||
# Credentials direkt im Entry speichern (für Connection-String bei Upload)
|
||
obscured_pw = _rclone_obscure(data.get('pass', '')) if data.get('pass') else ''
|
||
entry = {
|
||
'id': tid, 'type': ctype,
|
||
'name': data.get('name', ctype),
|
||
'dest_path': data.get('dest_path', 'PiCopy'),
|
||
'enabled': True,
|
||
'smb_host': data.get('host', ''),
|
||
'smb_share': data.get('share', ''),
|
||
'smb_user': data.get('user', ''),
|
||
'smb_pass': obscured_pw,
|
||
}
|
||
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/browse', methods=['POST'])
|
||
def r_upload_browse():
|
||
"""Listet SMB-Freigaben eines Servers ohne gespeicherte Config (rclone connection string)."""
|
||
data = request.get_json(force=True)
|
||
host = data.get('host', '').strip()
|
||
user = data.get('user', '').strip()
|
||
pw = data.get('pass', '')
|
||
if not host:
|
||
return jsonify(error='Server-Adresse fehlt'), 400
|
||
conn = f':smb,host={host}'
|
||
if user:
|
||
conn += f',user={user}'
|
||
if pw:
|
||
try:
|
||
obscured = _rclone_obscure(pw)
|
||
conn += f',pass={obscured}'
|
||
except Exception:
|
||
pass
|
||
conn += ':'
|
||
r = subprocess.run(
|
||
['rclone', '--config', str(RCLONE_CONF), 'lsd', conn],
|
||
capture_output=True, text=True, timeout=15
|
||
)
|
||
if r.returncode != 0:
|
||
lines = r.stderr.strip().splitlines()
|
||
err = lines[-1] if lines else 'Verbindung fehlgeschlagen'
|
||
return jsonify(error=err), 400
|
||
shares = [line.strip().split()[-1] for line in r.stdout.splitlines() if line.strip()]
|
||
return jsonify(shares=shares)
|
||
|
||
|
||
@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):
|
||
try:
|
||
ok, err = test_remote(tid)
|
||
except Exception as e:
|
||
log.exception('upload test failed')
|
||
ok, err = False, str(e)
|
||
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):
|
||
if dev.get('internal'):
|
||
INTERNAL_DEST_DIR.mkdir(parents=True, exist_ok=True)
|
||
return str(INTERNAL_DEST_DIR)
|
||
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 = internal_dest_device(load_cfg()) if port == '__internal__' else None
|
||
if dev is None:
|
||
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(5) # 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)
|
||
|
||
|
||
FORMAT_FILESYSTEMS = {
|
||
'exfat': {
|
||
'label': 'exFAT',
|
||
'desc': 'Empfohlen – Mac & Windows, keine 4-GB-Dateigrößenbeschränkung',
|
||
'cmd': lambda dev, name: ['mkfs.exfat', '-n', name, dev],
|
||
'pkg': 'exfatprogs',
|
||
},
|
||
'fat32': {
|
||
'label': 'FAT32',
|
||
'desc': 'Mac & Windows, max. 4 GB pro Datei',
|
||
'cmd': lambda dev, name: ['mkfs.vfat', '-F', '32', '-n', name[:11], dev],
|
||
'pkg': 'dosfstools',
|
||
},
|
||
'ntfs': {
|
||
'label': 'NTFS',
|
||
'desc': 'Windows nativ, Mac nur lesen',
|
||
'cmd': lambda dev, name: ['mkfs.ntfs', '-f', '-L', name[:32], dev],
|
||
'pkg': 'ntfs-3g',
|
||
},
|
||
}
|
||
|
||
format_state = {'running': False, 'error': None, 'done': False, 'fs': '', 'device': ''}
|
||
|
||
@app.route('/api/format/status')
|
||
def r_format_status():
|
||
return jsonify(dict(format_state))
|
||
|
||
@app.route('/api/format', methods=['POST'])
|
||
def r_format():
|
||
if format_state['running']:
|
||
return jsonify(error='Formatierung läuft bereits'), 409
|
||
if copy_state.get('running'):
|
||
return jsonify(error='Kopiervorgang läuft – bitte warten'), 409
|
||
|
||
body = request.get_json(force=True)
|
||
fs = body.get('fs', '').lower()
|
||
name = (body.get('name') or 'PICOPY').upper()
|
||
dev = body.get('device', '')
|
||
|
||
if fs not in FORMAT_FILESYSTEMS:
|
||
return jsonify(error=f'Unbekanntes Dateisystem: {fs}'), 400
|
||
if not dev.startswith('/dev/'):
|
||
return jsonify(error='Ungültiges Gerät'), 400
|
||
|
||
# Sicherheitscheck: Gerät muss ein bekanntes USB-Gerät sein
|
||
known = [d['device'] for d in usb_devices()]
|
||
if dev not in known:
|
||
return jsonify(error='Gerät nicht als USB-Laufwerk erkannt'), 400
|
||
|
||
def do_format():
|
||
format_state.update(running=True, error=None, done=False, fs=fs, device=dev)
|
||
try:
|
||
# Aushängen falls gemountet
|
||
subprocess.run(['umount', dev], capture_output=True)
|
||
|
||
cmd = FORMAT_FILESYSTEMS[fs]['cmd'](dev, name)
|
||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||
if r.returncode != 0:
|
||
err = r.stderr.strip() or r.stdout.strip() or 'Unbekannter Fehler'
|
||
# Hilfreiche Meldung wenn Paket fehlt
|
||
pkg = FORMAT_FILESYSTEMS[fs]['pkg']
|
||
if 'not found' in err or r.returncode == 127:
|
||
err = f'Befehl nicht gefunden – bitte installieren: apt install {pkg}'
|
||
format_state.update(error=err)
|
||
return
|
||
format_state.update(done=True)
|
||
log.info(f'Formatierung {fs} auf {dev} abgeschlossen')
|
||
except subprocess.TimeoutExpired:
|
||
format_state.update(error='Timeout – Formatierung dauerte zu lange')
|
||
except Exception as e:
|
||
format_state.update(error=str(e))
|
||
finally:
|
||
format_state['running'] = False
|
||
|
||
threading.Thread(target=do_format, daemon=True).start()
|
||
return jsonify(ok=True)
|
||
|
||
|
||
@app.route('/api/system/reboot', methods=['POST'])
|
||
def r_system_reboot():
|
||
threading.Thread(target=lambda: (
|
||
__import__('time').sleep(1),
|
||
subprocess.Popen(['reboot'])
|
||
), 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')
|
||
|
||
logo_req = _urlreq.urlopen(f'{RAW_BASE}/PiCopy_Logo.png', timeout=30)
|
||
logo_data = logo_req.read()
|
||
|
||
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')
|
||
ltmp = Path('/opt/picopy/PiCopy_Logo.png.tmp')
|
||
ltmp.write_bytes(logo_data)
|
||
with open(ltmp, 'rb') as fh:
|
||
os.fsync(fh.fileno())
|
||
os.replace(str(ltmp), '/opt/picopy/PiCopy_Logo.png')
|
||
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-img{height:28px;width:auto;object-fit:contain}
|
||
.topbar-right{margin-left:auto;display:flex;align-items:center;gap:.5rem;min-width:0;overflow:hidden}
|
||
.topbar-wifi{display:flex;align-items:center;gap:.6rem;font-size:.82rem;background:var(--surf);border:1px solid var(--brd);border-radius:9999px;padding:.3rem .75rem;white-space:nowrap;min-width:0}
|
||
.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)}.flash.warn{color:#f4a332}
|
||
|
||
/* -- 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}
|
||
.qr-box{display:none;margin-top:.75rem;padding:.65rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem}
|
||
.qr-row{display:flex;gap:.75rem;align-items:center}
|
||
.qr-row canvas{width:112px;height:112px;background:#fff;border-radius:.35rem;padding:.35rem;flex-shrink:0}
|
||
.qr-title{font-size:.83rem;font-weight:700}
|
||
.qr-url{font-family:ui-monospace,monospace;font-size:.76rem;color:var(--acc);margin-top:.2rem;word-break:break-all}
|
||
.qr-sub{font-size:.72rem;color:var(--sub);margin-top:.25rem;line-height:1.4}
|
||
@media(max-width:430px){.qr-row{align-items:flex-start}.qr-row canvas{width:96px;height:96px}}
|
||
|
||
/* -- 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:stretch;gap:.4rem;padding:.45rem .8rem;background:var(--surf2);border-bottom:1px solid var(--brd);flex-shrink:0}
|
||
.etab{display:inline-flex;align-items:center;padding:0 .6rem;border-radius:.35rem;font-size:.76rem;font-weight:600;cursor:pointer;border:1px solid transparent;color:var(--sub);transition:.15s;white-space:nowrap}
|
||
.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;display:inline-flex;align-items:center;background:transparent;border:1px solid transparent;color:var(--sub);border-radius:.35rem;padding:0 .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}
|
||
.si-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:.6rem;margin-bottom:.6rem}
|
||
.si-item{background:var(--bg2);border-radius:.45rem;padding:.55rem .7rem}
|
||
.si-label{font-size:.7rem;color:var(--sub);text-transform:uppercase;letter-spacing:.04em;margin-bottom:.2rem}
|
||
.si-val{font-size:1.05rem;font-weight:700;color:var(--txt)}
|
||
.si-sub{font-size:.7rem;color:var(--sub);margin-top:.1rem}
|
||
.si-bar{height:4px;background:var(--brd);border-radius:9999px;margin-top:.35rem;overflow:hidden}
|
||
.si-fill{height:100%;border-radius:9999px;transition:width .5s}
|
||
.si-fill.ok{background:var(--grn2)}.si-fill.warn{background:var(--ylw)}.si-fill.hot{background:var(--red)}
|
||
.hist-table{width:100%;border-collapse:collapse;font-size:.8rem}
|
||
.hist-table th{text-align:left;padding:.35rem .6rem;color:var(--sub);font-weight:600;font-size:.72rem;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--brd);white-space:nowrap}
|
||
.hist-table td{padding:.42rem .6rem;border-bottom:1px solid var(--brd);vertical-align:middle}
|
||
.hist-table tr:last-child td{border-bottom:none}
|
||
.hist-table tr:hover td{background:var(--bg2)}
|
||
.hist-ok{color:var(--grn);font-weight:700}.hist-err{color:var(--red);font-weight:700}
|
||
.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}
|
||
|
||
/* -- WireGuard VPN -- */
|
||
.wdot.vpn{background:var(--pur);box-shadow:0 0 6px var(--pur)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Topbar -->
|
||
<header class="topbar">
|
||
<div class="logo">
|
||
<img src="/logo.png" alt="PiCopy" class="logo-img">
|
||
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-right">
|
||
<div class="topbar-wifi">
|
||
<div class="wdot d" id="wdot"></div>
|
||
<span id="wifi-label">Verbinde...</span>
|
||
<span id="wifi-ip"></span>
|
||
</div>
|
||
<div id="vpn-pill" class="topbar-wifi" style="display:none">
|
||
<div class="wdot d" id="vpn-dot"></div>
|
||
<span id="vpn-label" style="font-weight:600;color:var(--txt)">VPN</span>
|
||
<span id="vpn-ip" style="color:var(--sub);font-family:monospace;font-size:.76rem"></span>
|
||
</div>
|
||
</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-prog" style="display:none;margin-top:.45rem">
|
||
<div class="prog-track"><div class="prog-fill" id="upload-fill" style="width:0%"></div></div>
|
||
<div class="meta-row">
|
||
<span class="pill acc" id="upload-pct"></span>
|
||
<span class="pill" id="upload-files"></span>
|
||
<span class="pill" id="upload-bytes"></span>
|
||
<span class="pill acc" id="upload-eta" style="display:none"></span>
|
||
<span class="pill" id="upload-speed" style="display:none"></span>
|
||
</div>
|
||
<div id="upload-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="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 id="copy-hint" class="flash" style="display:none"></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>
|
||
<button class="btn sm ghost" style="margin-left:auto" onclick="toggleStoragePanel()">💾 Speicher</button>
|
||
<button class="btn sm ghost danger" style="margin-left:.4rem" onclick="resetPorts()">↻ Ports zurücksetzen</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="pex-grid">
|
||
|
||
<!-- Quelle + Ziel in eigenem gleichbreiten Grid -->
|
||
<div class="port-pair">
|
||
|
||
<!-- Quellen (dynamisch) -->
|
||
<div id="slot-src">
|
||
<div id="sources-list"></div>
|
||
<div style="margin-top:.6rem">
|
||
<div class="role-tag src" style="margin-bottom:.5rem">+ Quelle hinzufügen</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>
|
||
<div class="field">
|
||
<label>Bezeichnung</label>
|
||
<input type="text" id="src-label" placeholder="z.B. Kamera 1 / linker Port">
|
||
</div>
|
||
<button class="btn grn" style="width:100%" onclick="addSource()">+ Quelle hinzufügen</button>
|
||
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
|
||
<div class="hint-box">Gerät einstecken → aus Liste wählen → Hinzufügen. Mehrere Quellen werden nacheinander auf dasselbe Ziel kopiert.</div>
|
||
</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. Zielplatte oder Interner Speicher">
|
||
</div>
|
||
<div class="field">
|
||
<label>Zieltyp</label>
|
||
<select id="dst-type" onchange="onDestTypeChange()">
|
||
<option value="usb">USB-Laufwerk</option>
|
||
<option value="internal">Interner Speicher vom Raspberry Pi</option>
|
||
</select>
|
||
</div>
|
||
<div class="field" id="dst-usb-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>
|
||
<div style="display:flex;gap:.5rem">
|
||
<button class="btn pri" style="flex:1" onclick="assignPort('dest')">✓ Als festes Ziel speichern</button>
|
||
<button class="btn" id="fmt-toggle-btn" style="flex:0 0 auto;display:none" onclick="toggleFmtBox()" title="Laufwerk formatieren">🔢 Formatieren</button>
|
||
</div>
|
||
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
|
||
<div class="hint-box" id="dst-hint">Gerät in den gewünschten Port → aus Liste wählen → Speichern. Ab dann wird dieser Port immer als Ziel verwendet.</div>
|
||
|
||
<!-- Formatieren -->
|
||
<div id="fmt-box" style="display:none;margin-top:.75rem;padding:.7rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem">
|
||
<div style="font-weight:700;font-size:.83rem;margin-bottom:.55rem">🔢 Laufwerk formatieren</div>
|
||
<div class="field">
|
||
<label>Dateisystem</label>
|
||
<select id="fmt-fs">
|
||
<option value="exfat">exFAT – empfohlen (Mac & Windows, keine 4-GB-Grenze)</option>
|
||
<option value="fat32">FAT32 – Mac & Windows, max. 4 GB pro Datei</option>
|
||
<option value="ntfs">NTFS – Windows nativ, Mac nur lesen</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>Bezeichnung (Volume-Name)</label>
|
||
<input type="text" id="fmt-name" value="PICOPY" maxlength="32" style="text-transform:uppercase">
|
||
</div>
|
||
<div style="background:rgba(248,113,113,.08);border:1px solid rgba(248,113,113,.35);border-radius:.4rem;padding:.45rem .6rem;font-size:.75rem;color:var(--red);margin-bottom:.55rem">
|
||
⚠ <strong>Achtung:</strong> Alle Daten auf dem Laufwerk werden unwiderruflich gelöscht!
|
||
</div>
|
||
<button class="btn" style="width:100%;background:rgba(248,113,113,.15);border-color:var(--red);color:var(--red)" onclick="startFormat()">Jetzt formatieren</button>
|
||
<div id="fmt-flash" class="flash" style="margin-top:.4rem"></div>
|
||
</div>
|
||
|
||
<div id="internal-share-box" style="display:none;margin-top:.75rem;padding:.65rem .75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:.5rem">
|
||
<div style="display:flex;align-items:center;gap:.55rem;justify-content:space-between">
|
||
<div style="min-width:0">
|
||
<div style="font-weight:700;font-size:.83rem">SMB-Freigabe</div>
|
||
<div id="internal-share-detail" style="font-size:.72rem;color:var(--sub);margin-top:.15rem"></div>
|
||
</div>
|
||
<button class="btn sm" id="internal-share-btn" onclick="toggleInternalShare()">Freigeben</button>
|
||
</div>
|
||
<div id="internal-share-flash" class="flash" style="margin-top:.35rem"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /port-pair -->
|
||
|
||
<!-- File Explorer -->
|
||
<div class="expl-wrap">
|
||
<div class="expl-bar">
|
||
<div id="src-tabs" style="display:contents"></div>
|
||
<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>
|
||
|
||
<!-- Speicher-Panel -->
|
||
<div id="storage-panel" style="display:none;margin-top:.85rem;padding:.75rem;background:var(--bg2);border:1px solid var(--brd);border-radius:var(--r)">
|
||
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.65rem">
|
||
<span style="font-size:.82rem;font-weight:700;color:var(--txt)">💾 Speicherübersicht</span>
|
||
<button class="btn sm ghost" style="margin-left:auto" onclick="loadStorageInfo()">↻ Aktualisieren</button>
|
||
</div>
|
||
<div id="storage-list" style="display:flex;flex-direction:column;gap:.55rem">
|
||
<div style="color:var(--sub);font-size:.82rem">Wird geladen…</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">
|
||
|
||
<!-- Schritt 1: Verbindungsdaten -->
|
||
<div id="ut-step1">
|
||
<div class="sec" style="margin-top:0">Schritt 1 – Server-Verbindung</div>
|
||
<div class="field"><label>Server (IP / Hostname)</label>
|
||
<input type="text" id="ut-host" placeholder="192.168.1.100 oder nas.local" autocomplete="off"></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||
<div class="field"><label>Benutzer</label>
|
||
<input type="text" id="ut-user" placeholder="(leer = anonym)" autocomplete="off"></div>
|
||
<div class="field"><label>Passwort</label>
|
||
<input type="password" id="ut-pass" placeholder="(leer = kein Passwort)" autocomplete="off"></div>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn pri" id="ut-connect-btn" onclick="utConnect()">🔗 Verbinden & Freigaben laden</button>
|
||
<button class="btn ghost" onclick="utToggleForm()">Abbrechen</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schritt 2: Freigabe wählen (nach erfolgreicher Verbindung) -->
|
||
<div id="ut-step2" style="display:none">
|
||
<div class="sec" style="margin-top:0">Schritt 2 – Freigabe & Details</div>
|
||
<div class="field"><label>Freigabe wählen</label>
|
||
<select id="ut-share-sel" style="width:100%"></select></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem">
|
||
<div class="field"><label>Name (Anzeigename)</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" placeholder="PiCopy" value="PiCopy"></div>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn pri" onclick="utSave()">✓ Speichern & Verbindung testen</button>
|
||
<button class="btn ghost" onclick="utBack()">← Zurück</button>
|
||
</div>
|
||
</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" data-tab="tc" onclick="swTab('tc','ta')">Heimnetz</div>
|
||
<div class="tab" data-tab="ta" 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 id="hotspot-qr-box" class="qr-box">
|
||
<div class="qr-row">
|
||
<canvas id="hotspot-qr" width="116" height="116"></canvas>
|
||
<div>
|
||
<div class="qr-title">Direkt öffnen</div>
|
||
<div class="qr-url" id="hotspot-qr-url">http://10.42.0.1:8080</div>
|
||
<div class="qr-sub">Im PiCopy-Hotspot mit dem Handy scannen und die Oberfläche öffnen.</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
<div style="display:flex;gap:.4rem">
|
||
<input type="password" id="ap-pw" placeholder="PiCopy," style="flex:1">
|
||
<button type="button" class="btn sm ghost" id="ap-pw-toggle" onclick="togglePwVis('ap-pw','ap-pw-toggle')" style="flex-shrink:0;line-height:0"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="12" rx="6" ry="4"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg></button>
|
||
</div>
|
||
</div>
|
||
<button class="btn pri" onclick="saveAP()">✓ Speichern & Neustart</button>
|
||
<div id="ap-flash" class="flash" style="margin-top:.4rem"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- -- WireGuard VPN -- -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div class="card-icon pur">⚿</div>
|
||
<span class="card-title">WireGuard VPN</span>
|
||
<span class="card-sub" id="wg-status-sub"></span>
|
||
</div>
|
||
<div class="card-body">
|
||
|
||
<!-- Paket nicht installiert -->
|
||
<div id="wg-not-installed" style="display:none">
|
||
<div style="display:flex;align-items:center;gap:.75rem;padding:.75rem .9rem;background:rgba(251,191,36,.07);border:1px solid rgba(251,191,36,.28);border-radius:.5rem;margin-bottom:.6rem">
|
||
<span style="font-size:1.3rem;flex-shrink:0">📦</span>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:.87rem;color:var(--ylw)">WireGuard nicht installiert</div>
|
||
<div style="font-size:.76rem;color:var(--sub);margin-top:.15rem">wireguard + wireguard-tools + openresolv werden per apt-get installiert</div>
|
||
</div>
|
||
<button class="btn pri" onclick="wgInstall()" style="flex-shrink:0">Installieren</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Paketoperation läuft -->
|
||
<div id="wg-pkg-progress" style="display:none">
|
||
<div style="display:flex;align-items:center;gap:.75rem;padding:.75rem .9rem;background:rgba(79,142,247,.07);border:1px solid rgba(79,142,247,.25);border-radius:.5rem;margin-bottom:.6rem">
|
||
<span style="font-size:1.3rem;flex-shrink:0" id="wg-pkg-icon">⏳</span>
|
||
<div>
|
||
<div style="font-weight:600;font-size:.87rem" id="wg-pkg-title">Installiere WireGuard...</div>
|
||
<div style="font-size:.76rem;color:var(--sub);margin-top:.1rem">apt-get läuft – bitte warten (bis 60 s)</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Hauptbereich (wenn installiert) -->
|
||
<div id="wg-installed-ui" style="display:none">
|
||
|
||
<!-- Verbindungsstatus -->
|
||
<div style="display:flex;align-items:center;gap:.75rem;margin-bottom:.85rem;padding:.65rem .85rem;background:var(--bg2);border-radius:.5rem;border:1px solid var(--brd)">
|
||
<div id="wg-dot" class="wdot d"></div>
|
||
<div style="flex:1;min-width:0">
|
||
<div id="wg-label" style="font-weight:600;font-size:.87rem">Getrennt</div>
|
||
<div id="wg-detail" style="font-size:.74rem;color:var(--sub);font-family:monospace;margin-top:.1rem"></div>
|
||
</div>
|
||
<button id="wg-btn-connect" class="btn grn sm" onclick="wgConnect()" style="display:none">⚿ Verbinden</button>
|
||
<button id="wg-btn-disconnect" class="btn danger sm" onclick="wgDisconnect()" style="display:none">✕ Trennen</button>
|
||
</div>
|
||
|
||
<!-- Konfiguration -->
|
||
<div class="sec" style="margin-top:0">Konfiguration</div>
|
||
<div style="font-size:.8rem;color:var(--sub);margin-bottom:.65rem;line-height:1.5">
|
||
WireGuard .conf einfügen - wird als <code style="background:var(--bg2);padding:.1rem .3rem;border-radius:.25rem">/etc/wireguard/picopy.conf</code> gespeichert (Permissions 600). Der private Schlüssel wird maskiert angezeigt.
|
||
</div>
|
||
<div class="field">
|
||
<label>WireGuard Konfiguration (.conf)</label>
|
||
<textarea id="wg-config" rows="9" placeholder="[Interface] PrivateKey = ... Address = 10.x.x.x/32 DNS = ... [Peer] PublicKey = ... Endpoint = mein-nas.dyndns.org:51820 AllowedIPs = 192.168.1.0/24" style="font-family:ui-monospace,monospace;font-size:.77rem;line-height:1.6"></textarea>
|
||
</div>
|
||
<label class="tog"><input type="checkbox" id="wg-auto"><span>Beim Start automatisch verbinden</span></label>
|
||
<div class="btn-row">
|
||
<button class="btn pri" onclick="wgSaveConfig()">✓ Konfiguration speichern</button>
|
||
<button class="btn danger" onclick="wgUninstall()" style="margin-left:auto" title="wireguard-Paket entfernen">✕ Deinstallieren</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="wg-flash" class="flash" style="margin-top:.4rem"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- -- System -- -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div class="card-icon" style="background:rgba(255,180,60,.1);color:#f4a332">⚙</div>
|
||
<span class="card-title">System</span>
|
||
</div>
|
||
<div class="card-body" style="display:flex;flex-direction:column;gap:.6rem">
|
||
<div class="si-grid" id="sysinfo-grid">
|
||
<div class="si-item">
|
||
<div class="si-label">CPU-Temp</div>
|
||
<div class="si-val" id="si-temp">--</div>
|
||
<div class="si-sub" id="si-temp-sub"> </div>
|
||
</div>
|
||
<div class="si-item">
|
||
<div class="si-label">RAM</div>
|
||
<div class="si-val" id="si-ram">--</div>
|
||
<div class="si-bar"><div class="si-fill ok" id="si-ram-bar" style="width:0%"></div></div>
|
||
</div>
|
||
<div class="si-item">
|
||
<div class="si-label">SD-Karte</div>
|
||
<div class="si-val" id="si-disk">--</div>
|
||
<div class="si-bar"><div class="si-fill ok" id="si-disk-bar" style="width:0%"></div></div>
|
||
</div>
|
||
</div>
|
||
<button class="btn" style="width:100%" onclick="checkUpdate()">🔍 Nach Update suchen</button>
|
||
<div id="sys-update-flash" class="flash" style="display:none"></div>
|
||
<button class="btn" style="width:100%;background:rgba(220,60,60,.12);color:#e05555;border-color:rgba(220,60,60,.25)" onclick="rebootDevice()">↺ Gerät neu starten</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- -- Kopier-Verlauf -- -->
|
||
<div class="card col2">
|
||
<div class="card-head">
|
||
<div class="card-icon" style="background:rgba(79,142,247,.1);color:var(--acc)">📋</div>
|
||
<span class="card-title">Kopier-Verlauf</span>
|
||
<button class="btn sm ghost danger" style="margin-left:auto" onclick="clearHistory()">✕ Löschen</button>
|
||
</div>
|
||
<div class="card-body" style="padding:.5rem .75rem">
|
||
<div id="history-wrap"><div class="expl-empty" style="padding:.75rem 0">Noch keine Kopiervorgänge gespeichert.</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- -- Logs -- -->
|
||
<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">Logs</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();
|
||
};
|
||
|
||
|
||
function drawHotspotQR(){
|
||
const url='http://10.42.0.1:8080';
|
||
const c=$('hotspot-qr'); if(!c)return;
|
||
$('hotspot-qr-url').textContent=url;
|
||
drawQR(c,url);
|
||
}
|
||
|
||
function drawQR(canvas,text){
|
||
const version=2,size=25,dataCw=34,ecCw=10;
|
||
const bytes=[...new TextEncoder().encode(text)];
|
||
const bits=[];
|
||
const put=(val,len)=>{for(let i=len-1;i>=0;i--)bits.push((val>>>i)&1);};
|
||
put(4,4); put(bytes.length,8); bytes.forEach(b=>put(b,8)); put(0,Math.min(4,dataCw*8-bits.length));
|
||
while(bits.length%8)bits.push(0);
|
||
const data=[];
|
||
for(let i=0;i<bits.length;i+=8)data.push(bits.slice(i,i+8).reduce((a,b)=>a*2+b,0));
|
||
for(let p=0xec;data.length<dataCw;p=p===0xec?0x11:0xec)data.push(p);
|
||
const gfMul=(x,y)=>{let z=0;for(let i=7;i>=0;i--){z=(z<<1)^((z>>>7)*0x11d);if((y>>>i)&1)z^=x;}return z&255;};
|
||
const gen=Array(ecCw).fill(0); gen[ecCw-1]=1;
|
||
for(let i=0,root=1;i<ecCw;i++,root=gfMul(root,2)){
|
||
for(let j=0;j<ecCw;j++){
|
||
gen[j]=gfMul(gen[j],root);
|
||
if(j+1<ecCw)gen[j]^=gen[j+1];
|
||
}
|
||
}
|
||
const rem=Array(ecCw).fill(0);
|
||
data.forEach(d=>{
|
||
const factor=d^rem.shift(); rem.push(0);
|
||
for(let i=0;i<ecCw;i++)rem[i]^=gfMul(gen[i],factor);
|
||
});
|
||
const code=data.concat(rem);
|
||
const m=Array.from({length:size},()=>Array(size).fill(null));
|
||
const set=(x,y,v)=>{if(x>=0&&y>=0&&x<size&&y<size)m[y][x]=v;};
|
||
const finder=(x,y)=>{
|
||
for(let dy=-1;dy<=7;dy++)for(let dx=-1;dx<=7;dx++){
|
||
const xx=x+dx,yy=y+dy;
|
||
const on=(dx>=0&&dx<=6&&dy>=0&&dy<=6&&(dx===0||dx===6||dy===0||dy===6||(dx>=2&&dx<=4&&dy>=2&&dy<=4)));
|
||
set(xx,yy,on?1:0);
|
||
}
|
||
};
|
||
finder(0,0); finder(size-7,0); finder(0,size-7);
|
||
for(let i=8;i<size-8;i++){set(i,6,i%2?0:1);set(6,i,i%2?0:1);}
|
||
for(let y=16;y<=20;y++)for(let x=16;x<=20;x++)set(x,y,(x===16||x===20||y===16||y===20||(x===18&&y===18))?1:0);
|
||
set(8,size-8,1);
|
||
for(let i=0;i<9;i++){if(m[8][i]===null)set(8,i,0);if(m[i][8]===null)set(i,8,0);}
|
||
for(let i=0;i<8;i++){if(m[8][size-1-i]===null)set(8,size-1-i,0);if(m[size-1-i][8]===null)set(size-1-i,8,0);}
|
||
const dataBits=code.flatMap(b=>Array.from({length:8},(_,i)=>(b>>>(7-i))&1));
|
||
let bi=0,up=true;
|
||
for(let x=size-1;x>0;x-=2){
|
||
if(x===6)x--;
|
||
for(let yi=0;yi<size;yi++){
|
||
const y=up?size-1-yi:yi;
|
||
for(let dx=0;dx<2;dx++){
|
||
const xx=x-dx;
|
||
if(m[y][xx]!==null)continue;
|
||
const mask=(x+y)%2===0;
|
||
m[y][xx]=(dataBits[bi++]||0)^(mask?1:0);
|
||
}
|
||
}
|
||
up=!up;
|
||
}
|
||
const fmt=qrFormatBits(1,0);
|
||
for(let i=0;i<=5;i++)set(8,i,(fmt>>i)&1); set(8,7,(fmt>>6)&1); set(8,8,(fmt>>7)&1); set(7,8,(fmt>>8)&1);
|
||
for(let i=9;i<15;i++)set(14-i,8,(fmt>>i)&1);
|
||
for(let i=0;i<8;i++)set(size-1-i,8,(fmt>>i)&1);
|
||
for(let i=8;i<15;i++)set(8,size-15+i,(fmt>>i)&1);
|
||
const ctx=canvas.getContext('2d'),scale=Math.floor(canvas.width/(size+8)),off=Math.floor((canvas.width-scale*size)/2);
|
||
ctx.fillStyle='#fff';ctx.fillRect(0,0,canvas.width,canvas.height);
|
||
ctx.fillStyle='#0a0f1e';
|
||
for(let y=0;y<size;y++)for(let x=0;x<size;x++)if(m[y][x])ctx.fillRect(off+x*scale,off+y*scale,scale,scale);
|
||
}
|
||
function qrFormatBits(ecl,mask){
|
||
let data=(ecl<<3)|mask, rem=data;
|
||
for(let i=0;i<10;i++)rem=(rem<<1)^(((rem>>>9)&1)?0x537:0);
|
||
return ((data<<10)|rem)^0x5412;
|
||
}
|
||
|
||
// -- Tabs ---------------------------------------------------------------------
|
||
function swTab(show,hide){
|
||
$(show).classList.add('on'); $(hide).classList.remove('on');
|
||
document.querySelectorAll('.tab').forEach(tab=>
|
||
tab.classList.toggle('on', tab.dataset.tab===show)
|
||
);
|
||
}
|
||
|
||
// -- Port Slots ----------------------------------------------------------------
|
||
async function refreshDevices(){
|
||
devs = await api('/devices');
|
||
renderSources();
|
||
renderSlot('dst', cfg.dest_port, cfg.dest_label);
|
||
renderUnassigned();
|
||
populateSel();
|
||
}
|
||
|
||
let selectedPortSet = new Set();
|
||
|
||
function renderSources(){
|
||
const ports = cfg.source_ports || [];
|
||
$('sources-list').innerHTML = ports.map((sp, i) => {
|
||
const dev = devs.find(d => d.usb_port === sp.port);
|
||
const info = dev
|
||
? (dev.label||dev.device) + (dev.size ? ' | '+dev.size : '')
|
||
: 'Gerät nicht verbunden';
|
||
const chk = selectedPortSet.has(sp.port) ? 'checked' : '';
|
||
return `<div class="port-slot ${dev?'src-on':''}" style="margin-bottom:.5rem">
|
||
<div class="role-tag src">▲ Quelle ${i+1}${sp.label?' – '+sp.label:''}</div>
|
||
<div class="port-display">
|
||
<div class="dot ${dev?'on':'off'}"></div>
|
||
<div style="min-width:0">
|
||
<div class="port-path">Port ${sp.port}</div>
|
||
<div class="port-info">${info}</div>
|
||
</div>
|
||
<label style="margin-left:auto;display:flex;align-items:center;gap:.3rem;font-size:.76rem;cursor:pointer;flex-shrink:0;white-space:nowrap">
|
||
<input type="checkbox" ${chk} onchange="toggleSrc('${sp.port}',this.checked)">
|
||
${'Kopieren'}
|
||
</label>
|
||
<button class="btn sm danger" style="margin-left:.4rem;flex-shrink:0"
|
||
onclick="removeSource('${sp.port}')">✕</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('') + (ports.length === 0
|
||
? '<div style="color:var(--sub);font-size:.83rem;margin-bottom:.5rem">'+'Noch keine Quelle konfiguriert.'+'</div>'
|
||
: '');
|
||
renderExplorerTabs();
|
||
}
|
||
|
||
function toggleSrc(port, on){
|
||
if(on) selectedPortSet.add(port); else selectedPortSet.delete(port);
|
||
}
|
||
|
||
function renderExplorerTabs(){
|
||
const ports = cfg.source_ports || [];
|
||
let tabs = ports.map((sp, i) => {
|
||
const r = 'src_'+i;
|
||
const label = sp.label || ('Quelle '+(i+1));
|
||
return `<button class="etab ${expl.role===r?'on':''}" id="etab-${r}"
|
||
onclick="expl.switchRole('${r}')">▲ ${label}</button>`;
|
||
}).join('');
|
||
if((cfg.dest_type||'usb')==='internal'){
|
||
tabs += `<button class="etab ${expl.role==='dst'?'on':''}" id="etab-dst"
|
||
onclick="expl.switchRole('dst')">▼ Intern</button>`;
|
||
}
|
||
$('src-tabs').innerHTML = tabs;
|
||
// Fallback: falls aktive Rolle nicht mehr existiert
|
||
if(expl.role!=='dst' && !ports.some((_,i)=>expl.role==='src_'+i)){
|
||
expl.role = ports.length>0 ? 'src_0' : 'dst';
|
||
}
|
||
}
|
||
|
||
function renderSlot(r, port, label){
|
||
if(r==='dst' && (cfg.dest_type||'usb')==='internal'){
|
||
const dot=$('dst-dot'), pp=$('dst-port-path'), pi=$('dst-dev-info');
|
||
const sl=$('slot-dst'), lb=$('dst-label');
|
||
sl.classList.add('dst-on');
|
||
dot.className='dot on';
|
||
pp.textContent='Interner Speicher';
|
||
pi.textContent=(label||cfg.internal_dest_label||'Interner Speicher')+' | /opt/picopy/internal';
|
||
if(lb && !lb.dataset.dirty) lb.value=label||cfg.internal_dest_label||'Interner Speicher';
|
||
return;
|
||
}
|
||
const 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('dst-on', !!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 srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
|
||
const mkOpts = filter => devs.filter(filter)
|
||
.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} - ${d.label||d.device} (${d.size})</option>`)
|
||
.join('');
|
||
const blank = v => `<option value="">- ${v} -</option>`;
|
||
|
||
const srcEl=$('src-select'), srcPrev=srcEl.value;
|
||
srcEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
|
||
+ mkOpts(d => !srcSet.has(d.usb_port) && ((cfg.dest_type||'usb')==='internal' || !cfg.dest_port || d.usb_port !== cfg.dest_port));
|
||
if(srcPrev && devs.find(d=>d.usb_port===srcPrev)) srcEl.value=srcPrev;
|
||
|
||
const dstEl=$('dst-select'), dstPrev=dstEl.value;
|
||
dstEl.innerHTML = blank('Gerät einstecken, dann hier wählen')
|
||
+ mkOpts(d => !srcSet.has(d.usb_port));
|
||
if(dstPrev && devs.find(d=>d.usb_port===dstPrev)) dstEl.value=dstPrev;
|
||
dstEl.onchange=updateFmtToggleBtn;
|
||
updateFmtToggleBtn();
|
||
}
|
||
|
||
function onDestTypeChange(markDirty=true){
|
||
const type=$('dst-type').value;
|
||
$('dst-usb-field').style.display=type==='internal'?'none':'';
|
||
$('internal-share-box').style.display=type==='internal'?'block':'none';
|
||
$('dst-hint').textContent=type==='internal'
|
||
? 'Kopiert auf /opt/picopy/internal. Die Daten können optional als SMB-Freigabe im Netzwerk bereitgestellt werden.'
|
||
: 'Gerät in den gewünschten Port → aus Liste wählen → Speichern. Ab dann wird dieser Port immer als Ziel verwendet.';
|
||
if(type==='internal' && !$('dst-label').value) $('dst-label').value='Interner Speicher';
|
||
if(markDirty) $('dst-label').dataset.dirty='1';
|
||
updateInternalShareBox();
|
||
renderSlot('dst',cfg.dest_port,cfg.dest_label);
|
||
renderExplorerTabs();
|
||
updateFmtToggleBtn();
|
||
if(type==='internal'){$('fmt-box').style.display='none';}
|
||
}
|
||
|
||
function renderUnassigned(){
|
||
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
|
||
const list=devs.filter(d=>!srcSet.has(d.usb_port)&&((cfg.dest_type||'usb')==='internal'||!cfg.dest_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 addSource(){
|
||
const port=$('src-select').value, label=$('src-label').value.trim();
|
||
if(!port){flash('src-flash','err','Bitte zuerst ein Gerät wählen.');return;}
|
||
if((cfg.dest_type||'usb')!=='internal' && port===cfg.dest_port){flash('src-flash','err','Port bereits als Ziel konfiguriert!');return;}
|
||
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash('src-flash','err','Port bereits als Quelle hinzugefügt!');return;}
|
||
cfg.source_ports = [...(cfg.source_ports||[]), {port, label}];
|
||
selectedPortSet.add(port);
|
||
await api('/config','POST',cfg);
|
||
$('src-label').value='';
|
||
flash('src-flash','ok','✓ '+'Quelle Port ${p} hinzugefügt.'.replace('${p}',port));
|
||
renderSources(); populateSel(); renderUnassigned();
|
||
}
|
||
|
||
async function resetPorts(){
|
||
if(!confirm('Alle Port-Zuweisungen (Quellen & Ziel) zurücksetzen?'))return;
|
||
await api('/config/ports/reset','POST');
|
||
cfg.source_ports=[]; cfg.dest_port=null; cfg.dest_label=''; cfg.dest_type='usb';
|
||
selectedPortSet.clear();
|
||
renderSources(); renderSlot('dst',null,''); populateSel(); renderUnassigned();
|
||
renderExplorerTabs(); expl.role='dst'; expl.load('');
|
||
}
|
||
|
||
async function removeSource(port){
|
||
cfg.source_ports = (cfg.source_ports||[]).filter(sp=>sp.port!==port);
|
||
selectedPortSet.delete(port);
|
||
await api('/config','POST',cfg);
|
||
renderSources(); populateSel(); renderUnassigned();
|
||
}
|
||
|
||
async function assignPort(role){
|
||
const sid='dst-select', lid='dst-label';
|
||
const fid='dst-flash', pk='dest_port', lk='dest_label';
|
||
const type=$('dst-type').value;
|
||
const port=$(sid).value, label=$(lid).value.trim();
|
||
if(type==='internal'){
|
||
cfg.dest_type='internal';
|
||
cfg.internal_dest_label=label||'Interner Speicher';
|
||
cfg[lk]=cfg.internal_dest_label;
|
||
$(lid).dataset.dirty='';
|
||
await api('/config','POST',cfg);
|
||
flash(fid,'ok','✓ '+'Interner Speicher als Ziel gespeichert.');
|
||
renderSlot('dst',cfg.dest_port,cfg.dest_label);
|
||
renderExplorerTabs(); expl.reload();
|
||
return;
|
||
}
|
||
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
|
||
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err','Port bereits als Quelle konfiguriert!');return;}
|
||
cfg.dest_type='usb';
|
||
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
|
||
await api('/config','POST',cfg);
|
||
flash(fid,'ok','✓ '+'Port ${p} als Ziel gespeichert.'.replace('${p}',port));
|
||
renderSlot('dst',cfg.dest_port,cfg.dest_label);
|
||
populateSel(); renderUnassigned();
|
||
}
|
||
['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 ports=[...(cfg.source_ports||[]).map(sp=>sp.port).filter(p=>selectedPortSet.has(p))];
|
||
if(!ports.length){flash('copy-hint','warn','Keine Quelle ausgewählt – bitte mindestens eine Quelle anhaken.');return;}
|
||
const r=await api('/copy/start','POST',{ports});
|
||
if(r.error) flash('copy-hint','warn',r.error);
|
||
else $('copy-hint').style.display='none';
|
||
}
|
||
async function cancelCopy(){ await api('/copy/cancel','POST'); }
|
||
|
||
// -- Config --------------------------------------------------------------------
|
||
async function loadCfg(){
|
||
cfg=await api('/config');
|
||
// Migration: altes source_port-Feld -> source_ports-Array
|
||
if(!cfg.source_ports) cfg.source_ports=[];
|
||
if(cfg.source_ports.length===0 && cfg.source_port)
|
||
cfg.source_ports=[{port:cfg.source_port, label:cfg.source_label||''}];
|
||
// Alle konfigurierten Quellen standardmäßig ausgewählt
|
||
selectedPortSet = new Set(cfg.source_ports.map(sp=>sp.port));
|
||
$('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';
|
||
$('ap-pw').value=cfg.ap_password||'';
|
||
$('dst-type').value=cfg.dest_type||'usb';
|
||
onDestTypeChange(false);
|
||
}
|
||
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.textContent='Gespeichert!'; 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();}
|
||
function togglePwVis(inputId, btnId){
|
||
const inp=$(inputId), btn=$(btnId);
|
||
const show = inp.type==='password';
|
||
inp.type = show ? 'text' : 'password';
|
||
const eye='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="12" rx="6" ry="4"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||
const eyeOff='<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><ellipse cx="12" cy="12" rx="6" ry="4"/><line x1="3" y1="3" x2="21" y2="21"/></svg>';
|
||
btn.innerHTML = show ? eyeOff : eye;
|
||
}
|
||
|
||
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 --------------------------------------------------------------
|
||
let utTargets=[], _utConn={};
|
||
|
||
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.smb_share||'?')}${t.dest_path?'/'+t.dest_path:''}</div>
|
||
</div>
|
||
<div class="ut-acts">
|
||
<button class="btn sm ghost" id="ut-test-${t.id}" onclick="utTest('${t.id}')">🔍 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 id="ut-test-result-${t.id}" style="display:none;font-size:.76rem;margin-top:.35rem;padding:.3rem .5rem;border-radius:.35rem"></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':'+ NAS-Ziel hinzufügen';
|
||
if(show){
|
||
$('ut-step1').style.display=''; $('ut-step2').style.display='none';
|
||
['ut-host','ut-user','ut-pass','ut-name'].forEach(id=>{$(id).value='';});
|
||
$('ut-dest').value='PiCopy';
|
||
$('ut-form-flash').style.display='none';
|
||
_utConn={};
|
||
}
|
||
}
|
||
async function utConnect(){
|
||
const host=$('ut-host').value.trim();
|
||
if(!host){flash('ut-form-flash','err','Server-Adresse fehlt');return;}
|
||
const btn=$('ut-connect-btn');
|
||
btn.disabled=true; btn.textContent='Verbinde...';
|
||
$('ut-form-flash').style.display='none';
|
||
const r=await api('/upload/browse','POST',{
|
||
host, user:$('ut-user').value.trim(), pass:$('ut-pass').value
|
||
});
|
||
btn.disabled=false; btn.innerHTML='🔗 Verbinden & Freigaben laden';
|
||
if(r.error){flash('ut-form-flash','err','✗ '+r.error);return;}
|
||
if(!r.shares||!r.shares.length){flash('ut-form-flash','warn','Verbunden, aber keine Freigaben gefunden');return;}
|
||
_utConn={host, user:$('ut-user').value.trim(), pass:$('ut-pass').value};
|
||
$('ut-share-sel').innerHTML=r.shares.map(s=>`<option value="${s}">${s}</option>`).join('');
|
||
if(!$('ut-name').value) $('ut-name').value=host;
|
||
$('ut-step1').style.display='none'; $('ut-step2').style.display='';
|
||
}
|
||
function utBack(){
|
||
$('ut-step1').style.display=''; $('ut-step2').style.display='none';
|
||
$('ut-form-flash').style.display='none';
|
||
}
|
||
async function utSave(){
|
||
const name=$('ut-name').value.trim(), dest=$('ut-dest').value.trim()||'PiCopy';
|
||
const share=$('ut-share-sel').value;
|
||
if(!name){flash('ut-form-flash','err','Name fehlt');return;}
|
||
if(!share){flash('ut-form-flash','err','Bitte eine Freigabe wählen');return;}
|
||
const body={type:'smb',name,dest_path:dest,share,
|
||
host:_utConn.host, user:_utConn.user, pass:_utConn.pass};
|
||
flash('ut-form-flash','warn','Speichere...');
|
||
const r=await api('/upload/targets','POST',body);
|
||
if(r.error){flash('ut-form-flash','err',r.error);return;}
|
||
flash('ut-form-flash','warn','Teste Verbindung – Schreibzugriff wird geprüft...');
|
||
try{
|
||
const tr=await api('/upload/targets/'+r.id+'/test','POST');
|
||
if(tr.ok){flash('ut-form-flash','ok','✓ Verbindung OK – Lesen & Schreiben erfolgreich');utToggleForm();await loadUTs();}
|
||
else flash('ut-form-flash','err','✗ '+(tr.error||'✗ Test fehlgeschlagen (Server-Timeout)'.slice(2)));
|
||
}catch(e){flash('ut-form-flash','err','✗ Test fehlgeschlagen (Server-Timeout)');}
|
||
}
|
||
async function utTest(id){
|
||
const btn=$('ut-test-'+id), res=$('ut-test-result-'+id);
|
||
btn.disabled=true; btn.textContent='Teste...';
|
||
res.style.display='none';
|
||
const r=await api('/upload/targets/'+id+'/test','POST');
|
||
btn.disabled=false; btn.innerHTML='🔍 Test';
|
||
res.style.display='block';
|
||
if(r.ok){
|
||
res.style.background='rgba(52,211,153,.12)'; res.style.color='var(--grn)';
|
||
res.textContent='✓ Verbindung OK – Lesen & Schreiben erfolgreich';
|
||
} else {
|
||
res.style.background='rgba(248,113,113,.1)'; res.style.color='var(--red)';
|
||
res.textContent='✗ ' + (r.error||'✗ Test fehlgeschlagen (Server-Timeout)'.slice(2));
|
||
}
|
||
}
|
||
async function utToggle(id){await api('/upload/targets/'+id+'/toggle','POST');await loadUTs();}
|
||
async function utDel(id,name){
|
||
if(!confirm('"${n}" wirklich löschen?'.replace('${n}',name)))return;
|
||
await api('/upload/targets/'+id,'DELETE');await loadUTs();
|
||
}
|
||
|
||
async function updateInternalShareBox(state=null){
|
||
if(!$('internal-share-box'))return;
|
||
const s=state||await api('/internal-share/status');
|
||
const btn=$('internal-share-btn'), detail=$('internal-share-detail');
|
||
const free=s.free!=null?fmtBytes(s.free)+' frei':'';
|
||
if(s.pkg_running){
|
||
btn.disabled=true; btn.textContent='Installiere...';
|
||
detail.textContent='Samba wird installiert. '+free;
|
||
return;
|
||
}
|
||
btn.disabled=false;
|
||
btn.textContent=s.enabled?'Freigabe stoppen':'Freigeben';
|
||
const status=s.enabled
|
||
? ((s.active?'Aktiv':'Konfiguriert')+' | \\\\'+(location.hostname||'picopy')+'\\PiCopy')
|
||
: 'Nicht freigegeben';
|
||
detail.textContent=status+(free?' | '+free:'');
|
||
}
|
||
|
||
async function toggleInternalShare(){
|
||
const current=await api('/internal-share/status');
|
||
const enable=!current.enabled;
|
||
if(enable && !current.installed){
|
||
if(!confirm('Samba installieren und /opt/picopy/internal als Netzwerkfreigabe PiCopy bereitstellen?\n\nDie Freigabe ist im Netzwerk lesbar erreichbar.'))return;
|
||
}
|
||
flash('internal-share-flash','ok',enable?'Aktiviere Freigabe...':'Deaktiviere Freigabe...');
|
||
const r=await api('/internal-share','POST',{enabled:enable});
|
||
if(r.error){flash('internal-share-flash','err',r.error);return;}
|
||
flash('internal-share-flash','ok',enable?'✓ Freigabe aktiv':'✓ Freigabe deaktiviert');
|
||
updateInternalShareBox(r.status);
|
||
}
|
||
|
||
// -- Format -------------------------------------------------------------------
|
||
let fmtPolling=null;
|
||
|
||
function toggleFmtBox(){
|
||
const box=$('fmt-box');
|
||
const visible=box.style.display!=='none';
|
||
box.style.display=visible?'none':'block';
|
||
if(!visible) $('fmt-flash').textContent='';
|
||
}
|
||
|
||
function updateFmtToggleBtn(){
|
||
const btn=$('fmt-toggle-btn');
|
||
if(!btn) return;
|
||
const sel=$('dst-select').value;
|
||
const isUsb=($('dst-type')||{}).value!=='internal';
|
||
btn.style.display=(isUsb && sel)?'':'none';
|
||
}
|
||
|
||
async function startFormat(){
|
||
const sel=$('dst-select').value;
|
||
if(!sel){flash('fmt-flash','err','Kein Gerät ausgewählt');return;}
|
||
const dev=devs.find(d=>d.usb_port===sel);
|
||
if(!dev){flash('fmt-flash','err','Gerät nicht gefunden');return;}
|
||
const fs=$('fmt-fs').value;
|
||
const name=($('fmt-name').value||'PICOPY').toUpperCase();
|
||
const fsLabel={'exfat':'exFAT','fat32':'FAT32','ntfs':'NTFS'}[fs]||fs;
|
||
if(!confirm(`Laufwerk "${dev.label||dev.device}" (${dev.size}) wirklich mit ${fsLabel} formatieren?\n\nAlle Daten werden gelöscht!`))return;
|
||
|
||
flash('fmt-flash','ok','Formatierung läuft...');
|
||
const r=await api('/format','POST',{fs,name,device:dev.device});
|
||
if(r.error){flash('fmt-flash','err',r.error);return;}
|
||
|
||
clearInterval(fmtPolling);
|
||
fmtPolling=setInterval(async()=>{
|
||
const s=await api('/format/status');
|
||
if(s.error){clearInterval(fmtPolling);flash('fmt-flash','err',s.error);return;}
|
||
if(s.done){
|
||
clearInterval(fmtPolling);
|
||
flash('fmt-flash','ok',`✓ Erfolgreich als ${fsLabel} formatiert`);
|
||
setTimeout(pollDevices,1500);
|
||
}
|
||
},800);
|
||
}
|
||
|
||
// -- File Explorer -------------------------------------------------------------
|
||
const expl={
|
||
role:'src_0', paths:{dst:''},
|
||
switchRole(r){
|
||
this.role=r;
|
||
document.querySelectorAll('.etab').forEach(t=>t.classList.remove('on'));
|
||
const tab=$('etab-'+r); if(tab) tab.classList.add('on');
|
||
this.load(this.paths[r]||'');
|
||
},
|
||
reload(){this.load(this.paths[this.role]||'');},
|
||
navigate(p){this.load(p);},
|
||
async load(path=''){
|
||
let port;
|
||
if(this.role==='dst'){
|
||
port=(cfg.dest_type||'usb')==='internal'?'__internal__':cfg.dest_port;
|
||
} else {
|
||
const idx=parseInt(this.role.replace('src_',''),10);
|
||
port=cfg.source_ports&&cfg.source_ports[idx]?cfg.source_ports[idx].port:null;
|
||
}
|
||
const body=$('expl-body'), bread=$('expl-bread');
|
||
if(!port){body.innerHTML='<div class="expl-empty">'+'Kein Port konfiguriert'+'</div>';bread.innerHTML='';return;}
|
||
const dev=port==='__internal__'
|
||
? {usb_port:'__internal__',label:'Interner Speicher',device:'internal'}
|
||
: 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+'</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||''; // role z.B. 'src_0', 'dst'
|
||
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==null)return'';
|
||
if(b===0)return'0 B';
|
||
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,vpn:v,internal_share:is}=await api('/status');
|
||
if(is) updateInternalShareBox(is);
|
||
// VPN Topbar + Card
|
||
if(v){
|
||
const vp=$('vpn-pill'),vdot=$('vpn-dot'),vl=$('vpn-label'),vi=$('vpn-ip');
|
||
const ni=$('wg-not-installed'),pp=$('wg-pkg-progress'),ui=$('wg-installed-ui');
|
||
if(v.pkg_running){
|
||
ni.style.display='none'; pp.style.display='block'; ui.style.display='none';
|
||
const act=t(v.pkg_action==='remove'?'wg.action_remove':'wg.action_install');
|
||
$('wg-pkg-title').textContent=act+' WireGuard...';
|
||
$('wg-pkg-icon').textContent='⏳';
|
||
$('wg-status-sub').textContent=act+'...';
|
||
vp.style.display='none';
|
||
} else if(!v.installed){
|
||
ni.style.display='block'; pp.style.display='none'; ui.style.display='none';
|
||
$('wg-status-sub').textContent='Nicht installiert'||'Nicht installiert';
|
||
vp.style.display='none';
|
||
if(v.pkg_error) flash('wg-flash','err',v.pkg_error);
|
||
} else {
|
||
ni.style.display='none'; pp.style.display='none'; ui.style.display='block';
|
||
const wgd=$('wg-dot'),wgl=$('wg-label'),wgdet=$('wg-detail');
|
||
const bc=$('wg-btn-connect'),bd=$('wg-btn-disconnect');
|
||
if(v.connected){
|
||
vp.style.display='flex'; vdot.className='wdot c';
|
||
vl.textContent='VPN aktiv'; vi.textContent=v.ip||'';
|
||
wgd.className='wdot c'; wgl.textContent='Verbunden';
|
||
wgdet.textContent=v.ip?(v.ip+(v.peer?' | peer ...'+v.peer.slice(-8):'')):'';
|
||
bc.style.display='none'; bd.style.display=''; bd.disabled=false;
|
||
$('wg-status-sub').textContent=v.ip||'';
|
||
} else {
|
||
vp.style.display=v.has_config?'flex':'none';
|
||
vdot.className='wdot d'; vl.textContent='VPN'; vi.textContent='';
|
||
wgd.className='wdot d'; wgl.textContent='Getrennt';
|
||
wgdet.textContent=v.error||'';
|
||
bc.style.display=v.has_config?'':'none'; bc.disabled=false; bd.style.display='none';
|
||
$('wg-status-sub').textContent=v.has_config?'Konfiguriert':'Nicht konfiguriert';
|
||
}
|
||
}
|
||
}
|
||
// 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='';}
|
||
const qrBox=$('hotspot-qr-box');
|
||
if(qrBox){qrBox.style.display=w.mode==='ap'?'block':'none'; if(w.mode==='ap')drawHotspotQR();}
|
||
// 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='none'; 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){
|
||
if(c.last_copy !== _lastHistoryTs){ _lastHistoryTs=c.last_copy; loadHistory(); }
|
||
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+'...':'';
|
||
const up=$('upload-prog'),uf=$('upload-fill');
|
||
const pct=Math.max(0,Math.min(100,u.progress||0));
|
||
if(u.running){
|
||
up.style.display='block'; uf.style.width=pct+'%';
|
||
$('upload-pct').textContent=pct+'%';
|
||
$('upload-files').textContent=(u.done||0)+' / '+(u.total||0)+' Dateien';
|
||
$('upload-bytes').textContent=fmtBytes(u.bytes_done||0)+' / '+fmtBytes(u.bytes_total||0);
|
||
const ue=fmtETA(u.eta_sec); $('upload-eta').style.display=ue?'':'none'; $('upload-eta').textContent=ue?'⏱ '+ue:'';
|
||
const us=fmtSpd(u.speed_bps); $('upload-speed').style.display=us?'':'none'; $('upload-speed').textContent=us?'⚡ '+us:'';
|
||
$('upload-file').textContent=u.current_file||'';
|
||
}else{
|
||
up.style.display='none';
|
||
}
|
||
$('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, _lastHistoryTs = 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${v} installieren?\n\nDas Web-Interface ist für ca. 10 Sekunden nicht erreichbar.'.replace('${v}',latest))) 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);
|
||
}
|
||
|
||
async function checkUpdate() {
|
||
const btn = event.currentTarget;
|
||
btn.disabled = true; btn.innerHTML = '🔍 '+'Prüfe...';
|
||
try {
|
||
await api('/update/check', 'POST');
|
||
// Warten bis der Server-Check abgeschlossen ist (max 15 s, alle 500 ms)
|
||
let u;
|
||
for (let i = 0; i < 30; i++) {
|
||
await new Promise(r => setTimeout(r, 500));
|
||
u = await api('/update/status');
|
||
if (!u.checking) break;
|
||
}
|
||
await pollUpdate(); // Badge sofort aktualisieren
|
||
const fl = $('sys-update-flash');
|
||
if (u.available && u.latest) {
|
||
fl.className = 'flash warn'; fl.textContent = 'Update v${v} verfügbar – über das Badge oben installieren.'.replace('${v}',u.latest);
|
||
} else if (u.error) {
|
||
fl.className = 'flash err'; fl.textContent = 'Fehler: ' + u.error;
|
||
} else {
|
||
fl.className = 'flash ok'; fl.textContent = 'PiCopy ist aktuell.';
|
||
}
|
||
fl.style.display = 'block';
|
||
if (fl.className.includes('ok')) setTimeout(() => fl.style.display = 'none', 3500);
|
||
} catch(e) {
|
||
const fl = $('sys-update-flash');
|
||
fl.className = 'flash err'; fl.textContent = 'Verbindungsfehler'; fl.style.display = 'block';
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = '🔍 '+'Nach Update suchen';
|
||
}
|
||
}
|
||
|
||
async function rebootDevice() {
|
||
if (!confirm('Gerät jetzt neu starten?\n\nDas Web-Interface ist für ca. 30 Sekunden nicht erreichbar.')) return;
|
||
try { await api('/system/reboot', 'POST'); } catch(e) {}
|
||
document.body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;color:#888;font-size:1rem">'+'↺ Gerät startet neu – bitte warten...'+'</div>';
|
||
setTimeout(async function waitForRestart() {
|
||
try { await fetch('/api/update/status'); location.reload(); }
|
||
catch(e) { setTimeout(waitForRestart, 2000); }
|
||
}, 10000);
|
||
}
|
||
|
||
// -- WireGuard VPN -------------------------------------------------------------
|
||
async function wgInstall(){
|
||
if(!confirm('wireguard + wireguard-tools + openresolv jetzt per apt-get installieren?\n\nDauer: ca. 30–90 Sekunden.'))return;
|
||
flash('wg-flash','ok','Starte Installation...');
|
||
const r=await api('/wireguard/install','POST');
|
||
if(r.error) flash('wg-flash','err',r.error);
|
||
}
|
||
async function wgUninstall(){
|
||
if(!confirm('WireGuard wirklich deinstallieren?\n\nDer aktive VPN-Tunnel wird vorher getrennt.\nDie Konfigurationsdatei bleibt erhalten.'))return;
|
||
flash('wg-flash','ok','Deinstalliere...');
|
||
const r=await api('/wireguard/uninstall','POST');
|
||
if(r.error) flash('wg-flash','err',r.error);
|
||
}
|
||
async function wgConnect(){
|
||
$('wg-btn-connect').disabled=true;
|
||
flash('wg-flash','ok','Verbinde VPN...');
|
||
await api('/wireguard/connect','POST');
|
||
}
|
||
async function wgDisconnect(){
|
||
$('wg-btn-disconnect').disabled=true;
|
||
flash('wg-flash','ok','Trenne VPN...');
|
||
const r=await api('/wireguard/disconnect','POST');
|
||
if(!r.ok) flash('wg-flash','err','Trennen fehlgeschlagen');
|
||
}
|
||
async function wgSaveConfig(){
|
||
const content=$('wg-config').value.trim();
|
||
if(!content){flash('wg-flash','err','Konfiguration ist leer');return;}
|
||
if(!content.includes('[Interface]')){flash('wg-flash','err','[Interface] fehlt');return;}
|
||
const auto=$('wg-auto').checked;
|
||
flash('wg-flash','ok','Speichere...');
|
||
const r=await api('/wireguard/config','POST',{content,auto});
|
||
if(r.error){flash('wg-flash','err',r.error);return;}
|
||
flash('wg-flash','ok','✓ Konfiguration gespeichert');
|
||
}
|
||
async function loadWgConfig(){
|
||
try{
|
||
const r=await api('/wireguard/config');
|
||
if(r.exists && r.config) $('wg-config').value=r.config;
|
||
const c=await api('/config');
|
||
$('wg-auto').checked=!!c.wireguard_auto;
|
||
}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);
|
||
}
|
||
|
||
// -- Sysinfo ------------------------------------------------------------------
|
||
async function pollSysinfo(){
|
||
try{
|
||
const s=await api('/sysinfo');
|
||
// CPU-Temp
|
||
const tempEl=$('si-temp'), tempSub=$('si-temp-sub');
|
||
if(s.cpu_temp!=null){
|
||
tempEl.textContent=s.cpu_temp+'°C';
|
||
const cls=s.cpu_temp>=80?'hot':s.cpu_temp>=65?'warn':'ok';
|
||
tempEl.style.color=cls==='hot'?'var(--red)':cls==='warn'?'var(--ylw)':'var(--grn)';
|
||
tempSub.textContent=s.cpu_temp>=80?'Heiß':s.cpu_temp>=65?'Warm':'Normal';
|
||
} else { tempEl.textContent='n/v'; tempSub.textContent=''; }
|
||
// RAM
|
||
if(s.ram_used!=null){
|
||
$('si-ram').textContent=s.ram_used+' / '+s.ram_total+' MB';
|
||
const rb=$('si-ram-bar'); rb.style.width=s.ram_pct+'%';
|
||
rb.className='si-fill '+(s.ram_pct>=90?'hot':s.ram_pct>=70?'warn':'ok');
|
||
}
|
||
// Disk
|
||
if(s.disk_used!=null){
|
||
$('si-disk').textContent=s.disk_used+' / '+s.disk_total+' GB';
|
||
const db=$('si-disk-bar'); db.style.width=s.disk_pct+'%';
|
||
db.className='si-fill '+(s.disk_pct>=90?'hot':s.disk_pct>=75?'warn':'ok');
|
||
}
|
||
}catch(e){}
|
||
}
|
||
|
||
// -- Speicher-Panel -----------------------------------------------------------
|
||
function fmtBytes(b){
|
||
if(b==null) return '–';
|
||
if(b<1024**3) return (b/1024**2).toFixed(1)+' MB';
|
||
return (b/1024**3).toFixed(2)+' GB';
|
||
}
|
||
let storagePanelOpen=false;
|
||
function toggleStoragePanel(){
|
||
storagePanelOpen=!storagePanelOpen;
|
||
const p=$('storage-panel');
|
||
p.style.display=storagePanelOpen?'block':'none';
|
||
if(storagePanelOpen) loadStorageInfo();
|
||
}
|
||
async function loadStorageInfo(){
|
||
const list=$('storage-list');
|
||
list.innerHTML='<div style="color:var(--sub);font-size:.82rem">Wird geladen…</div>';
|
||
try{
|
||
const items=await api('/storage-info');
|
||
if(!items||!items.length){
|
||
list.innerHTML='<div style="color:var(--sub);font-size:.82rem">Keine Geräte konfiguriert.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML=items.map(it=>{
|
||
const icon=it.type==='source'?'▲':'▼';
|
||
const typeLabel=it.type==='source'?'Quelle':'Ziel';
|
||
const color=it.type==='source'?'var(--grn)':'var(--acc)';
|
||
if(!it.mounted||it.total==null){
|
||
return `<div style="display:flex;align-items:center;gap:.55rem;padding:.4rem .5rem;background:var(--surf);border:1px solid var(--brd);border-radius:.4rem">
|
||
<span style="font-size:.8rem;color:${color}">${icon}</span>
|
||
<div style="min-width:0;flex:1">
|
||
<div style="font-size:.79rem;font-weight:600;color:var(--txt)">${it.label}</div>
|
||
<div style="font-size:.73rem;color:var(--sub)">${typeLabel} · Port ${it.port==='__internal__'?'intern':it.port} · nicht verbunden</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
const pct=it.pct||0;
|
||
const barCls=pct>=90?'hot':pct>=75?'warn':'ok';
|
||
return `<div style="padding:.45rem .5rem;background:var(--surf);border:1px solid var(--brd);border-radius:.4rem">
|
||
<div style="display:flex;align-items:center;gap:.55rem;margin-bottom:.35rem">
|
||
<span style="font-size:.8rem;color:${color}">${icon}</span>
|
||
<div style="min-width:0;flex:1">
|
||
<div style="font-size:.79rem;font-weight:600;color:var(--txt)">${it.label}</div>
|
||
<div style="font-size:.73rem;color:var(--sub)">${typeLabel} · ${fmtBytes(it.used)} belegt · ${fmtBytes(it.free)} frei · ${fmtBytes(it.total)} gesamt</div>
|
||
</div>
|
||
<span style="font-size:.76rem;font-weight:700;color:var(--${barCls==='hot'?'red':barCls==='warn'?'ylw':'grn'});flex-shrink:0">${pct}%</span>
|
||
</div>
|
||
<div class="si-bar" style="margin:0"><div class="si-fill ${barCls}" style="width:${pct}%"></div></div>
|
||
</div>`;
|
||
}).join('');
|
||
}catch(e){
|
||
list.innerHTML='<div style="color:var(--red);font-size:.82rem">Fehler beim Laden.</div>';
|
||
}
|
||
}
|
||
|
||
// -- Kopier-Verlauf -----------------------------------------------------------
|
||
function fmtDur(s){
|
||
if(s<60) return s+'s';
|
||
const m=Math.floor(s/60), sec=s%60;
|
||
return m+'m'+(sec?sec+'s':'');
|
||
}
|
||
async function loadHistory(){
|
||
try{
|
||
const h=await api('/history');
|
||
renderHistory(h);
|
||
}catch(e){}
|
||
}
|
||
function renderHistory(h){
|
||
const w=$('history-wrap');
|
||
if(!h||!h.length){
|
||
w.innerHTML='<div class="expl-empty" style="padding:.75rem 0">Noch keine Kopiervorgänge gespeichert.</div>';
|
||
return;
|
||
}
|
||
w.innerHTML=`<table class="hist-table">
|
||
<thead><tr>
|
||
<th>Datum</th><th>Quellen</th><th>Ziel</th>
|
||
<th style="text-align:right">Dateien</th><th style="text-align:right">Größe</th>
|
||
<th style="text-align:right">Dauer</th><th>Status</th>
|
||
</tr></thead>
|
||
<tbody>${h.map(e=>{
|
||
const d=new Date(e.ts);
|
||
const date=d.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
|
||
const time=d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'});
|
||
const srcs=(e.sources||[]).join(', ')||'?';
|
||
const files=e.copied+(e.skipped?` <span style="color:var(--sub);font-size:.75em">(+${e.skipped} übersp.)</span>`:'');
|
||
const size=e.bytes>0?fmtBytes(e.bytes):'--';
|
||
const status=e.ok
|
||
? '<span class="hist-ok">✓ OK</span>'
|
||
: `<span class="hist-err" title="${(e.error||'').replace(/"/g,'"')}">✗ Fehler</span>`;
|
||
const io=e.errors?` <span style="color:var(--red);font-size:.75em">${e.errors} I/O-Err.</span>`:'';
|
||
return`<tr>
|
||
<td><span style="font-weight:600">${date}</span><br><span style="color:var(--sub);font-size:.75em">${time}</span></td>
|
||
<td>${srcs}</td>
|
||
<td style="color:var(--sub)">${e.dest||'?'}</td>
|
||
<td style="text-align:right">${files}${io}</td>
|
||
<td style="text-align:right">${size}</td>
|
||
<td style="text-align:right">${fmtDur(e.duration||0)}</td>
|
||
<td>${status}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table>`;
|
||
}
|
||
async function clearHistory(){
|
||
if(!confirm('Kopier-Verlauf wirklich löschen?'))return;
|
||
await api('/history','DELETE');
|
||
renderHistory([]);
|
||
}
|
||
|
||
(async()=>{
|
||
await loadCfg();
|
||
await refreshDevices();
|
||
await loadUTs();
|
||
await loadWgConfig();
|
||
expl.load('');
|
||
loadHistory();
|
||
pollSysinfo();
|
||
setInterval(poll,1500);
|
||
setInterval(refreshDevices,8000);
|
||
setInterval(pollUpdate,60000);
|
||
setInterval(pollSysinfo,8000);
|
||
poll();
|
||
pollUpdate();
|
||
setTimeout(pollUpdate, 8000);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
if __name__ == '__main__':
|
||
cleanup_stale_mounts()
|
||
load_state()
|
||
wg_update_state()
|
||
threading.Thread(target=usb_monitor, daemon=True).start()
|
||
threading.Thread(target=wifi_monitor, daemon=True).start()
|
||
threading.Thread(target=wg_monitor, daemon=True).start()
|
||
threading.Thread(target=update_check_loop, daemon=True).start()
|
||
if load_cfg().get('wireguard_auto') and WG_CONF.exists():
|
||
threading.Thread(target=wg_connect, 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)
|