From e06c464fb0b76c69bf32c433cf47bcc980fde065 Mon Sep 17 00:00:00 2001 From: Tobias Leuschner Date: Sat, 9 May 2026 11:54:04 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Unterst=C3=BCtzung=20f=C3=BCr=20mehrere?= =?UTF-8?q?=20Quellger=C3=A4te=20hinzugef=C3=BCgt=20und=20Versionsnummer?= =?UTF-8?q?=20auf=201.0.23=20erh=C3=B6ht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 424 +++++++++++++++++++++++++++++++--------------------- version.txt | 2 +- 2 files changed, 254 insertions(+), 172 deletions(-) diff --git a/app.py b/app.py index 052676c..950ea5a 100644 --- a/app.py +++ b/app.py @@ -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,115 +606,135 @@ 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 - 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, - current=str(f.name)) + + 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 - 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, - current=str(f.name)) - continue - else: - add_log(f'Unvollständige Datei gefunden, wird neu kopiert: {f.name}') - elif dup_mode == 'rename': - dst_f = _unique_path(dst_f) - # overwrite: einfach weitermachen + 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"]})') - 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 - 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}') + 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: - copy_state.update(done=i+1, - progress=int((i+1)/total*100) if total else 100, - current=str(f.name)) - continue - copied_pairs.append((f, dst_f)) + 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=global_done, + progress=int(global_done/total*100) if total else 100, + current=str(f.name)) + continue - with copy_lock: - 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() + if dst_f.exists(): + if dup_mode == 'skip': + if dst_f.stat().st_size == f.stat().st_size: + skipped += 1 + with copy_lock: + 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, wird neu kopiert: {f.name}') + elif dup_mode == 'rename': + dst_f = _unique_path(dst_f) - msg_parts = [f'{len(copied_pairs)} kopiert'] + fsize = f.stat().st_size + tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp') + try: + 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=global_done, + progress=int(global_done/total*100) if total else 100, + current=str(f.name)) + continue + all_copied_pairs.append((f, dst_f)) + + with copy_lock: + 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=global_done, + progress=int(global_done/total*100) if total else 100, + current=str(f.name), speed_bps=int(speed), eta_sec=eta) + if global_done % 20 == 0: + save_state() + + 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) - except Exception: pass + 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) - 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() + 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 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) - 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 + 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 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
-
-
-
-
-
-
Kein Port konfiguriert
+ +
+
+
+
+ Quelle hinzufügen
+
+ +
+
+ + +
+ +
+
Gerät einstecken → aus Liste wählen → Hinzufügen. Mehrere Quellen werden nacheinander auf dasselbe Ziel kopiert.
-
- - -
-
- - -
- -
-
Gerät in den gewünschten Port → aus Liste wählen → Speichern. PiCopy merkt sich den physischen Port dauerhaft.
@@ -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); - renderSlot('dst', cfg.dest_port, cfg.dest_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 `
+
▲ Quelle ${i+1}${sp.label?' – '+sp.label:''}
+
+
+
+
Port ${sp.port}
+
${info}
+
+ +
+
`; + }).join('') + (ports.length === 0 + ? '
Noch keine Quelle konfiguriert.
' + : ''); +} + 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=>``).join(''); - ['src-select','dst-select'].forEach(id=>{ - const el=$(id),prev=el.value; - el.innerHTML=''+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=>``) + .join(''); + const blank = v => ``; + + 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(){
`).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='
Kein Port konfiguriert
';bread.innerHTML='';return;} const dev=devs.find(d=>d.usb_port===port); diff --git a/version.txt b/version.txt index 2fa3901..1c2de38 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.22 \ No newline at end of file +1.0.23 \ No newline at end of file