Release v1.0.2 – Stromausfall-Schutz
Atomare Schreibvorgänge (schützt vor Dateikorruption durch Stromausfall): - _atomic_write(): schreibt erst .tmp, sync auf Disk, dann os.replace() (POSIX-atomar) - save_cfg() / save_state() verwenden _atomic_write statt write_text() - Update-Install schreibt app.py.tmp, fsync, dann atomares Umbenennen Korruptionsschutz beim Laden: - load_cfg() / load_state(): bei JSON-Fehler Warnung loggen, .corrupt-Backup anlegen, sicher mit Standardwerten weiterlaufen statt zu crashen Schutz vor unvollständigen Kopien: - Jede Datei wird als .picopy_tmp kopiert, erst nach Abschluss atomar umbenannt - Duplikat-Skip prüft Dateigröße: stimmt sie nicht überein, war die Datei abgeschnitten und wird automatisch neu kopiert - .picopy_incomplete Marker-Datei im Zielordner während des Kopiervorgangs - Veraltete .picopy_tmp-Dateien werden beim Kopierstart bereinigt - subprocess.run(['sync']) vor dem Unmounten der Laufwerke Startup-Bereinigung: - cleanup_stale_mounts() beim Start: hängende /mnt/picopy-Mounts aus vorherigen Abstürzen werden sauber per umount -l entfernt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
108
app.py
108
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()
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.1
|
||||
1.0.2
|
||||
|
||||
Reference in New Issue
Block a user