3711 lines
155 KiB
Python
3711 lines
155 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
|
||
|
||
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)
|
||
|
||
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,
|
||
'ui_lang': 'de',
|
||
'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
|
||
|
||
# -- 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
|
||
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()
|
||
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)
|
||
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()
|
||
|
||
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."""
|
||
targets = [t for t in cfg.get('upload_targets', []) if t.get('enabled')]
|
||
if not targets:
|
||
return
|
||
|
||
with upload_lock:
|
||
upload_state.update(running=True, results=[], current='',
|
||
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('/api/devices')
|
||
def r_devices():
|
||
return jsonify(usb_devices())
|
||
|
||
@app.route('/api/config', methods=['GET', 'POST'])
|
||
def r_config():
|
||
if request.method == 'POST':
|
||
cfg = load_cfg()
|
||
cfg.update(request.get_json(force=True))
|
||
save_cfg(cfg)
|
||
return jsonify(ok=True)
|
||
return jsonify(load_cfg())
|
||
|
||
@app.route('/api/status')
|
||
def r_status():
|
||
with copy_lock:
|
||
cs = dict(copy_state)
|
||
with wifi_lock:
|
||
ws = dict(wifi_state)
|
||
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)
|
||
|
||
|
||
@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')
|
||
|
||
tmp = Path('/opt/picopy/app.py.tmp')
|
||
tmp.write_text(new_code, encoding='utf-8')
|
||
with open(tmp, 'rb') as fh:
|
||
os.fsync(fh.fileno()) # Sicherstellen dass Daten auf der Platte sind
|
||
os.replace(str(tmp), '/opt/picopy/app.py') # Atomares Umbenennen
|
||
vtmp = Path('/opt/picopy/version.txt.tmp')
|
||
vtmp.write_text(new_version + '\n', encoding='utf-8')
|
||
with open(vtmp, 'rb') as fh:
|
||
os.fsync(fh.fileno())
|
||
os.replace(str(vtmp), '/opt/picopy/version.txt')
|
||
log.info('Update installiert - starte Dienst neu...')
|
||
|
||
# Systemd startet den Dienst automatisch neu
|
||
subprocess.Popen(['systemctl', 'restart', 'picopy'])
|
||
return jsonify(ok=True)
|
||
|
||
except SyntaxError as e:
|
||
return jsonify(error=f'Update-Datei ungültig: {e}'), 500
|
||
except Exception as e:
|
||
log.exception('Update fehlgeschlagen')
|
||
return jsonify(error=str(e)), 500
|
||
|
||
|
||
# -- HTML Template -------------------------------------------------------------
|
||
|
||
HTML = r"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>PiCopy</title>
|
||
<style>
|
||
/* -- Reset & Tokens -- */
|
||
:root {
|
||
--bg: #0a0f1e;
|
||
--bg2: #111827;
|
||
--surf: #1a2235;
|
||
--surf2: #1f2a40;
|
||
--brd: #2a3650;
|
||
--brd2: #374766;
|
||
--txt: #e8edf5;
|
||
--sub: #8b9ab5;
|
||
--acc: #4f8ef7;
|
||
--acc2: #3b7de8;
|
||
--grn: #34d399;
|
||
--grn2: #10b981;
|
||
--red: #f87171;
|
||
--ylw: #fbbf24;
|
||
--pur: #a78bfa;
|
||
--r: .6rem;
|
||
--r2: .9rem;
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100vh;padding:0 0 4rem}
|
||
|
||
/* -- Topbar -- */
|
||
.topbar{background:var(--bg2);border-bottom:1px solid var(--brd);padding:.75rem 1.5rem;display:flex;align-items:center;gap:1rem;position:sticky;top:0;z-index:100;backdrop-filter:blur(8px)}
|
||
.logo{display:flex;align-items:center;gap:.55rem;font-size:1rem;font-weight:700;letter-spacing:-.02em;color:var(--txt)}
|
||
.logo-dot{width:8px;height:8px;border-radius:50%;background:var(--acc);box-shadow:0 0 8px var(--acc)}
|
||
.topbar-wifi{margin-left:auto;display:flex;align-items:center;gap:.6rem;font-size:.82rem;background:var(--surf);border:1px solid var(--brd);border-radius:9999px;padding:.3rem .75rem}
|
||
.topbar-wifi~.topbar-wifi,.topbar-wifi~.lang-toggle{margin-left:0}
|
||
.lang-toggle{display:inline-flex;border:1px solid var(--brd);border-radius:9999px;overflow:hidden;background:var(--surf);flex-shrink:0}
|
||
.lang-toggle button{border:0;background:transparent;color:var(--sub);font-size:.74rem;font-weight:700;padding:.32rem .55rem;cursor:pointer}
|
||
.lang-toggle button.on{background:var(--acc);color:#fff}
|
||
.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}
|
||
.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">
|
||
<div class="logo-dot"></div>
|
||
PiCopy
|
||
</div>
|
||
<div id="upd-badge" class="upd-badge" onclick="installUpdate()" title="Klicken zum Installieren" data-title-i18n="title.install">
|
||
↑ <span id="upd-version"></span> verfügbar
|
||
</div>
|
||
<div class="topbar-wifi">
|
||
<div class="wdot d" id="wdot"></div>
|
||
<span id="wifi-label" data-i18n="wifi.connecting">Verbinde...</span>
|
||
<span id="wifi-ip"></span>
|
||
</div>
|
||
<div class="lang-toggle" title="Language">
|
||
<button id="lang-de" onclick="setLang('de')">DE</button>
|
||
<button id="lang-en" onclick="setLang('en')">EN</button>
|
||
</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>
|
||
</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" data-title-i18n="title.dismiss" 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>
|
||
</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>
|
||
<button class="btn pri" style="width:100%" onclick="assignPort('dest')">✓ Als festes Ziel speichern</button>
|
||
<div id="dst-flash" class="flash" style="margin-top:.4rem"></div>
|
||
<div class="hint-box" 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>
|
||
|
||
<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" data-title-i18n="title.reload">↻</button>
|
||
</div>
|
||
<div class="expl-bread" id="expl-bread"></div>
|
||
<div class="expl-scroll" id="expl-body">
|
||
<div class="expl-empty">Port konfigurieren und Gerät verbinden</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Nicht zugewiesene Geräte -->
|
||
<div id="unassigned-wrap" style="display:none;margin-top:.85rem">
|
||
<div class="sec">Weitere verbundene Geräte</div>
|
||
<div id="unassigned-list" style="display:flex;flex-direction:column;gap:.35rem"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- -- Kopier-Einstellungen -- -->
|
||
<div class="card col2">
|
||
<div class="card-head">
|
||
<div class="card-icon ylw">⚙</div>
|
||
<span class="card-title">Kopier-Einstellungen</span>
|
||
</div>
|
||
<div class="card-body" style="display:grid;grid-template-columns:1fr 1fr;gap:0 2rem">
|
||
|
||
<!-- Linke Spalte: Ordner & Auto -->
|
||
<div>
|
||
<div class="sec" style="margin-top:0">Ordnerstruktur</div>
|
||
<div class="field">
|
||
<label>Datumsformat</label>
|
||
<select id="c-fmt">
|
||
<option value="%Y-%m-%d">JJJJ-MM-TT (2024-01-15)</option>
|
||
<option value="%Y%m%d">JJJJMMTT (20240115)</option>
|
||
<option value="%d-%m-%Y">TT-MM-JJJJ (15-01-2024)</option>
|
||
<option value="%Y/%m/%d">JJJJ/MM/TT (Unterordner)</option>
|
||
</select>
|
||
</div>
|
||
<label class="tog"><input type="checkbox" id="c-time"><span>Uhrzeit im Ordnernamen</span></label>
|
||
<label class="tog"><input type="checkbox" id="c-sub"><span>Unterordner pro Quelle</span></label>
|
||
<label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren</span></label>
|
||
|
||
<div class="sec">Dateifilter</div>
|
||
<div class="field">
|
||
<label>Nur diese Typen kopieren (leer = alle)</label>
|
||
<input type="text" id="c-filter" placeholder="jpg, raw, mp4, mov ...">
|
||
</div>
|
||
<div style="display:flex;gap:.35rem;flex-wrap:wrap;margin-top:-.35rem;margin-bottom:.85rem">
|
||
<button class="btn sm ghost" onclick="setFilter('jpg,jpeg,heic,raw,cr2,nef,arw,dng,png')">📷 Fotos</button>
|
||
<button class="btn sm ghost" onclick="setFilter('mp4,mov,avi,mkv,mts,m2ts,wmv')">🎬 Videos</button>
|
||
<button class="btn sm ghost" onclick="setFilter('jpg,jpeg,heic,raw,cr2,nef,arw,dng,mp4,mov,mts,m2ts')">📷+🎬</button>
|
||
<button class="btn sm ghost" onclick="setFilter('')">✕ Alle</button>
|
||
</div>
|
||
<label class="tog"><input type="checkbox" id="c-excl"><span>Systemdateien ausschließen<br><span style="font-size:.72rem;color:var(--sub)">.DS_Store, Thumbs.db, RECYCLER, System Volume Information ...</span></span></label>
|
||
</div>
|
||
|
||
<!-- Rechte Spalte: Duplikate & Sicherheit -->
|
||
<div>
|
||
<div class="sec" style="margin-top:0">Duplikate</div>
|
||
<div class="field">
|
||
<label>Wenn Zieldatei bereits existiert</label>
|
||
<select id="c-dup">
|
||
<option value="skip">Überspringen (empfohlen)</option>
|
||
<option value="overwrite">Überschreiben</option>
|
||
<option value="rename">Umbenennen (_1, _2 ...)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="sec">Integrität & Aufräumen</div>
|
||
<label class="tog" style="margin-bottom:.85rem">
|
||
<input type="checkbox" id="c-verify">
|
||
<span>Dateien nach Kopieren per MD5 verifizieren<br>
|
||
<span style="font-size:.72rem;color:var(--sub)">Stellt sicher dass jede Datei identisch ankam - dauert länger</span></span>
|
||
</label>
|
||
<label class="tog">
|
||
<input type="checkbox" id="c-delsrc">
|
||
<span style="color:var(--ylw)">⚠ Quelldateien nach Kopieren löschen<br>
|
||
<span style="font-size:.72rem;color:var(--sub)">Löscht Dateien von der Quelle nach erfolgreichem Kopieren (bei Verify: nur verifizierte)</span></span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- Speichern-Zeile über beide Spalten -->
|
||
<div style="grid-column:1/-1;margin-top:.25rem">
|
||
<div class="btn-row" style="margin-top:0">
|
||
<button class="btn pri" onclick="saveCopyCfg()">✓ Speichern</button>
|
||
<div id="copy-cfg-msg" class="flash ok" style="display:none;align-self:center">Gespeichert!</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- -- Upload-Ziele -- -->
|
||
<div class="card">
|
||
<div class="card-head">
|
||
<div class="card-icon pur">^</div>
|
||
<span class="card-title">Fernkopie - NAS / SMB</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="ut-list" style="display:flex;flex-direction:column;gap:.45rem;margin-bottom:.65rem"></div>
|
||
<button class="btn" onclick="utToggleForm()" id="ut-add-btn">+ NAS-Ziel hinzufügen</button>
|
||
|
||
<div id="ut-form" class="add-panel" style="display:none">
|
||
|
||
<!-- 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')" data-i18n="Heimnetz">Heimnetz</div>
|
||
<div class="tab" data-tab="ta" onclick="swTab('ta','tc')" data-i18n="Hotspot (AP)">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" data-title-i18n="title.scan_nets">🔍</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" data-i18n="qr.title">Direkt öffnen</div>
|
||
<div class="qr-url" id="hotspot-qr-url">http://10.42.0.1:8080</div>
|
||
<div class="qr-sub" data-i18n="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><input type="password" id="ap-pw" placeholder="PiCopy,"></div>
|
||
<button class="btn pri" onclick="saveAP()">✓ Speichern & Neustart</button>
|
||
<div id="ap-flash" class="flash" style="margin-top:.4rem"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- -- 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" data-i18n="wg.apt_running">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" data-title-i18n="title.wg_rm">✕ 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">
|
||
<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>
|
||
|
||
<!-- -- 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();
|
||
};
|
||
|
||
const I18N = {
|
||
de: {
|
||
'wifi.connecting':'Verbinde...', 'wifi.none':'Kein WLAN', 'wifi.connected':'Verbunden',
|
||
'wifi.hotspot':'Hotspot: ', 'vpn.active':'VPN aktiv', 'vpn.disconnected':'Getrennt',
|
||
'copy.ready':'Bereit', 'copy.copying':'Kopiert... ', 'copy.verify':'Verifiziere... ',
|
||
'copy.delete':'Quelle wird geleert...', 'copy.done':'✓ Abgeschlossen', 'copy.files':' Dateien',
|
||
'copy.checked':' geprüft', 'qr.title':'Direkt öffnen',
|
||
'qr.sub':'Im PiCopy-Hotspot mit dem Handy scannen und die Oberfläche öffnen.',
|
||
'share.on':'Freigabe stoppen', 'share.off':'Freigeben', 'share.inactive':'Nicht freigegeben',
|
||
'share.install':'Installiere...', 'share.installing':'Samba wird installiert. ',
|
||
'flash.saving':'Speichere...', 'flash.testing':'Teste Verbindung – Schreibzugriff wird geprüft...',
|
||
'flash.ok_test':'✓ Verbindung OK – Lesen & Schreiben erfolgreich',
|
||
'flash.fail_timeout':'✗ Test fehlgeschlagen (Server-Timeout)',
|
||
'flash.no_source':'Keine Quelle ausgewählt – bitte mindestens eine Quelle anhaken.',
|
||
'flash.no_device':'Bitte zuerst ein Gerät wählen.',
|
||
'flash.port_as_target':'Port bereits als Ziel konfiguriert!',
|
||
'flash.port_as_source':'Port bereits als Quelle konfiguriert!',
|
||
'flash.port_src_dup':'Port bereits als Quelle hinzugefügt!',
|
||
'flash.no_server':'Server-Adresse fehlt',
|
||
'flash.no_shares':'Verbunden, aber keine Freigaben gefunden',
|
||
'flash.no_name':'Name fehlt', 'flash.no_share':'Bitte eine Freigabe wählen',
|
||
'flash.wifi_connecting':'Verbinde... (bis 30s)',
|
||
'flash.wifi_started':'Gestartet. Neue IP erscheint oben.',
|
||
'flash.ap_saved':'Gespeichert! Hotspot startet neu.',
|
||
'flash.connect_btn':'Verbinde...',
|
||
'flash.src_added':'Quelle Port ${p} hinzugefügt.',
|
||
'flash.dst_saved':'Port ${p} als Ziel gespeichert.',
|
||
'flash.int_saved':'Interner Speicher als Ziel gespeichert.',
|
||
'flash.no_ssid':'Bitte SSID eingeben',
|
||
'flash.pw_short':'Min. 8 Zeichen',
|
||
'btn.testing':'Teste...', 'btn.checking':'Prüfe...',
|
||
'src.none':'Noch keine Quelle konfiguriert.',
|
||
'src.copy_cb':'Kopieren',
|
||
'ut.none':'Noch keine Fernziele konfiguriert',
|
||
'expl.no_port':'Kein Port konfiguriert', 'expl.no_dev':'Gerät nicht verbunden',
|
||
'expl.loading':'Lade...', 'expl.empty_drive':'Laufwerk leer', 'expl.empty_folder':'Ordner leer',
|
||
'expl.conn_err':'Verbindungsfehler',
|
||
'confirm.del_target':'"${n}" wirklich löschen?',
|
||
'confirm.wg_install':'wireguard + wireguard-tools + openresolv jetzt per apt-get installieren?\n\nDauer: ca. 30–90 Sekunden.',
|
||
'confirm.wg_uninstall':'WireGuard wirklich deinstallieren?\n\nDer aktive VPN-Tunnel wird vorher getrennt.\nDie Konfigurationsdatei bleibt erhalten.',
|
||
'confirm.reboot':'Gerät jetzt neu starten?\n\nDas Web-Interface ist für ca. 30 Sekunden nicht erreichbar.',
|
||
'confirm.update':'Update auf v${v} installieren?\n\nDas Web-Interface ist für ca. 10 Sekunden nicht erreichbar.',
|
||
'reboot.wait':'↺ Gerät startet neu – bitte warten...',
|
||
'wg.installing':'Starte Installation...', 'wg.removing':'Deinstalliere...',
|
||
'wg.connecting':'Verbinde VPN...', 'wg.disconnecting':'Trenne VPN...',
|
||
'wg.disc_failed':'Trennen fehlgeschlagen', 'wg.cfg_empty':'Konfiguration ist leer',
|
||
'wg.saving':'Speichere...', 'wg.saved':'✓ Konfiguration gespeichert',
|
||
'wg.invalid_cfg':'[Interface] fehlt',
|
||
'wg.action_install':'Installiere', 'wg.action_remove':'Deinstalliere',
|
||
'copy.cfg_saved':'Gespeichert!',
|
||
'copy.error_prefix':'Fehler: ',
|
||
'status.active':'Aktiv', 'status.inactive':'Inaktiv',
|
||
'status.configured':'Konfiguriert', 'status.not_cfg':'Nicht konfiguriert',
|
||
'size.free':' frei',
|
||
'confirm.samba':'Samba installieren und /opt/picopy/internal als Netzwerkfreigabe PiCopy bereitstellen?\n\nDie Freigabe ist im Netzwerk lesbar erreichbar.',
|
||
'share.activating':'Aktiviere Freigabe...', 'share.deactivating':'Deaktiviere Freigabe...',
|
||
'share.active_ok':'✓ Freigabe aktiv', 'share.deactive_ok':'✓ Freigabe deaktiviert',
|
||
'update.available':'Update v${v} verfügbar – über das Badge oben installieren.',
|
||
'update.error_prefix':'Fehler: ', 'update.current':'PiCopy ist aktuell.',
|
||
'update.installing':'↓ Installiere...',
|
||
'btn.connect_load':'🔗 Verbinden & Freigaben laden',
|
||
'ut.toggle_close':'✕ Abbrechen', 'ut.toggle_open':'+ NAS-Ziel hinzufügen',
|
||
'check_update':'Nach Update suchen',
|
||
'title.install':'Klicken zum Installieren', 'title.dismiss':'Meldung schließen',
|
||
'title.reload':'Neu laden', 'title.scan_nets':'Netzwerke suchen',
|
||
'title.wg_rm':'wireguard-Paket entfernen',
|
||
'wg.apt_running':'apt-get läuft – bitte warten (bis 60 s)',
|
||
'wg.not_installed':'Nicht installiert',
|
||
'Heimnetz':'Heimnetz',
|
||
},
|
||
en: {
|
||
'wifi.connecting':'Connecting...', 'wifi.none':'No Wi-Fi', 'wifi.connected':'Connected',
|
||
'wifi.hotspot':'Hotspot: ', 'vpn.active':'VPN active', 'vpn.disconnected':'Disconnected',
|
||
'copy.ready':'Ready', 'copy.copying':'Copying... ', 'copy.verify':'Verifying... ',
|
||
'copy.delete':'Clearing source...', 'copy.done':'✓ Complete', 'copy.files':' files',
|
||
'copy.checked':' checked', 'qr.title':'Open directly',
|
||
'qr.sub':'Scan while connected to the PiCopy hotspot to open the interface.',
|
||
'share.on':'Stop share', 'share.off':'Share', 'share.inactive':'Not shared',
|
||
'share.install':'Installing...', 'share.installing':'Installing Samba. ',
|
||
'flash.saving':'Saving...', 'flash.testing':'Testing connection – checking write access...',
|
||
'flash.ok_test':'✓ Connection OK – Read & Write successful',
|
||
'flash.fail_timeout':'✗ Test failed (server timeout)',
|
||
'flash.no_source':'No source selected – please check at least one source.',
|
||
'flash.no_device':'Please select a device first.',
|
||
'flash.port_as_target':'Port already configured as target!',
|
||
'flash.port_as_source':'Port already configured as source!',
|
||
'flash.port_src_dup':'Port already added as source!',
|
||
'flash.no_server':'Server address missing',
|
||
'flash.no_shares':'Connected, but no shares found',
|
||
'flash.no_name':'Name missing', 'flash.no_share':'Please select a share',
|
||
'flash.wifi_connecting':'Connecting... (up to 30s)',
|
||
'flash.wifi_started':'Started. New IP will appear above.',
|
||
'flash.ap_saved':'Saved! Hotspot restarting.',
|
||
'flash.connect_btn':'Connecting...',
|
||
'flash.src_added':'Source port ${p} added.',
|
||
'flash.dst_saved':'Port ${p} saved as target.',
|
||
'flash.int_saved':'Internal storage saved as target.',
|
||
'flash.no_ssid':'Please enter SSID',
|
||
'flash.pw_short':'Min. 8 characters',
|
||
'btn.testing':'Testing...', 'btn.checking':'Checking...',
|
||
'src.none':'No sources configured yet.',
|
||
'src.copy_cb':'Copy',
|
||
'ut.none':'No remote targets configured',
|
||
'expl.no_port':'No port configured', 'expl.no_dev':'Device not connected',
|
||
'expl.loading':'Loading...', 'expl.empty_drive':'Drive empty', 'expl.empty_folder':'Folder empty',
|
||
'expl.conn_err':'Connection error',
|
||
'confirm.del_target':'Really delete "${n}"?',
|
||
'confirm.wg_install':'Install wireguard + wireguard-tools + openresolv via apt-get?\n\nDuration: approx. 30–90 seconds.',
|
||
'confirm.wg_uninstall':'Really uninstall WireGuard?\n\nThe active VPN tunnel will be disconnected first.\nThe configuration file will be kept.',
|
||
'confirm.reboot':'Restart device now?\n\nThe web interface will be unavailable for approx. 30 seconds.',
|
||
'confirm.update':'Install update v${v}?\n\nThe web interface will be unavailable for approx. 10 seconds.',
|
||
'reboot.wait':'↺ Device restarting – please wait...',
|
||
'wg.installing':'Starting installation...', 'wg.removing':'Uninstalling...',
|
||
'wg.connecting':'Connecting VPN...', 'wg.disconnecting':'Disconnecting VPN...',
|
||
'wg.disc_failed':'Disconnect failed', 'wg.cfg_empty':'Configuration is empty',
|
||
'wg.saving':'Saving...', 'wg.saved':'✓ Configuration saved',
|
||
'wg.invalid_cfg':'[Interface] missing',
|
||
'wg.action_install':'Installing', 'wg.action_remove':'Uninstalling',
|
||
'copy.cfg_saved':'Saved!',
|
||
'copy.error_prefix':'Error: ',
|
||
'status.active':'Active', 'status.inactive':'Inactive',
|
||
'status.configured':'Configured', 'status.not_cfg':'Not configured',
|
||
'size.free':' free',
|
||
'confirm.samba':'Install Samba and share /opt/picopy/internal as network share PiCopy?\n\nThe share will be accessible read-only on the network.',
|
||
'share.activating':'Activating share...', 'share.deactivating':'Deactivating share...',
|
||
'share.active_ok':'✓ Share active', 'share.deactive_ok':'✓ Share deactivated',
|
||
'update.available':'Update v${v} available – install via the badge above.',
|
||
'update.error_prefix':'Error: ', 'update.current':'PiCopy is up to date.',
|
||
'update.installing':'↓ Installing...',
|
||
'btn.connect_load':'🔗 Connect & Load Shares',
|
||
'ut.toggle_close':'✕ Cancel', 'ut.toggle_open':'+ Add NAS Target',
|
||
'check_update':'Check For Update',
|
||
'title.install':'Click to install', 'title.dismiss':'Close message',
|
||
'title.reload':'Reload', 'title.scan_nets':'Scan networks',
|
||
'title.wg_rm':'Remove WireGuard package',
|
||
'wg.apt_running':'apt-get running – please wait (up to 60 s)',
|
||
'wg.not_installed':'Not installed',
|
||
'Heimnetz':'Home Network',
|
||
}
|
||
};
|
||
const STATIC_EN = {
|
||
// Card titles
|
||
'Kopierstatus':'Copy Status', 'USB Ports & Datei-Explorer':'USB Ports & File Explorer',
|
||
'Kopier-Einstellungen':'Copy Settings', 'Fernkopie - NAS / SMB':'Remote Copy - NAS / SMB',
|
||
'WiFi-Einstellungen':'Wi-Fi Settings', 'WireGuard VPN':'WireGuard VPN',
|
||
'System':'System', 'Logs':'Logs',
|
||
// Section headers
|
||
'Ordnerstruktur':'Folder Structure', 'Dateifilter':'File Filter',
|
||
'Duplikate':'Duplicates', 'Integrität & Aufräumen':'Integrity & Cleanup',
|
||
'Heimnetz':'Home Network', 'Hotspot (AP)':'Hotspot (AP)',
|
||
'Konfiguration':'Configuration', 'Fernkopie':'Remote Copy',
|
||
'Weitere verbundene Geräte':'Other Connected Devices',
|
||
'Schritt 1 – Server-Verbindung':'Step 1 – Server Connection',
|
||
'Schritt 2 – Freigabe & Details':'Step 2 – Share & Details',
|
||
// Labels
|
||
'Bezeichnung':'Label', 'Zieltyp':'Target Type', 'Datumsformat':'Date Format',
|
||
'Uhrzeit im Ordnernamen':'Add Time To Folder Name',
|
||
'Unterordner pro Quelle':'Subfolder Per Source', 'Automatisch kopieren':'Auto Copy',
|
||
'Nur diese Typen kopieren (leer = alle)':'Only These Types (empty = all)',
|
||
'Systemdateien ausschließen':'Exclude System Files',
|
||
'Wenn Zieldatei bereits existiert':'When Target File Exists',
|
||
'Dateien nach Kopieren per MD5 verifizieren':'Verify Files With MD5 After Copy',
|
||
'Quelldateien nach Kopieren löschen':'Delete Source Files After Copy',
|
||
'Netzwerk (SSID)':'Network (SSID)', 'Passwort':'Password',
|
||
'Hotspot-Name (SSID)':'Hotspot Name (SSID)', 'Passwort (min. 8 Zeichen)':'Password (min. 8 chars)',
|
||
'Port lernen - Gerät wählen':'Learn Port - Select Device',
|
||
'Beim Start automatisch verbinden':'Connect Automatically On Startup',
|
||
'WireGuard Konfiguration (.conf)':'WireGuard Configuration (.conf)',
|
||
'Server (IP / Hostname)':'Server (IP / Hostname)', 'Benutzer':'User',
|
||
'Freigabe wählen':'Select Share', 'Name (Anzeigename)':'Name (display name)',
|
||
'Ziel-Ordner auf dem NAS':'Destination Folder on NAS',
|
||
'Als festes Ziel speichern':'Save As Fixed Target', 'SMB-Freigabe':'SMB Share',
|
||
'Interner Speicher vom Raspberry Pi':'Raspberry Pi Internal Storage',
|
||
// Buttons
|
||
'Kopieren starten':'Start Copy', 'Abbrechen':'Cancel', 'Speichern':'Save',
|
||
'Speichern & Neustart':'Save & Restart', 'Verbinden & Speichern':'Connect & Save',
|
||
'NAS-Ziel hinzufügen':'Add NAS Target', 'Quelle hinzufügen':'Add Source',
|
||
'Nach Update suchen':'Check For Update', 'Gerät neu starten':'Restart Device',
|
||
'Deinstallieren':'Uninstall', 'Installieren':'Install',
|
||
'Freigeben':'Share', 'Freigabe stoppen':'Stop Share',
|
||
'Alle':'All', '✕ Alle':'✕ All', '📷 Fotos':'📷 Photos', '🎬 Videos':'🎬 Videos',
|
||
'Konfiguration speichern':'Save Configuration',
|
||
// Options
|
||
'- Gerät einstecken, dann hier wählen -':'- Insert device, then select here -',
|
||
'USB-Laufwerk':'USB Drive', 'Interner Speicher':'Internal Storage',
|
||
'Überspringen (empfohlen)':'Skip (recommended)', 'Überschreiben':'Overwrite',
|
||
'Umbenennen (_1, _2 ...)':'Rename (_1, _2 ...)',
|
||
'JJJJ-MM-TT (2024-01-15)':'YYYY-MM-DD (2024-01-15)',
|
||
'JJJJ/MM/TT (Unterordner)':'YYYY/MM/DD (Subfolder)',
|
||
'JJJJMMTT (20240115)':'YYYYMMDD (20240115)',
|
||
'TT-MM-JJJJ (15-01-2024)':'DD-MM-YYYY (15-01-2024)',
|
||
// States / info
|
||
'Kein Port konfiguriert':'No port configured', 'Gerät nicht verbunden':'Device not connected',
|
||
'Noch keine Einträge':'No entries yet',
|
||
'Port konfigurieren und Gerät verbinden':'Configure port and connect device',
|
||
'Nicht freigegeben':'Not shared',
|
||
// Role tags & misc
|
||
'▼ Ziel':'▼ Target', '⬇ Ziel':'▼ Target', 'Ziel':'Target',
|
||
'Fotos':'Photos', 'Videos':'Videos',
|
||
};
|
||
function t(k){return (I18N[cfg.ui_lang||'de']&&I18N[cfg.ui_lang||'de'][k])||I18N.de[k]||k;}
|
||
function applyLang(){
|
||
const lang=cfg.ui_lang||'de';
|
||
document.documentElement.lang=lang;
|
||
$('lang-de').classList.toggle('on',lang==='de');
|
||
$('lang-en').classList.toggle('on',lang==='en');
|
||
document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n);});
|
||
document.querySelectorAll('button,label,span,.card-title,.sec,.hint-box,.expl-empty,option').forEach(el=>{
|
||
if(el.children.length>0) return;
|
||
if(!el.dataset.deText) el.dataset.deText=el.textContent.trim();
|
||
const de=el.dataset.deText;
|
||
el.textContent=lang==='en'?(STATIC_EN[de]||de):de;
|
||
});
|
||
const ph = {
|
||
'src-label':['z.B. Kamera 1 / linker Port','e.g. Camera 1 / left port'],
|
||
'dst-label':['z.B. Zielplatte oder Interner Speicher','e.g. target drive or internal storage'],
|
||
'w-ssid':['WLAN-Name','Wi-Fi name'],
|
||
'w-pw':['WLAN-Passwort','Wi-Fi password'],
|
||
'ut-host':['192.168.1.100 oder nas.local','192.168.1.100 or nas.local'],
|
||
'ut-user':['(leer = anonym)','(empty = anonymous)'],
|
||
'ut-pass':['(leer = kein Passwort)','(empty = no password)'],
|
||
'ut-name':['z.B. Heimserver NAS','e.g. Home server NAS'],
|
||
'ut-dest':['PiCopy','PiCopy'],
|
||
'c-fmt':['%Y-%m-%d','%Y-%m-%d'],
|
||
'c-filter':['jpg, raw, mp4, mov ...','jpg, raw, mp4, mov ...'],
|
||
'ap-ssid':['PiCopy','PiCopy'],
|
||
'ap-pw':['PiCopy,','PiCopy,'],
|
||
};
|
||
Object.entries(ph).forEach(([id,v])=>{const el=$(id); if(el) el.placeholder=lang==='en'?v[1]:v[0];});
|
||
document.querySelectorAll('[data-title-i18n]').forEach(el=>{el.title=t(el.dataset.titleI18n);});
|
||
}
|
||
async function setLang(lang){
|
||
cfg.ui_lang=lang;
|
||
await api('/config','POST',cfg);
|
||
applyLang();
|
||
poll();
|
||
}
|
||
|
||
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();
|
||
applyLang();
|
||
}
|
||
|
||
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)">
|
||
${t('src.copy_cb')}
|
||
</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">'+t('src.none')+'</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=t('expl.no_dev'); }
|
||
} else {
|
||
dot.className='dot off'; pp.textContent='-'; pi.textContent=t('expl.no_port');
|
||
}
|
||
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' || 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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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')||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',t('flash.no_device'));return;}
|
||
if((cfg.dest_type||'usb')!=='internal' && port===cfg.dest_port){flash('src-flash','err',t('flash.port_as_target'));return;}
|
||
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash('src-flash','err',t('flash.port_src_dup'));return;}
|
||
cfg.source_ports = [...(cfg.source_ports||[]), {port, label}];
|
||
selectedPortSet.add(port);
|
||
await api('/config','POST',cfg);
|
||
$('src-label').value='';
|
||
flash('src-flash','ok','✓ '+t('flash.src_added').replace('${p}',port));
|
||
renderSources(); populateSel(); renderUnassigned();
|
||
}
|
||
|
||
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','✓ '+t('flash.int_saved'));
|
||
renderSlot('dst',cfg.dest_port,cfg.dest_label);
|
||
renderExplorerTabs(); expl.reload();
|
||
return;
|
||
}
|
||
if(!port){flash(fid,'err',t('flash.no_device'));return;}
|
||
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err',t('flash.port_as_source'));return;}
|
||
cfg.dest_type='usb';
|
||
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
|
||
await api('/config','POST',cfg);
|
||
flash(fid,'ok','✓ '+t('flash.dst_saved').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',t('flash.no_source'));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';
|
||
$('dst-type').value=cfg.dest_type||'usb';
|
||
onDestTypeChange(false);
|
||
applyLang();
|
||
}
|
||
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=t('copy.cfg_saved'); m.style.display='block';
|
||
setTimeout(()=>m.style.display='none',2500);
|
||
}
|
||
function setFilter(v){ $('c-filter').value=v; }
|
||
|
||
// -- WiFi ----------------------------------------------------------------------
|
||
async function scanNets(){
|
||
$('net-list').style.display='flex'; $('net-list').innerHTML='<div class="expl-empty" style="padding:.5rem">Suche...</div>';
|
||
const nets=await api('/wifi/scan');
|
||
if(!nets.length){$('net-list').innerHTML='<div class="expl-empty" style="padding:.5rem">Keine Netzwerke</div>';return;}
|
||
$('net-list').innerHTML=nets.map(n=>{
|
||
const b=n.signal>66?'▂▄▆█':n.signal>33?'▂▄▆░':'▂▄░░';
|
||
return`<div class="net-row" onclick="pickNet('${n.ssid.replace(/'/g,"\\'")}')"><span>${n.ssid}</span><span class="net-sig">${b} ${n.signal}%</span></div>`;
|
||
}).join('');
|
||
}
|
||
function pickNet(s){$('w-ssid').value=s;$('net-list').style.display='none';$('w-pw').focus();}
|
||
async function connectWifi(){
|
||
const ssid=$('w-ssid').value.trim(),pw=$('w-pw').value;
|
||
if(!ssid){flash('wifi-flash','err',t('flash.no_ssid'));return;}
|
||
flash('wifi-flash','ok',t('flash.wifi_connecting'));
|
||
const r=await api('/wifi/connect','POST',{ssid,password:pw});
|
||
if(r.error) flash('wifi-flash','err',r.error);
|
||
else flash('wifi-flash','ok',t('flash.wifi_started'));
|
||
}
|
||
async function saveAP(){
|
||
const s=$('ap-ssid').value.trim(),p=$('ap-pw').value;
|
||
if(!s){flash('ap-flash','err',t('flash.no_name'));return;}
|
||
if(p.length<8){flash('ap-flash','err',t('flash.pw_short'));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',t('flash.ap_saved'));
|
||
}
|
||
|
||
// -- Upload-Ziele --------------------------------------------------------------
|
||
let utTargets=[], _utConn={};
|
||
|
||
async function loadUTs(){utTargets=await api('/upload/targets');renderUTs();applyLang();}
|
||
function renderUTs(){
|
||
const el=$('ut-list');
|
||
if(!utTargets.length){el.innerHTML='<div class="empty">'+t('ut.none')+'</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?t('status.active'):t('status.inactive')}</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?t('ut.toggle_close'):t('ut.toggle_open');
|
||
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',t('flash.no_server'));return;}
|
||
const btn=$('ut-connect-btn');
|
||
btn.disabled=true; btn.textContent=t('flash.connect_btn');
|
||
$('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=t('btn.connect_load');
|
||
if(r.error){flash('ut-form-flash','err','✗ '+r.error);return;}
|
||
if(!r.shares||!r.shares.length){flash('ut-form-flash','warn',t('flash.no_shares'));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',t('flash.no_name'));return;}
|
||
if(!share){flash('ut-form-flash','err',t('flash.no_share'));return;}
|
||
const body={type:'smb',name,dest_path:dest,share,
|
||
host:_utConn.host, user:_utConn.user, pass:_utConn.pass};
|
||
flash('ut-form-flash','warn',t('flash.saving'));
|
||
const r=await api('/upload/targets','POST',body);
|
||
if(r.error){flash('ut-form-flash','err',r.error);return;}
|
||
flash('ut-form-flash','warn',t('flash.testing'));
|
||
try{
|
||
const tr=await api('/upload/targets/'+r.id+'/test','POST');
|
||
if(tr.ok){flash('ut-form-flash','ok',t('flash.ok_test'));utToggleForm();await loadUTs();}
|
||
else flash('ut-form-flash','err','✗ '+(tr.error||t('flash.fail_timeout').slice(2)));
|
||
}catch(e){flash('ut-form-flash','err',t('flash.fail_timeout'));}
|
||
}
|
||
async function utTest(id){
|
||
const btn=$('ut-test-'+id), res=$('ut-test-result-'+id);
|
||
btn.disabled=true; btn.textContent=t('btn.testing');
|
||
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=t('flash.ok_test');
|
||
} else {
|
||
res.style.background='rgba(248,113,113,.1)'; res.style.color='var(--red)';
|
||
res.textContent='✗ ' + (r.error||t('flash.fail_timeout').slice(2));
|
||
}
|
||
}
|
||
async function utToggle(id){await api('/upload/targets/'+id+'/toggle','POST');await loadUTs();}
|
||
async function utDel(id,name){
|
||
if(!confirm(t('confirm.del_target').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)+t('size.free'):'';
|
||
if(s.pkg_running){
|
||
btn.disabled=true; btn.textContent=t('share.install');
|
||
detail.textContent=t('share.installing')+free;
|
||
return;
|
||
}
|
||
btn.disabled=false;
|
||
btn.textContent=s.enabled?t('share.on'):t('share.off');
|
||
const status=s.enabled
|
||
? ((s.active?t('status.active'):t('status.configured'))+' | \\\\'+(location.hostname||'picopy')+'\\PiCopy')
|
||
: t('share.inactive');
|
||
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(t('confirm.samba')))return;
|
||
}
|
||
flash('internal-share-flash','ok',enable?t('share.activating'):t('share.deactivating'));
|
||
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?t('share.active_ok'):t('share.deactive_ok'));
|
||
updateInternalShareBox(r.status);
|
||
}
|
||
|
||
// -- 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">'+t('expl.no_port')+'</div>';bread.innerHTML='';return;}
|
||
const dev=port==='__internal__'
|
||
? {usb_port:'__internal__',label:t('Interner Speicher')||'Interner Speicher',device:'internal'}
|
||
: devs.find(d=>d.usb_port===port);
|
||
if(!dev){body.innerHTML='<div class="expl-empty">'+t('expl.no_dev')+'</div>';bread.innerHTML='<span style="color:var(--sub)">Port '+port+'</span>';return;}
|
||
body.innerHTML='<div class="expl-empty">'+t('expl.loading')+'</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">'+t('expl.conn_err')+'</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">'+t('expl.empty_drive')+'</div>';return;}
|
||
if(!entries.length){body.innerHTML=h+'<div class="expl-empty">'+t('expl.empty_folder')+'</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=t('wg.not_installed')||'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=t('vpn.active'); vi.textContent=v.ip||'';
|
||
wgd.className='wdot c'; wgl.textContent=t('wifi.connected');
|
||
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=t('vpn.disconnected');
|
||
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?t('status.configured'):t('status.not_cfg');
|
||
}
|
||
}
|
||
}
|
||
// WiFi
|
||
const wd=$('wdot'),wl=$('wifi-label'),wi=$('wifi-ip');
|
||
if(w.mode==='client'){wd.className='wdot c';wl.textContent=w.ssid||t('wifi.connected');wi.textContent=w.ip||'';}
|
||
else if(w.mode==='ap'){wd.className='wdot a';wl.textContent=t('wifi.hotspot')+(w.ssid||'PiCopy');wi.textContent='10.42.0.1';}
|
||
else{wd.className='wdot d';wl.textContent=t('wifi.none');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=t('copy.verify')+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+t('copy.checked');
|
||
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=t('copy.delete');
|
||
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=t('copy.copying')+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+t('copy.files');
|
||
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=t('copy.error_prefix')+c.error;
|
||
pf.className='prog-fill err'; pw.style.display='block'; pf.style.width='100%';
|
||
sum.textContent=''; time.textContent='';
|
||
}else if(c.last_copy && !_dismissed){
|
||
tx.className='st-headline st-ok'; tx.textContent=t('copy.done');
|
||
pf.className='prog-fill done'; pw.style.display='block'; pf.style.width='100%';
|
||
sum.textContent=c.total+t('copy.files')+' | '+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=t('copy.ready');
|
||
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;
|
||
function dismissStatus(){
|
||
_dismissed = true;
|
||
if(_autoDismissTimer){ clearTimeout(_autoDismissTimer); _autoDismissTimer=null; }
|
||
$('st-text').className='st-headline st-idle'; $('st-text').textContent=t('copy.ready');
|
||
$('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(t('confirm.update').replace('${v}',latest))) return;
|
||
|
||
$('upd-badge').innerHTML = t('update.installing');
|
||
$('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 = '🔍 '+t('btn.checking');
|
||
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 = t('update.available').replace('${v}',u.latest);
|
||
} else if (u.error) {
|
||
fl.className = 'flash err'; fl.textContent = t('update.error_prefix') + u.error;
|
||
} else {
|
||
fl.className = 'flash ok'; fl.textContent = t('update.current');
|
||
}
|
||
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 = t('expl.conn_err'); fl.style.display = 'block';
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = '🔍 '+t('check_update');
|
||
}
|
||
}
|
||
|
||
async function rebootDevice() {
|
||
if (!confirm(t('confirm.reboot'))) 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">'+t('reboot.wait')+'</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(t('confirm.wg_install')))return;
|
||
flash('wg-flash','ok',t('wg.installing'));
|
||
const r=await api('/wireguard/install','POST');
|
||
if(r.error) flash('wg-flash','err',r.error);
|
||
}
|
||
async function wgUninstall(){
|
||
if(!confirm(t('confirm.wg_uninstall')))return;
|
||
flash('wg-flash','ok',t('wg.removing'));
|
||
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',t('wg.connecting'));
|
||
await api('/wireguard/connect','POST');
|
||
}
|
||
async function wgDisconnect(){
|
||
$('wg-btn-disconnect').disabled=true;
|
||
flash('wg-flash','ok',t('wg.disconnecting'));
|
||
const r=await api('/wireguard/disconnect','POST');
|
||
if(!r.ok) flash('wg-flash','err',t('wg.disc_failed'));
|
||
}
|
||
async function wgSaveConfig(){
|
||
const content=$('wg-config').value.trim();
|
||
if(!content){flash('wg-flash','err',t('wg.cfg_empty'));return;}
|
||
if(!content.includes('[Interface]')){flash('wg-flash','err',t('wg.invalid_cfg'));return;}
|
||
const auto=$('wg-auto').checked;
|
||
flash('wg-flash','ok',t('wg.saving'));
|
||
const r=await api('/wireguard/config','POST',{content,auto});
|
||
if(r.error){flash('wg-flash','err',r.error);return;}
|
||
flash('wg-flash','ok',t('wg.saved'));
|
||
}
|
||
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);
|
||
}
|
||
|
||
(async()=>{
|
||
await loadCfg();
|
||
await refreshDevices();
|
||
await loadUTs();
|
||
await loadWgConfig();
|
||
expl.load('');
|
||
setInterval(poll,1500);
|
||
setInterval(refreshDevices,8000);
|
||
setInterval(pollUpdate,60000);
|
||
poll();
|
||
pollUpdate();
|
||
setTimeout(pollUpdate, 8000); // Server-Check-Ergebnis abholen bevor der 60s-Takt greift
|
||
})();
|
||
</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)
|