feat: Unterstützung für mehrere Quellgeräte hinzugefügt und Versionsnummer auf 1.0.23 erhöht

This commit is contained in:
2026-05-09 11:54:04 +02:00
parent ad10a92f26
commit e06c464fb0
2 changed files with 254 additions and 172 deletions

334
app.py
View File

@@ -51,7 +51,8 @@ WIFI_BOOT_WAIT = 25 # Sekunden warten beim Start bevor AP gestartet wird
DEFAULT_CONFIG = {
# USB
'source_port': None, 'source_label': '',
'source_ports': [], # [{port, label}, ...]
'source_port': None, 'source_label': '', # Migration legacy
'dest_port': None, 'dest_label': '',
'folder_format': '%Y-%m-%d', 'add_time': True,
'subfolder': True, 'auto_copy': True,
@@ -572,9 +573,19 @@ def _fmt_bytes(b):
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
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 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)]
try:
with copy_lock:
copy_state.update(running=True, progress=0, error=None,
@@ -583,12 +594,8 @@ def do_copy(src_dev, dst_dev, cfg):
start_ts=time.time(), eta_sec=None, speed_bps=0,
phase='copy')
save_state()
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"]})')
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:
@@ -599,100 +606,120 @@ def do_copy(src_dev, dst_dev, cfg):
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}')
# -- Alle Quellen mounten & Dateien sammeln -------------------------
# source_data: [(src_dev, src_path, files, dst_dir, incomplete_marker)]
source_data = []
total = 0
bytes_total = 0
# Halbkopierte .picopy_tmp-Dateien aus vorherigen Unterbrechungen entfernen
for stale in dst_dir.rglob('*.picopy_tmp'):
log.info(f'Bereinige Temp-Datei: {stale}')
stale.unlink(missing_ok=True)
# Incomplete-Marker: existiert dieser nach Neustart, war die letzte Kopie unterbrochen
incomplete_marker = dst_dir / '.picopy_incomplete'
incomplete_marker.write_text(json.dumps({
'started': datetime.now().isoformat(),
'source': src_dev.get('label', ''),
}))
# -- Dateien sammeln & filtern --------------------------------------
src_path = Path(src_mp)
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 durch Filter ausgeschlossen')
total = len(files)
bytes_total = sum(f.stat().st_size for f in files)
with copy_lock:
copy_state['total'] = total
copy_state['bytes_total'] = bytes_total
add_log(f'{total} Dateien ({_fmt_bytes(bytes_total)})')
save_state()
dup_mode = cfg.get('duplicate_handling', 'skip')
copied_pairs = [] # [(src, dst)] erfolgreich kopiert
skipped = 0
io_errors = 0
# -- Phase 1: Kopieren ----------------------------------------------
for i, f in enumerate(files):
for src_dev in src_devs:
with copy_lock:
cancelled = not copy_state['running']
if cancelled:
add_log('Abgebrochen')
return
rel = f.relative_to(src_path)
dst_f = dst_dir / rel
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=i+1,
progress=int((i+1)/total*100) if total else 100,
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':
# Größenvergleich: stimmt die Größe nicht überein, war die Datei
# beim letzten Kopieren möglicherweise durch Stromausfall abgeschnitten
if dst_f.stat().st_size == f.stat().st_size:
skipped += 1
with copy_lock:
copy_state.update(done=i+1,
progress=int((i+1)/total*100) if total else 100,
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 gefunden, wird neu kopiert: {f.name}')
add_log(f'Unvollständige Datei, wird neu kopiert: {f.name}')
elif dup_mode == 'rename':
dst_f = _unique_path(dst_f)
# overwrite: einfach weitermachen
fsize = f.stat().st_size
tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp')
try:
shutil.copy2(f, tmp_f) # Erst in Temp-Datei kopieren
os.replace(str(tmp_f), str(dst_f)) # Dann atomar umbenennen
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=i+1,
progress=int((i+1)/total*100) if total else 100,
copy_state.update(done=global_done,
progress=int(global_done/total*100) if total else 100,
current=str(f.name))
continue
copied_pairs.append((f, dst_f))
all_copied_pairs.append((f, dst_f))
with copy_lock:
copy_state['bytes_done'] += fsize
@@ -701,13 +728,13 @@ def do_copy(src_dev, dst_dev, cfg):
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,
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 (i+1) % 20 == 0:
if global_done % 20 == 0:
save_state()
msg_parts = [f'{len(copied_pairs)} kopiert']
msg_parts = [f'{len(all_copied_pairs)} kopiert']
if skipped:
msg_parts.append(f'{skipped} übersprungen')
if io_errors:
@@ -715,22 +742,22 @@ def do_copy(src_dev, dst_dev, cfg):
# -- Phase 2: Verifizieren ------------------------------------------
verify_errors = 0
verified_pairs = list(copied_pairs)
verified_pairs = list(all_copied_pairs)
if cfg.get('verify_checksum') and 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(copied_pairs), current='',
total=len(all_copied_pairs), current='',
eta_sec=None, speed_bps=0)
add_log(f'Verifiziere {len(copied_pairs)} Dateien...')
add_log(f'Verifiziere {len(all_copied_pairs)} Dateien...')
verified_pairs = []
for i, (src_f, dst_f) in enumerate(copied_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(copied_pairs)*100),
progress=int((i+1)/len(all_copied_pairs)*100),
current=src_f.name)
if cancelled:
add_log('Abgebrochen')
@@ -769,16 +796,17 @@ def do_copy(src_dev, dst_dev, cfg):
else:
add_log('Quelle geleert ✓')
# Alle Daten auf den Datenträger schreiben bevor wir abmelden
subprocess.run(['sync'], capture_output=True)
try: incomplete_marker.unlink(missing_ok=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))
threading.Thread(target=run_uploads, args=(dst_dir, cfg), daemon=True).start()
dst_dir_root = Path(dst_mp) / date_str
threading.Thread(target=run_uploads, args=(dst_dir_root, cfg), daemon=True).start()
except Exception as e:
log.exception('Copy failed')
@@ -787,9 +815,10 @@ def do_copy(src_dev, dst_dev, cfg):
add_log(f'Fehler: {e}')
finally:
subprocess.run(['sync'], capture_output=True) # Sicherheits-Sync vor Unmount
if src_owned and src_mp:
subprocess.run(['umount', src_mp], capture_output=True)
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:
@@ -800,17 +829,19 @@ def do_copy(src_dev, dst_dev, cfg):
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'):
src_ports = _resolve_source_ports(cfg)
if not cfg.get('auto_copy') or not src_ports or not cfg.get('dest_port'):
return
with copy_lock:
if copy_state['running'] or copy_state['error']:
return
devs = usb_devices()
src = next((d for d in devs if d['usb_port'] == cfg['source_port']), None)
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 = 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()
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:
@@ -952,11 +983,13 @@ def r_start():
return jsonify(error='Abbruch wird noch abgeschlossen - bitte kurz warten und erneut versuchen.'), 400
cfg = load_cfg()
devs = usb_devices()
src = next((d for d in devs if d['usb_port'] == cfg.get('source_port')), None)
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 not srcs: return jsonify(error='Keine Quellgeräte gefunden (Ports nicht verbunden)'), 400
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'), 400
if not dst: return jsonify(error='Zielgerät nicht gefunden'), 400
_copy_thread = threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True)
_copy_thread = threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True)
_copy_thread.start()
return jsonify(ok=True)
@@ -1642,29 +1675,25 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
<!-- Quelle + Ziel in eigenem gleichbreiten Grid -->
<div class="port-pair">
<!-- Quelle -->
<div class="port-slot" id="slot-src">
<div class="role-tag src">▲ Quelle</div>
<div class="port-display">
<div class="dot off" id="src-dot"></div>
<div style="min-width:0">
<div class="port-path" id="src-port-path">-</div>
<div class="port-info" id="src-dev-info">Kein Port konfiguriert</div>
</div>
</div>
<div class="field">
<label>Bezeichnung</label>
<input type="text" id="src-label" placeholder="z.B. linker blauer Port">
</div>
<!-- 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>
<button class="btn grn" style="width:100%" onclick="assignPort('source')">✓&nbsp;Als feste Quelle speichern</button>
<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()">+&nbsp;Quelle hinzufügen</button>
<div id="src-flash" class="flash" style="margin-top:.4rem"></div>
<div class="hint-box">Gerät in den gewünschten Port &rarr; aus Liste wählen &rarr; Speichern. PiCopy merkt sich den physischen Port dauerhaft.</div>
<div class="hint-box">Gerät einstecken &rarr; aus Liste wählen &rarr; Hinzufügen. Mehrere Quellen werden nacheinander auf dasselbe Ziel kopiert.</div>
</div>
</div>
<!-- Ziel -->
@@ -1969,18 +1998,41 @@ function swTab(show,hide){
// -- Port Slots ----------------------------------------------------------------
async function refreshDevices(){
devs = await api('/devices');
renderSlot('src', cfg.source_port, cfg.source_label);
renderSources();
renderSlot('dst', cfg.dest_port, cfg.dest_label);
renderUnassigned();
populateSel();
}
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';
return `<div class="port-slot ${dev?'src-on':''}" style="margin-bottom:.5rem">
<div class="role-tag src">&#9650; 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>
<button class="btn sm danger" style="margin-left:auto;flex-shrink:0"
onclick="removeSource('${sp.port}')">&#10005;</button>
</div>
</div>`;
}).join('') + (ports.length === 0
? '<div style="color:var(--sub);font-size:.83rem;margin-bottom:.5rem">Noch keine Quelle konfiguriert.</div>'
: '');
}
function renderSlot(r, port, label){
const isSrc=r==='src', dev=devs.find(d=>d.usb_port===port);
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('src-on', isSrc && !!port);
sl.classList.toggle('dst-on', !isSrc && !!port);
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:''); }
@@ -1992,16 +2044,26 @@ function renderSlot(r, port, label){
}
function populateSel(){
const opts=devs.map(d=>`<option value="${d.usb_port}">Port ${d.usb_port||'?'} - ${d.label||d.device} (${d.size})</option>`).join('');
['src-select','dst-select'].forEach(id=>{
const el=$(id),prev=el.value;
el.innerHTML='<option value="">- Gerät einstecken, dann hier wählen -</option>'+opts;
if(prev && devs.find(d=>d.usb_port===prev)) el.value=prev;
});
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) && 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 renderUnassigned(){
const list=devs.filter(d=>d.usb_port!==cfg.source_port&&d.usb_port!==cfg.dest_port);
const srcSet = new Set((cfg.source_ports||[]).map(sp=>sp.port));
const list=devs.filter(d=>!srcSet.has(d.usb_port)&&d.usb_port!==cfg.dest_port);
const w=$('unassigned-wrap');
if(!list.length){w.style.display='none';return;}
w.style.display='block';
@@ -2013,21 +2075,37 @@ function renderUnassigned(){
</div>`).join('');
}
async function addSource(){
const port=$('src-select').value, label=$('src-label').value.trim();
if(!port){flash('src-flash','err','Bitte zuerst ein Gerät wählen.');return;}
if(port===cfg.dest_port){flash('src-flash','err','Port bereits als Ziel konfiguriert!');return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash('src-flash','err','Port bereits als Quelle hinzugefügt!');return;}
cfg.source_ports = [...(cfg.source_ports||[]), {port, label}];
await api('/config','POST',cfg);
$('src-label').value='';
flash('src-flash','ok','✓ Quelle Port '+port+' hinzugefügt.');
renderSources(); populateSel(); renderUnassigned();
}
async function removeSource(port){
cfg.source_ports = (cfg.source_ports||[]).filter(sp=>sp.port!==port);
await api('/config','POST',cfg);
renderSources(); populateSel(); renderUnassigned();
}
async function assignPort(role){
const s=role==='source', sid=s?'src-select':'dst-select', lid=s?'src-label':'dst-label';
const fid=s?'src-flash':'dst-flash', pk=s?'source_port':'dest_port', lk=s?'source_label':'dest_label';
const sid='dst-select', lid='dst-label';
const fid='dst-flash', pk='dest_port', lk='dest_label';
const port=$(sid).value, label=$(lid).value.trim();
if(!port){flash(fid,'err','Bitte zuerst ein Gerät wählen.');return;}
const other=s?cfg.dest_port:cfg.source_port;
if(port===other){flash(fid,'err','Port bereits als '+(s?'Ziel':'Quelle')+' konfiguriert!');return;}
if((cfg.source_ports||[]).some(sp=>sp.port===port)){flash(fid,'err','Port bereits als Quelle konfiguriert!');return;}
cfg[pk]=port; cfg[lk]=label; $(lid).dataset.dirty='';
await api('/config','POST',cfg);
flash(fid,'ok','✓ Port '+port+' als '+(s?'Quelle':'Ziel')+' gespeichert.');
renderSlot('src',cfg.source_port,cfg.source_label);
flash(fid,'ok','✓ Port '+port+' als Ziel gespeichert.');
renderSlot('dst',cfg.dest_port,cfg.dest_label);
renderUnassigned();
populateSel(); renderUnassigned();
}
['src-label','dst-label'].forEach(id=>window.addEventListener('DOMContentLoaded',()=>{
['dst-label'].forEach(id=>window.addEventListener('DOMContentLoaded',()=>{
const el=$(id); if(el) el.addEventListener('input',()=>el.dataset.dirty='1');
}));
@@ -2044,6 +2122,10 @@ 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||''}];
$('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||'';
@@ -2179,7 +2261,7 @@ const expl={
reload(){this.load(this.paths[this.role]);},
navigate(p){this.load(p);},
async load(path=''){
const port=this.role==='src'?cfg.source_port:cfg.dest_port;
const port=this.role==='src'?(cfg.source_ports&&cfg.source_ports[0]?.port):cfg.dest_port;
const body=$('expl-body'), bread=$('expl-bread');
if(!port){body.innerHTML='<div class="expl-empty">Kein Port konfiguriert</div>';bread.innerHTML='';return;}
const dev=devs.find(d=>d.usb_port===port);

View File

@@ -1 +1 @@
1.0.22
1.0.23