Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-05-13 12:01:11 +02:00
parent 50c0b4d012
commit f96c656385
22 changed files with 4352 additions and 4032 deletions

17
routes/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""PiCopy register_routes(app): registriert alle Blueprints."""
def register_routes(app):
from routes.copy_routes import copy_bp
from routes.wifi_routes import wifi_bp
from routes.wireguard_routes import wireguard_bp
from routes.upload_routes import upload_bp
from routes.system_routes import system_bp
from routes.browse_routes import browse_bp
app.register_blueprint(copy_bp)
app.register_blueprint(wifi_bp)
app.register_blueprint(wireguard_bp)
app.register_blueprint(upload_bp)
app.register_blueprint(system_bp)
app.register_blueprint(browse_bp)

147
routes/browse_routes.py Normal file
View File

@@ -0,0 +1,147 @@
"""PiCopy Blueprint: /api/browse, /api/history*, /api/internal-share*."""
import os
import subprocess
from datetime import datetime
from pathlib import Path
from flask import Blueprint, jsonify, request
from picopy.config import load_cfg, HISTORY_FILE, INTERNAL_DEST_DIR, log
from picopy.state import load_history
from picopy.usb import usb_devices, internal_dest_device
from picopy.samba import internal_share_update_state, set_internal_share_enabled
browse_bp = Blueprint('browse', __name__)
_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
@browse_bp.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
@browse_bp.route('/api/history')
def r_history():
return jsonify(load_history())
@browse_bp.route('/api/history', methods=['DELETE'])
def r_history_clear():
try:
HISTORY_FILE.write_text('[]', encoding='utf-8')
except Exception:
pass
return jsonify(ok=True)
@browse_bp.route('/api/internal-share/status')
def r_internal_share_status():
return jsonify(internal_share_update_state())
@browse_bp.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())

153
routes/copy_routes.py Normal file
View File

@@ -0,0 +1,153 @@
"""PiCopy Blueprint: /api/copy/*, /api/devices, /api/storage-info, /api/status, /api/config*."""
import shutil
import subprocess
import threading
from flask import Blueprint, jsonify, request
from picopy.config import load_cfg, save_cfg, _fmt_bytes
from picopy.state import copy_state, copy_lock
from picopy.usb import usb_devices, ensure_mount, internal_dest_device
from picopy.wifi import wifi_state, wifi_lock
from picopy.wireguard import wg_state, wg_lock
from picopy.samba import internal_share_update_state
import picopy.copy_engine as _ce
copy_bp = Blueprint('copy', __name__)
def _resolve_source_ports(cfg) -> list:
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)
@copy_bp.route('/api/devices')
def r_devices():
return jsonify(usb_devices())
@copy_bp.route('/api/storage-info')
def r_storage_info():
cfg = load_cfg()
devs = usb_devices()
result = []
src_ports = {sp['port'] for sp in _resolve_source_ports(cfg)}
dst_port = cfg.get('dest_port')
def _du_for_dev(dev):
mp, owned = ensure_mount(dev)
if not mp:
return dict(total=None, used=None, free=None, pct=None)
try:
du = shutil.disk_usage(mp)
return dict(total=du.total, used=du.used, free=du.free,
pct=round(du.used / du.total * 100) if du.total else 0)
except Exception:
return dict(total=None, used=None, free=None, pct=None)
finally:
if owned:
subprocess.run(['umount', mp], capture_output=True)
for dev in devs:
port = dev['usb_port']
if port in src_ports:
role = 'source'
elif port == dst_port:
role = 'dest'
else:
role = 'other'
entry = dict(
role=role,
label=dev.get('label') or dev.get('device') or f'Port {port}',
port=port,
device=dev.get('device', ''),
size_str=dev.get('size', ''),
)
entry.update(_du_for_dev(dev))
result.append(entry)
if cfg.get('dest_type') == 'internal':
entry = dict(role='dest',
label=cfg.get('internal_dest_label') or 'Interner Speicher',
port='__internal__', device='internal', size_str='')
entry.update(_du_for_dev({'internal': True}))
result.append(entry)
return jsonify(result)
@copy_bp.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())
@copy_bp.route('/api/config/ports/reset', methods=['POST'])
def r_ports_reset():
cfg = load_cfg()
cfg['source_ports'] = []
cfg['source_port'] = None
cfg['source_label'] = ''
cfg['dest_port'] = None
cfg['dest_label'] = ''
cfg['dest_type'] = 'usb'
save_cfg(cfg)
return jsonify(ok=True)
@copy_bp.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)
@copy_bp.route('/api/copy/start', methods=['POST'])
def r_start():
with copy_lock:
if copy_state['running']:
return jsonify(error='Bereits aktiv'), 400
if _ce._copy_thread is not None and _ce._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
t = threading.Thread(target=_ce.do_copy, args=(srcs, dst, cfg), daemon=True)
_ce._copy_thread = t
t.start()
return jsonify(ok=True)
@copy_bp.route('/api/copy/cancel', methods=['POST'])
def r_cancel():
with copy_lock:
copy_state['running'] = False
return jsonify(ok=True)

86
routes/system_routes.py Normal file
View File

@@ -0,0 +1,86 @@
"""PiCopy Blueprint: /api/sysinfo, /api/update/*, /api/format/*, /api/system/*."""
import subprocess
import threading
from flask import Blueprint, jsonify, request
from picopy.usb import usb_devices
from picopy.state import copy_state
from picopy.system import (
get_sysinfo, update_state, update_lock,
format_state, FORMAT_FILESYSTEMS,
check_for_updates, install_update, do_format,
)
system_bp = Blueprint('system', __name__)
@system_bp.route('/api/sysinfo')
def r_sysinfo():
return jsonify(get_sysinfo())
@system_bp.route('/api/update/status')
def r_update_status():
with update_lock:
return jsonify(dict(update_state))
@system_bp.route('/api/update/check', methods=['POST'])
def r_update_check():
threading.Thread(target=check_for_updates, daemon=True).start()
return jsonify(ok=True)
@system_bp.route('/api/update/install', methods=['POST'])
def r_update_install():
from picopy.config import log
try:
install_update()
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
@system_bp.route('/api/format/status')
def r_format_status():
return jsonify(dict(format_state))
@system_bp.route('/api/format', methods=['POST'])
def r_format():
if format_state['running']:
return jsonify(error='Formatierung läuft bereits'), 409
if copy_state.get('running'):
return jsonify(error='Kopiervorgang läuft bitte warten'), 409
body = request.get_json(force=True)
fs = body.get('fs', '').lower()
name = (body.get('name') or 'PICOPY').upper()
dev = body.get('device', '')
if fs not in FORMAT_FILESYSTEMS:
return jsonify(error=f'Unbekanntes Dateisystem: {fs}'), 400
if not dev.startswith('/dev/'):
return jsonify(error='Ungültiges Gerät'), 400
# Sicherheitscheck: Gerät muss ein bekanntes USB-Gerät sein
known = [d['device'] for d in usb_devices()]
if dev not in known:
return jsonify(error='Gerät nicht als USB-Laufwerk erkannt'), 400
threading.Thread(target=do_format, args=(fs, name, dev), daemon=True).start()
return jsonify(ok=True)
@system_bp.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)

123
routes/upload_routes.py Normal file
View File

@@ -0,0 +1,123 @@
"""PiCopy Blueprint: /api/upload/*."""
import subprocess
import uuid as _uuid_mod
from flask import Blueprint, jsonify, request
from picopy.config import load_cfg, save_cfg
from picopy.upload import (
upload_state, upload_lock,
configure_smb_remote, delete_remote, test_remote,
_rclone_obscure, RCLONE_CONF as _RCLONE_CONF,
)
upload_bp = Blueprint('upload', __name__)
@upload_bp.route('/api/upload/targets', methods=['GET'])
def r_upload_list():
return jsonify(load_cfg().get('upload_targets', []))
@upload_bp.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)
@upload_bp.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)
@upload_bp.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)
@upload_bp.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)
@upload_bp.route('/api/upload/targets/<tid>/test', methods=['POST'])
def r_upload_test(tid):
from picopy.config import log
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)
@upload_bp.route('/api/upload/status')
def r_upload_status():
with upload_lock:
return jsonify(dict(upload_state))

83
routes/wifi_routes.py Normal file
View File

@@ -0,0 +1,83 @@
"""PiCopy Blueprint: /api/wifi/*."""
import threading
import time
from flask import Blueprint, jsonify, request
from picopy.config import load_cfg, save_cfg
from picopy.wifi import (
wifi_state, wifi_lock,
is_ap_active, stop_ap, start_ap,
connect_client_wifi, update_wifi_state,
scan_wifi_networks,
)
wifi_bp = Blueprint('wifi', __name__)
@wifi_bp.route('/api/wifi/scan')
def r_wifi_scan():
nets = scan_wifi_networks()
return jsonify(nets)
@wifi_bp.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')
@wifi_bp.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)
@wifi_bp.route('/api/wifi/status')
def r_wifi_status():
with wifi_lock:
return jsonify(dict(wifi_state))

View File

@@ -0,0 +1,71 @@
"""PiCopy Blueprint: /api/wireguard/*."""
import re
import threading
from flask import Blueprint, jsonify, request
from picopy.config import load_cfg, save_cfg
from picopy.wireguard import (
wg_state, wg_lock, WG_CONF,
wg_connect, wg_disconnect,
wg_install, wg_uninstall,
wg_save_config,
)
wireguard_bp = Blueprint('wireguard', __name__)
@wireguard_bp.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='')
@wireguard_bp.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')
@wireguard_bp.route('/api/wireguard/disconnect', methods=['POST'])
def r_wg_disconnect():
ok = wg_disconnect()
return jsonify(ok=ok)
@wireguard_bp.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)
@wireguard_bp.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)