diff --git a/app.py b/app.py index 2c603a1..e352118 100644 --- a/app.py +++ b/app.py @@ -18,7 +18,7 @@ from flask import Flask, jsonify, request app = Flask(__name__) -VERSION = '1.0.1' +VERSION = '1.0.2' RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main' BASE_DIR = Path('/opt/picopy') @@ -69,19 +69,22 @@ def load_state(): global copy_state try: if STATE_FILE.exists(): - saved = json.loads(STATE_FILE.read_text()) - # Nur nicht-laufende Daten wiederherstellen + saved = json.loads(STATE_FILE.read_text(encoding='utf-8')) saved['running'] = False saved['current'] = '' copy_state.update(saved) - except Exception: - pass + except (json.JSONDecodeError, ValueError) as e: + log.warning(f'state.json korrupt ({e}), starte mit leerem Zustand') + try: STATE_FILE.rename(STATE_FILE.with_suffix('.corrupt')) + except Exception: pass + except Exception as e: + log.warning(f'state.json nicht lesbar: {e}') def save_state(): try: with copy_lock: data = dict(copy_state) - STATE_FILE.write_text(json.dumps(data)) + _atomic_write(STATE_FILE, json.dumps(data)) except Exception: pass @@ -100,13 +103,17 @@ def load_cfg(): cfg = DEFAULT_CONFIG.copy() try: if CONFIG_FILE.exists(): - cfg.update(json.loads(CONFIG_FILE.read_text())) - except Exception: - pass + cfg.update(json.loads(CONFIG_FILE.read_text(encoding='utf-8'))) + except (json.JSONDecodeError, ValueError) as e: + log.error(f'config.json korrupt ({e}), verwende Standardwerte') + try: CONFIG_FILE.rename(CONFIG_FILE.with_suffix('.corrupt')) + except Exception: pass + except Exception as e: + log.warning(f'config.json nicht lesbar: {e}') return cfg def save_cfg(cfg): - CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) + _atomic_write(CONFIG_FILE, json.dumps(cfg, indent=2)) # ── WiFi Hilfsfunktionen ───────────────────────────────────────────────────── @@ -379,6 +386,32 @@ def _file_md5(p: Path) -> str: return h.hexdigest() +def _atomic_write(path: Path, content: str) -> None: + """Schreibt atomar: erst .tmp, dann os.replace() – sicher bei Stromausfall.""" + tmp = path.with_suffix(path.suffix + '.tmp') + try: + tmp.write_text(content, encoding='utf-8') + with open(tmp, 'rb') as fh: + os.fsync(fh.fileno()) # Daten wirklich auf Datenträger schreiben + os.replace(str(tmp), str(path)) # Atomares Umbenennen (POSIX-Garantie) + except Exception: + try: tmp.unlink(missing_ok=True) + except Exception: pass + raise + + +def cleanup_stale_mounts() -> None: + """Bereinigt beim Start hängen gebliebene PiCopy-Mounts (z.B. nach Stromausfall).""" + try: + with open('/proc/mounts') as fh: + mps = [line.split()[1] for line in fh if '/mnt/picopy' in line] + for mp in mps: + log.info(f'Bereinige veralteten Mount: {mp}') + subprocess.run(['umount', '-l', mp], capture_output=True) + except Exception as e: + log.warning(f'Stale-Mount-Bereinigung fehlgeschlagen: {e}') + + def _fmt_bytes(b): if b < 1024: return f'{b} B' if b < 1024**2: return f'{b/1024:.1f} KB' @@ -421,6 +454,18 @@ def do_copy(src_dev, dst_dev, cfg): dst_dir.mkdir(parents=True, exist_ok=True) add_log(f'Zielordner: {dst_dir}') + # 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()] @@ -453,18 +498,30 @@ def do_copy(src_dev, dst_dev, cfg): if dst_f.exists(): if dup_mode == 'skip': - 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 + # 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 - fsize = f.stat().st_size - shutil.copy2(f, dst_f) + 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 Exception: + try: tmp_f.unlink(missing_ok=True) + except Exception: pass + raise copied_pairs.append((f, dst_f)) with copy_lock: @@ -538,6 +595,11 @@ 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 + with copy_lock: copy_state['last_copy'] = datetime.now().isoformat() add_log('Fertig! ' + ', '.join(msg_parts)) @@ -551,6 +613,7 @@ 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) if dst_owned and dst_mp: @@ -1032,9 +1095,11 @@ def r_update_install(): # Syntax-Check bevor wir irgendetwas überschreiben compile(new_code, 'app.py', 'exec') - tmp = Path('/tmp/picopy_update.py') - tmp.write_text(new_code) - shutil.copy(str(tmp), '/opt/picopy/app.py') + tmp = Path('/opt/picopy/app.py.tmp') + tmp.write_text(new_code, encoding='utf-8') + with open(tmp, 'rb') as fh: + os.fsync(fh.fileno()) # Sicherstellen dass Daten auf der Platte sind + os.replace(str(tmp), '/opt/picopy/app.py') # Atomares Umbenennen log.info('Update installiert – starte Dienst neu…') # Systemd startet den Dienst automatisch neu @@ -1992,6 +2057,7 @@ function flash(id,cls,msg){ if __name__ == '__main__': + cleanup_stale_mounts() load_state() threading.Thread(target=usb_monitor, daemon=True).start() threading.Thread(target=wifi_monitor, daemon=True).start() diff --git a/version.txt b/version.txt index 7dea76e..6d7de6e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.1 +1.0.2