diff --git a/app.py b/app.py
index 7786617..2427572 100644
--- a/app.py
+++ b/app.py
@@ -50,6 +50,8 @@ 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,
}
copy_lock = threading.Lock()
@@ -242,6 +244,24 @@ def wifi_monitor():
# ── 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
@@ -309,13 +329,22 @@ def add_log(msg):
copy_state['logs'].append({'t': datetime.now().strftime('%H:%M:%S'), 'm': msg})
copy_state['logs'] = copy_state['logs'][-200:]
+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 do_copy(src_dev, dst_dev, cfg):
src_mp = dst_mp = None
src_owned = dst_owned = False
try:
with copy_lock:
copy_state.update(running=True, progress=0, error=None,
- done=0, total=0, logs=[], current='')
+ done=0, total=0, logs=[], current='',
+ bytes_total=0, bytes_done=0,
+ start_ts=time.time(), eta_sec=None, speed_bps=0)
save_state()
add_log('Kopiervorgang gestartet')
@@ -344,9 +373,11 @@ def do_copy(src_dev, dst_dev, cfg):
src_path = Path(src_mp)
files = [f for f in src_path.rglob('*') if f.is_file()]
total = len(files)
+ bytes_total = sum(f.stat().st_size for f in files)
with copy_lock:
copy_state['total'] = total
- add_log(f'{total} Dateien gefunden')
+ copy_state['bytes_total'] = bytes_total
+ add_log(f'{total} Dateien gefunden ({_fmt_bytes(bytes_total)})')
save_state()
for i, f in enumerate(files):
@@ -356,11 +387,22 @@ def do_copy(src_dev, dst_dev, cfg):
return
dst_f = dst_dir / f.relative_to(src_path)
dst_f.parent.mkdir(parents=True, exist_ok=True)
+ fsize = f.stat().st_size
shutil.copy2(f, dst_f)
with copy_lock:
- copy_state.update(done=i+1,
- progress=int((i+1)/total*100) if total else 100,
- current=str(f.name))
+ 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=i+1,
+ progress=int((i+1)/total*100) if total else 100,
+ current=str(f.name),
+ speed_bps=int(speed),
+ eta_sec=eta,
+ )
if (i+1) % 20 == 0:
save_state()
@@ -618,7 +660,7 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
.btn-row{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.8rem}
.prog-wrap{margin:.6rem 0 .3rem;height:6px;background:var(--bg);border-radius:3px;overflow:hidden}
.prog-bar{height:100%;background:var(--acc);border-radius:3px;transition:width .4s ease}
-.prog-info{font-size:.78rem;color:var(--mut);min-height:1.1rem}
+.prog-info{font-size:.78rem;color:var(--mut);min-height:1.1rem;font-family:ui-monospace,monospace}
.st-ok{color:var(--grn)}.st-run{color:var(--acc)}.st-err{color:var(--red)}.st-idle{color:var(--mut)}
.field{margin-bottom:.8rem}
.field label{display:block;font-size:.81rem;color:var(--mut);margin-bottom:.3rem}
@@ -656,8 +698,8 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
.pdot{width:10px;height:10px;border-radius:50%;flex-shrink:0;transition:.3s}
.pdot.on{background:var(--grn);box-shadow:0 0 6px var(--grn)}
.pdot.off{background:var(--brd)}
-.port-dev-name{font-weight:600;font-size:.9rem;line-height:1.3}
-.port-dev-sub{font-size:.73rem;color:var(--mut);font-family:monospace;margin-top:.1rem}
+.port-path{font-weight:700;font-size:1rem;font-family:ui-monospace,monospace;color:var(--txt);line-height:1.3}
+.port-dev-info{font-size:.75rem;color:var(--mut);margin-top:.15rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.port-hint{font-size:.73rem;color:var(--mut);margin-top:.65rem;padding:.5rem .65rem;background:var(--bg);border-radius:.4rem;border-left:3px solid var(--brd)}
/* ── Port + Explorer grid ── */
@@ -723,6 +765,10 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
+
+
+
+
@@ -743,18 +789,18 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
-
Nicht verbunden
-
Kein Port konfiguriert
+
—
+
Kein Port konfiguriert
-
-
+
+
-
+
@@ -768,18 +814,18 @@ h2{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08e
-
Nicht verbunden
-
Kein Port konfiguriert
+
—
+
Kein Port konfiguriert
-
-
+
+
-
+
@@ -898,31 +944,38 @@ function renderPortSlots() {
}
function renderSlot(role, port, label) {
- const isSrc = role === 'src';
- const dev = devs.find(d => d.usb_port === port);
- const dot = $(role+'-dot'), nameEl=$(role+'-dev-name'), subEl=$(role+'-dev-sub');
- const slotEl = $('slot-'+role), lblEl=$(role+'-label');
+ const isSrc = role === 'src';
+ const dev = devs.find(d => d.usb_port === port);
+ const dot = $(role+'-dot');
+ const pathEl = $(role+'-port-path');
+ const infoEl = $(role+'-dev-info');
+ const slotEl = $('slot-'+role);
+ const lblEl = $(role+'-label');
+
slotEl.classList.toggle('has-src', isSrc && !!port);
slotEl.classList.toggle('has-dst', !isSrc && !!port);
- if (dev) {
- dot.className = 'pdot on';
- nameEl.textContent = dev.label || dev.device;
- subEl.textContent = 'Port '+port+(dev.size?' · '+dev.size:'')+(dev.mount?' · '+dev.mount:'');
- } else if (port) {
- dot.className = 'pdot off';
- nameEl.textContent = label || 'Nicht verbunden';
- subEl.textContent = 'Konfiguriert: Port '+port+(label?' · '+label:'');
+
+ if (port) {
+ // Port is configured — show port path PROMINENTLY
+ pathEl.textContent = 'Port ' + port + (label ? ' · ' + label : '');
+ if (dev) {
+ dot.className = 'pdot on';
+ infoEl.textContent = (dev.label||dev.device) + (dev.size?' · '+dev.size:'') + (dev.mount?' · '+dev.mount:'');
+ } else {
+ dot.className = 'pdot off';
+ infoEl.textContent = 'Gerät nicht verbunden';
+ }
} else {
- dot.className = 'pdot off';
- nameEl.textContent = 'Nicht verbunden';
- subEl.textContent = 'Kein Port konfiguriert';
+ dot.className = 'pdot off';
+ pathEl.textContent = '—';
+ infoEl.textContent = 'Kein Port konfiguriert';
}
if (lblEl && !lblEl.dataset.dirty) lblEl.value = label || '';
}
function populateSelects() {
const opts = devs.map(d =>
- `
`
+ `
`
).join('');
['src-select','dst-select'].forEach(id => {
const el=$(id), prev=el.value;
@@ -955,7 +1008,7 @@ async function assignPort(role) {
cfg[isSrc?'source_label':'dest_label'] = label;
$(lblId).dataset.dirty='';
await api('/config','POST',cfg);
- flash(fId,'ok','Gespeichert — Port '+port+' ist jetzt feste '+(isSrc?'Quelle':'Ziel')+'.');
+ flash(fId,'ok','Port '+port+' dauerhaft als '+(isSrc?'Quelle':'Ziel')+' gespeichert.');
renderPortSlots(); renderUnassigned();
expl.reload();
}
@@ -1131,10 +1184,23 @@ async function poll() {
if(c.running){
txt.className='st-run';txt.textContent='Kopiert… '+c.progress+'%';
wrap.style.display='block';bar.style.width=c.progress+'%';
- info.textContent=c.current?c.done+' / '+c.total+' — '+c.current:'';
+ // Datei + Bytes-Info
+ const byteInfo = c.bytes_total>0 ? fmtBytes(c.bytes_done)+' / '+fmtBytes(c.bytes_total) : '';
+ info.textContent = (c.current ? c.done+' / '+c.total+' — '+c.current : '') + (byteInfo ? ' ('+byteInfo+')' : '');
+ // ETA + Speed Badges
+ const etaRow=$('eta-row'), etaBadge=$('eta-badge'), speedBadge=$('speed-badge');
+ const etaTxt=fmtETA(c.eta_sec), spdTxt=fmtSpeed(c.speed_bps);
+ if(etaTxt||spdTxt){
+ etaRow.style.display='flex'; etaRow.style.alignItems='center';
+ etaBadge.innerHTML = etaTxt ? '⏱ '+etaTxt : '';
+ etaBadge.style.display = etaTxt ? '' : 'none';
+ speedBadge.innerHTML = spdTxt ? '⚡ '+spdTxt : '';
+ speedBadge.style.display = spdTxt ? '' : 'none';
+ } else { etaRow.style.display='none'; }
sum.textContent='';bS.style.display='none';bC.style.display='';
} else {
bS.style.display='';bC.style.display='none';info.textContent='';
+ $('eta-row').style.display='none';
if(c.error){txt.className='st-err';txt.textContent='Fehler: '+c.error;wrap.style.display='none';sum.textContent='';}
else if(c.last_copy){txt.className='st-ok';txt.textContent='✓ Abgeschlossen';wrap.style.display='block';bar.style.width='100%';sum.textContent=c.total+' Dateien · '+new Date(c.last_copy).toLocaleString('de-DE');}
else{txt.className='st-idle';txt.textContent='Bereit';wrap.style.display='none';sum.textContent='';}
@@ -1144,6 +1210,29 @@ async function poll() {
} catch(e){}
}
+function fmtETA(sec) {
+ if (!sec || sec <= 0) return '';
+ if (sec < 60) return 'noch < 1 Min.';
+ const m = Math.round(sec / 60);
+ if (m < 60) return 'noch ca. ' + m + ' Min.';
+ const h = Math.floor(m / 60), rm = m % 60;
+ return 'noch ca. ' + h + ' Std.' + (rm ? ' ' + rm + ' Min.' : '');
+}
+function fmtSpeed(bps) {
+ if (!bps || bps <= 0) return '';
+ if (bps < 1024) return bps + ' B/s';
+ if (bps < 1048576) return (bps/1024).toFixed(1) + ' KB/s';
+ if (bps < 1073741824) return (bps/1048576).toFixed(1) + ' MB/s';
+ return (bps/1073741824).toFixed(2) + ' GB/s';
+}
+function fmtBytes(b) {
+ if (!b) return '';
+ 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 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()=>{