Initial commit: Implementiere PiCopy - Automatischer USB-Kopierdienst mit Web-Interface, einschließlich Installations- und Bereitstellungsskripten sowie Systemd-Service.
This commit is contained in:
558
app.py
Normal file
558
app.py
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""PiCopy - Automatischer USB-Kopierdienst mit Web-Interface"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
BASE_DIR = Path('/opt/picopy')
|
||||||
|
CONFIG_FILE = BASE_DIR / 'config.json'
|
||||||
|
LOG_DIR = BASE_DIR / 'logs'
|
||||||
|
LOG_FILE = LOG_DIR / 'picopy.log'
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s %(levelname)s %(message)s',
|
||||||
|
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()]
|
||||||
|
)
|
||||||
|
log = logging.getLogger('picopy')
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
'source_port': None,
|
||||||
|
'dest_port': None,
|
||||||
|
'folder_format': '%Y-%m-%d',
|
||||||
|
'add_time': True,
|
||||||
|
'subfolder': True,
|
||||||
|
'auto_copy': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
'running': False, 'progress': 0,
|
||||||
|
'total': 0, 'done': 0,
|
||||||
|
'current': '', 'error': None,
|
||||||
|
'last_copy': None, 'logs': [],
|
||||||
|
}
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def load_cfg():
|
||||||
|
cfg = DEFAULT_CONFIG.copy()
|
||||||
|
try:
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
cfg.update(json.loads(CONFIG_FILE.read_text()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save_cfg(cfg):
|
||||||
|
CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def usb_port_of(dev_name):
|
||||||
|
try:
|
||||||
|
real = Path(f'/sys/block/{dev_name}').resolve()
|
||||||
|
port = None
|
||||||
|
for seg in str(real).split('/'):
|
||||||
|
if re.fullmatch(r'\d+[\-\d.]+', seg) and ':' not in seg:
|
||||||
|
port = seg
|
||||||
|
return port
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def usb_devices():
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
['lsblk', '-J', '-o', 'NAME,TRAN,MOUNTPOINT,LABEL,SIZE,MODEL'],
|
||||||
|
timeout=10, text=True
|
||||||
|
)
|
||||||
|
data = json.loads(out)
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'lsblk: {e}')
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for bd in data.get('blockdevices', []):
|
||||||
|
if bd.get('tran') != 'usb':
|
||||||
|
continue
|
||||||
|
name = bd['name']
|
||||||
|
port = usb_port_of(name)
|
||||||
|
model = (bd.get('label') or bd.get('model') or name).strip()
|
||||||
|
children = bd.get('children') or []
|
||||||
|
if children:
|
||||||
|
for child in children:
|
||||||
|
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 '',
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result.append({
|
||||||
|
'device': f'/dev/{name}',
|
||||||
|
'usb_port': port,
|
||||||
|
'mount': bd.get('mountpoint') or '',
|
||||||
|
'label': model,
|
||||||
|
'size': bd.get('size') or '',
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mount(dev_info):
|
||||||
|
mp = dev_info.get('mount')
|
||||||
|
if mp:
|
||||||
|
return mp, False
|
||||||
|
dev = dev_info['device']
|
||||||
|
mp = f'/mnt/picopy{dev.replace("/", "_")}'
|
||||||
|
os.makedirs(mp, exist_ok=True)
|
||||||
|
r = subprocess.run(['mount', dev, mp], capture_output=True)
|
||||||
|
if r.returncode:
|
||||||
|
log.error(f'mount failed: {r.stderr.decode()}')
|
||||||
|
return None, False
|
||||||
|
return mp, True
|
||||||
|
|
||||||
|
|
||||||
|
def add_log(msg):
|
||||||
|
log.info(msg)
|
||||||
|
with lock:
|
||||||
|
state['logs'].append({'t': datetime.now().strftime('%H:%M:%S'), 'm': msg})
|
||||||
|
state['logs'] = state['logs'][-100:]
|
||||||
|
|
||||||
|
|
||||||
|
def do_copy(src_dev, dst_dev, cfg):
|
||||||
|
src_mp = dst_mp = None
|
||||||
|
src_owned = dst_owned = False
|
||||||
|
try:
|
||||||
|
with lock:
|
||||||
|
state.update(running=True, progress=0, error=None,
|
||||||
|
done=0, total=0, logs=[], current='')
|
||||||
|
|
||||||
|
add_log('Kopiervorgang gestartet')
|
||||||
|
|
||||||
|
src_mp, src_owned = ensure_mount(src_dev)
|
||||||
|
if not src_mp:
|
||||||
|
raise RuntimeError(f'Quelle nicht mountbar: {src_dev["device"]}')
|
||||||
|
add_log(f'Quelle: {src_mp} ({src_dev["label"]})')
|
||||||
|
|
||||||
|
dst_mp, dst_owned = ensure_mount(dst_dev)
|
||||||
|
if not dst_mp:
|
||||||
|
raise RuntimeError(f'Ziel nicht mountbar: {dst_dev["device"]}')
|
||||||
|
add_log(f'Ziel: {dst_mp} ({dst_dev["label"]})')
|
||||||
|
|
||||||
|
ts = datetime.now()
|
||||||
|
date_str = ts.strftime(cfg['folder_format'])
|
||||||
|
if cfg.get('add_time'):
|
||||||
|
date_str += '_' + ts.strftime('%H%M%S')
|
||||||
|
label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source'))
|
||||||
|
|
||||||
|
dst_dir = Path(dst_mp) / date_str
|
||||||
|
if cfg.get('subfolder'):
|
||||||
|
dst_dir = dst_dir / label
|
||||||
|
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
add_log(f'Zielordner: {dst_dir}')
|
||||||
|
|
||||||
|
src_path = Path(src_mp)
|
||||||
|
files = [f for f in src_path.rglob('*') if f.is_file()]
|
||||||
|
total = len(files)
|
||||||
|
with lock:
|
||||||
|
state['total'] = total
|
||||||
|
add_log(f'{total} Dateien gefunden')
|
||||||
|
|
||||||
|
for i, f in enumerate(files):
|
||||||
|
with lock:
|
||||||
|
if not state['running']:
|
||||||
|
add_log('Abgebrochen')
|
||||||
|
return
|
||||||
|
dst_f = dst_dir / f.relative_to(src_path)
|
||||||
|
dst_f.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(f, dst_f)
|
||||||
|
with lock:
|
||||||
|
state.update(
|
||||||
|
done=i + 1,
|
||||||
|
progress=int((i + 1) / total * 100) if total else 100,
|
||||||
|
current=str(f.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
with lock:
|
||||||
|
state['last_copy'] = datetime.now().isoformat()
|
||||||
|
add_log(f'Fertig! {total} Dateien kopiert')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception('Copy failed')
|
||||||
|
with lock:
|
||||||
|
state['error'] = str(e)
|
||||||
|
add_log(f'Fehler: {e}')
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if src_owned and src_mp:
|
||||||
|
subprocess.run(['umount', src_mp], capture_output=True)
|
||||||
|
if dst_owned and dst_mp:
|
||||||
|
subprocess.run(['umount', dst_mp], capture_output=True)
|
||||||
|
with lock:
|
||||||
|
state['running'] = False
|
||||||
|
state['current'] = ''
|
||||||
|
|
||||||
|
|
||||||
|
def check_auto_copy():
|
||||||
|
cfg = load_cfg()
|
||||||
|
if not cfg.get('auto_copy') or not cfg.get('source_port') or not cfg.get('dest_port'):
|
||||||
|
return
|
||||||
|
with lock:
|
||||||
|
if state['running']:
|
||||||
|
return
|
||||||
|
devs = usb_devices()
|
||||||
|
src = next((d for d in devs if d['usb_port'] == cfg['source_port']), None)
|
||||||
|
dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None)
|
||||||
|
if src and dst:
|
||||||
|
log.info('Auto-Copy: beide Geräte verbunden')
|
||||||
|
threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def usb_monitor():
|
||||||
|
try:
|
||||||
|
import pyudev
|
||||||
|
ctx = pyudev.Context()
|
||||||
|
mon = pyudev.Monitor.from_netlink(ctx)
|
||||||
|
mon.filter_by(subsystem='block', device_type='disk')
|
||||||
|
for dev in iter(mon.poll, None):
|
||||||
|
if dev.action == 'add':
|
||||||
|
log.info(f'USB eingesteckt: {dev.device_node}')
|
||||||
|
threading.Timer(3.0, check_auto_copy).start()
|
||||||
|
except ImportError:
|
||||||
|
log.warning('pyudev nicht verfügbar – USB-Überwachung deaktiviert')
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f'USB-Monitor Fehler: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HTML = r"""<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>PiCopy</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#0f172a;--surf:#1e293b;--surf2:#243044;--brd:#334155;--txt:#e2e8f0;--mut:#94a3b8;--acc:#3b82f6;--grn:#22c55e;--red:#ef4444;--ylw:#f59e0b}
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{background:var(--bg);color:var(--txt);font-family:system-ui,sans-serif;padding:1rem 1rem 3rem}
|
||||||
|
a{color:var(--acc)}
|
||||||
|
h1{font-size:1.4rem;font-weight:700;display:flex;align-items:center;gap:.5rem;margin-bottom:1.5rem}
|
||||||
|
h1 .dot{width:10px;height:10px;border-radius:50%;background:var(--acc)}
|
||||||
|
h2{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-bottom:.75rem}
|
||||||
|
.wrap{max-width:920px;margin:0 auto;display:grid;gap:1rem;grid-template-columns:1fr}
|
||||||
|
@media(min-width:600px){.wrap{grid-template-columns:1fr 1fr}}
|
||||||
|
.card{background:var(--surf);border:1px solid var(--brd);border-radius:.8rem;padding:1.1rem}
|
||||||
|
.card.span2{grid-column:1/-1}
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:.35rem;padding:.4rem .85rem;border:1px solid var(--brd);border-radius:.4rem;background:transparent;color:var(--txt);font-size:.85rem;cursor:pointer;transition:.15s}
|
||||||
|
.btn:hover{border-color:var(--acc);color:var(--acc)}
|
||||||
|
.btn.pri{background:var(--acc);border-color:var(--acc);color:#fff}
|
||||||
|
.btn.pri:hover{background:#2563eb;border-color:#2563eb}
|
||||||
|
.btn.danger{border-color:var(--red);color:var(--red)}
|
||||||
|
.btn.danger:hover{background:rgba(239,68,68,.1)}
|
||||||
|
.btn.sm{padding:.25rem .55rem;font-size:.75rem}
|
||||||
|
.btn-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.75rem}
|
||||||
|
.prog-wrap{margin:.65rem 0 .35rem;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
|
||||||
|
.prog-bar{height:100%;background:var(--acc);border-radius:3px;transition:width .4s}
|
||||||
|
.prog-info{font-size:.8rem;color:var(--mut)}
|
||||||
|
.stat-ok{color:var(--grn)}
|
||||||
|
.stat-run{color:var(--acc)}
|
||||||
|
.stat-err{color:var(--red)}
|
||||||
|
.stat-idle{color:var(--mut)}
|
||||||
|
.dev-list{display:flex;flex-direction:column;gap:.5rem}
|
||||||
|
.dev{padding:.7rem .85rem;border:1px solid var(--brd);border-radius:.5rem;cursor:default;transition:.15s}
|
||||||
|
.dev:hover{border-color:var(--acc)}
|
||||||
|
.dev.src{border-color:var(--grn);background:rgba(34,197,94,.06)}
|
||||||
|
.dev.dst{border-color:var(--acc);background:rgba(59,130,246,.06)}
|
||||||
|
.dev-top{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
|
||||||
|
.dev-name{font-weight:600;font-size:.9rem}
|
||||||
|
.dev-badge{font-size:.65rem;font-weight:700;text-transform:uppercase;padding:.1rem .4rem;border-radius:.25rem}
|
||||||
|
.badge-src{background:rgba(34,197,94,.15);color:var(--grn)}
|
||||||
|
.badge-dst{background:rgba(59,130,246,.15);color:var(--acc)}
|
||||||
|
.dev-meta{font-size:.75rem;color:var(--mut);display:flex;gap:.4rem;flex-wrap:wrap;margin:.25rem 0}
|
||||||
|
.dev-meta span{background:var(--bg);padding:.1rem .35rem;border-radius:.2rem}
|
||||||
|
.dev-acts{display:flex;gap:.4rem;margin-top:.4rem}
|
||||||
|
.field{margin-bottom:.85rem}
|
||||||
|
.field label{display:block;font-size:.82rem;color:var(--mut);margin-bottom:.3rem}
|
||||||
|
.field input,.field select{width:100%;padding:.45rem .65rem;background:var(--bg);border:1px solid var(--brd);border-radius:.4rem;color:var(--txt);font-size:.88rem}
|
||||||
|
.field input:focus,.field select:focus{outline:none;border-color:var(--acc)}
|
||||||
|
.toggle-row{display:flex;align-items:center;gap:.5rem;margin-bottom:.65rem;cursor:pointer;user-select:none}
|
||||||
|
.toggle-row input{accent-color:var(--acc);width:16px;height:16px;cursor:pointer}
|
||||||
|
.toggle-row span{font-size:.88rem}
|
||||||
|
.log-box{font-family:ui-monospace,monospace;font-size:.76rem;max-height:220px;overflow-y:auto}
|
||||||
|
.log-entry{display:flex;gap:.5rem;padding:.2rem 0;border-bottom:1px solid rgba(51,65,85,.5)}
|
||||||
|
.log-t{color:var(--mut);flex-shrink:0}
|
||||||
|
.empty{color:var(--mut);font-size:.88rem;padding:.25rem 0}
|
||||||
|
.pill{display:inline-flex;align-items:center;gap:.3rem;font-size:.75rem;padding:.15rem .5rem;border-radius:9999px;border:1px solid var(--brd);color:var(--mut)}
|
||||||
|
.pill.green{border-color:var(--grn);color:var(--grn);background:rgba(34,197,94,.08)}
|
||||||
|
.pill.blue{border-color:var(--acc);color:var(--acc);background:rgba(59,130,246,.08)}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<div class="card span2">
|
||||||
|
<h1><span class="dot"></span>PiCopy</h1>
|
||||||
|
|
||||||
|
<h2>Status</h2>
|
||||||
|
<div id="st-text" class="stat-idle">Bereit</div>
|
||||||
|
<div id="st-bar-wrap" class="prog-wrap" style="display:none">
|
||||||
|
<div id="st-bar" class="prog-bar" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<div id="st-info" class="prog-info"></div>
|
||||||
|
<div id="st-summary" style="font-size:.82rem;color:var(--mut);margin-top:.35rem"></div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button id="btn-start" class="btn pri" onclick="startCopy()">▶ Kopieren starten</button>
|
||||||
|
<button id="btn-cancel" class="btn danger" onclick="cancelCopy()" style="display:none">■ Abbrechen</button>
|
||||||
|
<button class="btn" onclick="refreshDevices()">↻ Geräte neu laden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card span2">
|
||||||
|
<h2>Verbundene USB-Geräte</h2>
|
||||||
|
<div id="dev-list" class="dev-list"><div class="empty">Lade…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Einstellungen</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Ordner-Datumsformat</label>
|
||||||
|
<select id="c-fmt">
|
||||||
|
<option value="%Y-%m-%d">JJJJ-MM-TT (2024-01-15)</option>
|
||||||
|
<option value="%Y%m%d">JJJJMMTT (20240115)</option>
|
||||||
|
<option value="%d-%m-%Y">TT-MM-JJJJ (15-01-2024)</option>
|
||||||
|
<option value="%Y/%m/%d">JJJJ/MM/TT (Unterordner)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" id="c-time">
|
||||||
|
<span>Uhrzeit im Ordnernamen (z.B. 2024-01-15_143022)</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" id="c-sub">
|
||||||
|
<span>Unterordner pro Quelle (nach Gerätebezeichnung)</span>
|
||||||
|
</label>
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" id="c-auto">
|
||||||
|
<span>Automatisch kopieren sobald beide Geräte verbunden sind</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn pri" onclick="saveConfig()">✓ Speichern</button>
|
||||||
|
<div id="cfg-msg" style="font-size:.8rem;color:var(--grn);margin-top:.5rem;display:none">Gespeichert!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Protokoll</h2>
|
||||||
|
<div id="log-box" class="log-box"><div class="empty">Noch keine Einträge</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let cfg = {};
|
||||||
|
let devs = [];
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
|
async function api(path, method='GET', body=null){
|
||||||
|
const o={method,headers:{'Content-Type':'application/json'}};
|
||||||
|
if(body) o.body=JSON.stringify(body);
|
||||||
|
const r=await fetch('/api'+path,o);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDevices(){
|
||||||
|
devs = await api('/devices');
|
||||||
|
renderDevs();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDevs(){
|
||||||
|
const el=$('dev-list');
|
||||||
|
if(!devs.length){ el.innerHTML='<div class="empty">Keine USB-Speichergeräte gefunden. Bitte Gerät einstecken und Seite neu laden.</div>'; return; }
|
||||||
|
el.innerHTML=devs.map(d=>{
|
||||||
|
const isSrc=d.usb_port===cfg.source_port;
|
||||||
|
const isDst=d.usb_port===cfg.dest_port;
|
||||||
|
const cls=isSrc?'src':isDst?'dst':'';
|
||||||
|
const badge=isSrc?'<span class="dev-badge badge-src">✓ Quelle</span>':
|
||||||
|
isDst?'<span class="dev-badge badge-dst">✓ Ziel</span>':'';
|
||||||
|
return `<div class="dev ${cls}">
|
||||||
|
<div class="dev-top">
|
||||||
|
<span class="dev-name">${d.label||d.device}</span>${badge}
|
||||||
|
</div>
|
||||||
|
<div class="dev-meta">
|
||||||
|
<span>${d.device}</span>
|
||||||
|
<span>USB-Port: <b>${d.usb_port||'unbekannt'}</b></span>
|
||||||
|
<span>${d.size}</span>
|
||||||
|
${d.mount?'<span>'+d.mount+'</span>':''}
|
||||||
|
</div>
|
||||||
|
<div class="dev-acts">
|
||||||
|
<button class="btn sm" onclick="assign('${d.usb_port}','source')">Als Quelle</button>
|
||||||
|
<button class="btn sm" onclick="assign('${d.usb_port}','dest')">Als Ziel</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assign(port, role){
|
||||||
|
if(role==='source') cfg.source_port=port;
|
||||||
|
else cfg.dest_port=port;
|
||||||
|
await api('/config','POST',cfg);
|
||||||
|
renderDevs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCfg(){
|
||||||
|
cfg=await api('/config');
|
||||||
|
$('c-fmt').value=cfg.folder_format||'%Y-%m-%d';
|
||||||
|
$('c-time').checked=!!cfg.add_time;
|
||||||
|
$('c-sub').checked=!!cfg.subfolder;
|
||||||
|
$('c-auto').checked=!!cfg.auto_copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig(){
|
||||||
|
cfg.folder_format=$('c-fmt').value;
|
||||||
|
cfg.add_time=$('c-time').checked;
|
||||||
|
cfg.subfolder=$('c-sub').checked;
|
||||||
|
cfg.auto_copy=$('c-auto').checked;
|
||||||
|
await api('/config','POST',cfg);
|
||||||
|
const m=$('cfg-msg');
|
||||||
|
m.style.display='block';
|
||||||
|
setTimeout(()=>m.style.display='none',2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCopy(){
|
||||||
|
const r=await api('/copy/start','POST');
|
||||||
|
if(r.error) alert('Fehler: '+r.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelCopy(){
|
||||||
|
await api('/copy/cancel','POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollStatus(){
|
||||||
|
try{
|
||||||
|
const s=await api('/status');
|
||||||
|
const txt=$('st-text'), bar=$('st-bar'), wrap=$('st-bar-wrap');
|
||||||
|
const info=$('st-info'), sum=$('st-summary');
|
||||||
|
const bStart=$('btn-start'), bCancel=$('btn-cancel');
|
||||||
|
|
||||||
|
if(s.running){
|
||||||
|
txt.className='stat-run';
|
||||||
|
txt.textContent='Kopiert… '+s.progress+'%';
|
||||||
|
wrap.style.display='block';
|
||||||
|
bar.style.width=s.progress+'%';
|
||||||
|
info.textContent=s.current?s.done+' / '+s.total+' — '+s.current:'';
|
||||||
|
sum.textContent='';
|
||||||
|
bStart.style.display='none';
|
||||||
|
bCancel.style.display='';
|
||||||
|
} else {
|
||||||
|
bStart.style.display='';
|
||||||
|
bCancel.style.display='none';
|
||||||
|
info.textContent='';
|
||||||
|
if(s.error){
|
||||||
|
txt.className='stat-err';
|
||||||
|
txt.textContent='Fehler: '+s.error;
|
||||||
|
sum.textContent='';
|
||||||
|
wrap.style.display='none';
|
||||||
|
} else if(s.last_copy){
|
||||||
|
txt.className='stat-ok';
|
||||||
|
txt.textContent='Abgeschlossen';
|
||||||
|
sum.textContent=s.total+' Dateien · '+new Date(s.last_copy).toLocaleString('de-DE');
|
||||||
|
wrap.style.display='block';
|
||||||
|
bar.style.width='100%';
|
||||||
|
} else {
|
||||||
|
txt.className='stat-idle';
|
||||||
|
txt.textContent='Bereit';
|
||||||
|
sum.textContent='';
|
||||||
|
wrap.style.display='none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lb=$('log-box');
|
||||||
|
if(s.logs&&s.logs.length){
|
||||||
|
lb.innerHTML=s.logs.slice().reverse().map(l=>
|
||||||
|
`<div class="log-entry"><span class="log-t">${l.t}</span><span>${l.m}</span></div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async()=>{
|
||||||
|
await loadCfg();
|
||||||
|
await refreshDevices();
|
||||||
|
setInterval(pollStatus,1500);
|
||||||
|
setInterval(refreshDevices,10000);
|
||||||
|
pollStatus();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Flask Routes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return HTML
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/devices')
|
||||||
|
def r_devices():
|
||||||
|
return jsonify(usb_devices())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/config', methods=['GET', 'POST'])
|
||||||
|
def r_config():
|
||||||
|
if request.method == 'POST':
|
||||||
|
cfg = load_cfg()
|
||||||
|
cfg.update(request.get_json(force=True))
|
||||||
|
save_cfg(cfg)
|
||||||
|
return jsonify(ok=True)
|
||||||
|
return jsonify(load_cfg())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/status')
|
||||||
|
def r_status():
|
||||||
|
with lock:
|
||||||
|
return jsonify(dict(state))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/copy/start', methods=['POST'])
|
||||||
|
def r_start():
|
||||||
|
with lock:
|
||||||
|
if state['running']:
|
||||||
|
return jsonify(error='Bereits aktiv'), 400
|
||||||
|
cfg = load_cfg()
|
||||||
|
devs = usb_devices()
|
||||||
|
src = next((d for d in devs if d['usb_port'] == cfg.get('source_port')), None)
|
||||||
|
dst = next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None)
|
||||||
|
if not src:
|
||||||
|
return jsonify(error='Quellgerät nicht gefunden – USB-Port konfiguriert?'), 400
|
||||||
|
if not dst:
|
||||||
|
return jsonify(error='Zielgerät nicht gefunden – USB-Port konfiguriert?'), 400
|
||||||
|
threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True).start()
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/copy/cancel', methods=['POST'])
|
||||||
|
def r_cancel():
|
||||||
|
with lock:
|
||||||
|
state['running'] = False
|
||||||
|
return jsonify(ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
threading.Thread(target=usb_monitor, daemon=True).start()
|
||||||
|
log.info('PiCopy läuft auf http://0.0.0.0:8080')
|
||||||
|
app.run(host='0.0.0.0', port=8080, debug=False, use_reloader=False)
|
||||||
21
deploy.sh
Normal file
21
deploy.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy.sh - Überträgt PiCopy zum Pi und installiert es
|
||||||
|
# Verwendung: bash deploy.sh
|
||||||
|
# Benötigt: sshpass (brew install sshpass)
|
||||||
|
|
||||||
|
PI_HOST="10.0.100.61"
|
||||||
|
PI_USER="tobias"
|
||||||
|
PI_PASS="dmu7uqMH9roYzdtovlm0XfXT6"
|
||||||
|
REMOTE="/home/tobias/picopy_deploy"
|
||||||
|
|
||||||
|
SSH="sshpass -p '$PI_PASS' ssh -o StrictHostKeyChecking=no $PI_USER@$PI_HOST"
|
||||||
|
SCP="sshpass -p '$PI_PASS' scp -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
|
echo ">> Dateien übertragen..."
|
||||||
|
eval "$SCP -r $(pwd)/. $PI_USER@$PI_HOST:$REMOTE/"
|
||||||
|
|
||||||
|
echo ">> Installation starten..."
|
||||||
|
eval "$SSH 'cd $REMOTE && sudo bash install.sh'"
|
||||||
|
|
||||||
|
echo ">> Fertig!"
|
||||||
|
eval "$SSH 'sudo systemctl status picopy --no-pager'"
|
||||||
42
install.sh
Normal file
42
install.sh
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# PiCopy Installations-Script für den Raspberry Pi
|
||||||
|
# Ausführen auf dem Pi als root oder mit sudo:
|
||||||
|
# sudo bash install.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PI_DIR="/opt/picopy"
|
||||||
|
SERVICE="picopy"
|
||||||
|
|
||||||
|
echo "=== PiCopy Installation ==="
|
||||||
|
|
||||||
|
# Abhängigkeiten installieren
|
||||||
|
echo ">> Pakete installieren..."
|
||||||
|
apt-get update -q
|
||||||
|
apt-get install -y python3 python3-venv python3-pip lsblk
|
||||||
|
|
||||||
|
# Verzeichnis anlegen
|
||||||
|
echo ">> Verzeichnis anlegen: $PI_DIR"
|
||||||
|
mkdir -p "$PI_DIR/logs"
|
||||||
|
|
||||||
|
# Python-Umgebung
|
||||||
|
echo ">> Python venv erstellen..."
|
||||||
|
python3 -m venv "$PI_DIR/venv"
|
||||||
|
"$PI_DIR/venv/bin/pip" install --quiet flask pyudev
|
||||||
|
|
||||||
|
# App-Dateien kopieren
|
||||||
|
echo ">> Dateien kopieren..."
|
||||||
|
cp app.py "$PI_DIR/app.py"
|
||||||
|
|
||||||
|
# Systemd-Service einrichten
|
||||||
|
echo ">> Systemd-Service einrichten..."
|
||||||
|
cp picopy.service "/etc/systemd/system/$SERVICE.service"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "$SERVICE"
|
||||||
|
systemctl restart "$SERVICE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installation abgeschlossen ==="
|
||||||
|
echo "Web-Interface: http://$(hostname -I | awk '{print $1}'):8080"
|
||||||
|
echo "Status: systemctl status $SERVICE"
|
||||||
|
echo "Logs: journalctl -u $SERVICE -f"
|
||||||
16
picopy.service
Normal file
16
picopy.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=PiCopy – Automatischer USB-Kopierdienst
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/picopy
|
||||||
|
ExecStart=/opt/picopy/venv/bin/python /opt/picopy/app.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=2.3
|
||||||
|
pyudev>=0.24
|
||||||
Reference in New Issue
Block a user