Files
PiCopy/app.py

559 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()">&#9654; Kopieren starten</button>
<button id="btn-cancel" class="btn danger" onclick="cancelCopy()" style="display:none">&#9632; Abbrechen</button>
<button class="btn" onclick="refreshDevices()">&#8635; 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 &nbsp;(2024-01-15)</option>
<option value="%Y%m%d">JJJJMMTT &nbsp;(20240115)</option>
<option value="%d-%m-%Y">TT-MM-JJJJ &nbsp;(15-01-2024)</option>
<option value="%Y/%m/%d">JJJJ/MM/TT &nbsp;(Unter­ordner)</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()">&#10003; 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">&#10003; Quelle</span>':
isDst?'<span class="dev-badge badge-dst">&#10003; 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)