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__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
VERSION = '1.0.1'
|
VERSION = '1.0.2'
|
||||||
RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main'
|
RAW_BASE = 'https://git.leuschner.dev/Tobias/PiCopy/raw/branch/main'
|
||||||
|
|
||||||
BASE_DIR = Path('/opt/picopy')
|
BASE_DIR = Path('/opt/picopy')
|
||||||
@@ -69,19 +69,22 @@ def load_state():
|
|||||||
global copy_state
|
global copy_state
|
||||||
try:
|
try:
|
||||||
if STATE_FILE.exists():
|
if STATE_FILE.exists():
|
||||||
saved = json.loads(STATE_FILE.read_text())
|
saved = json.loads(STATE_FILE.read_text(encoding='utf-8'))
|
||||||
# Nur nicht-laufende Daten wiederherstellen
|
|
||||||
saved['running'] = False
|
saved['running'] = False
|
||||||
saved['current'] = ''
|
saved['current'] = ''
|
||||||
copy_state.update(saved)
|
copy_state.update(saved)
|
||||||
except Exception:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
pass
|
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():
|
def save_state():
|
||||||
try:
|
try:
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
data = dict(copy_state)
|
data = dict(copy_state)
|
||||||
STATE_FILE.write_text(json.dumps(data))
|
_atomic_write(STATE_FILE, json.dumps(data))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -100,13 +103,17 @@ def load_cfg():
|
|||||||
cfg = DEFAULT_CONFIG.copy()
|
cfg = DEFAULT_CONFIG.copy()
|
||||||
try:
|
try:
|
||||||
if CONFIG_FILE.exists():
|
if CONFIG_FILE.exists():
|
||||||
cfg.update(json.loads(CONFIG_FILE.read_text()))
|
cfg.update(json.loads(CONFIG_FILE.read_text(encoding='utf-8')))
|
||||||
except Exception:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
pass
|
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
|
return cfg
|
||||||
|
|
||||||
def save_cfg(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 ─────────────────────────────────────────────────────
|
# ── WiFi Hilfsfunktionen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -379,6 +386,32 @@ def _file_md5(p: Path) -> str:
|
|||||||
return h.hexdigest()
|
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):
|
def _fmt_bytes(b):
|
||||||
if b < 1024: return f'{b} B'
|
if b < 1024: return f'{b} B'
|
||||||
if b < 1024**2: return f'{b/1024:.1f} KB'
|
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)
|
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||||
add_log(f'Zielordner: {dst_dir}')
|
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 ──────────────────────────────────────
|
# ── Dateien sammeln & filtern ──────────────────────────────────────
|
||||||
src_path = Path(src_mp)
|
src_path = Path(src_mp)
|
||||||
all_files = [f for f in src_path.rglob('*') if f.is_file()]
|
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 dst_f.exists():
|
||||||
if dup_mode == 'skip':
|
if dup_mode == 'skip':
|
||||||
skipped += 1
|
# Größenvergleich: stimmt die Größe nicht überein, war die Datei
|
||||||
with copy_lock:
|
# beim letzten Kopieren möglicherweise durch Stromausfall abgeschnitten
|
||||||
copy_state.update(done=i+1,
|
if dst_f.stat().st_size == f.stat().st_size:
|
||||||
progress=int((i+1)/total*100) if total else 100,
|
skipped += 1
|
||||||
current=str(f.name))
|
with copy_lock:
|
||||||
continue
|
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':
|
elif dup_mode == 'rename':
|
||||||
dst_f = _unique_path(dst_f)
|
dst_f = _unique_path(dst_f)
|
||||||
# overwrite: einfach weitermachen
|
# overwrite: einfach weitermachen
|
||||||
|
|
||||||
fsize = f.stat().st_size
|
fsize = f.stat().st_size
|
||||||
shutil.copy2(f, dst_f)
|
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))
|
copied_pairs.append((f, dst_f))
|
||||||
|
|
||||||
with copy_lock:
|
with copy_lock:
|
||||||
@@ -538,6 +595,11 @@ def do_copy(src_dev, dst_dev, cfg):
|
|||||||
else:
|
else:
|
||||||
add_log('Quelle geleert ✓')
|
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:
|
with copy_lock:
|
||||||
copy_state['last_copy'] = datetime.now().isoformat()
|
copy_state['last_copy'] = datetime.now().isoformat()
|
||||||
add_log('Fertig! ' + ', '.join(msg_parts))
|
add_log('Fertig! ' + ', '.join(msg_parts))
|
||||||
@@ -551,6 +613,7 @@ def do_copy(src_dev, dst_dev, cfg):
|
|||||||
add_log(f'Fehler: {e}')
|
add_log(f'Fehler: {e}')
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
|
subprocess.run(['sync'], capture_output=True) # Sicherheits-Sync vor Unmount
|
||||||
if src_owned and src_mp:
|
if src_owned and src_mp:
|
||||||
subprocess.run(['umount', src_mp], capture_output=True)
|
subprocess.run(['umount', src_mp], capture_output=True)
|
||||||
if dst_owned and dst_mp:
|
if dst_owned and dst_mp:
|
||||||
@@ -1032,9 +1095,11 @@ def r_update_install():
|
|||||||
# Syntax-Check bevor wir irgendetwas überschreiben
|
# Syntax-Check bevor wir irgendetwas überschreiben
|
||||||
compile(new_code, 'app.py', 'exec')
|
compile(new_code, 'app.py', 'exec')
|
||||||
|
|
||||||
tmp = Path('/tmp/picopy_update.py')
|
tmp = Path('/opt/picopy/app.py.tmp')
|
||||||
tmp.write_text(new_code)
|
tmp.write_text(new_code, encoding='utf-8')
|
||||||
shutil.copy(str(tmp), '/opt/picopy/app.py')
|
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…')
|
log.info('Update installiert – starte Dienst neu…')
|
||||||
|
|
||||||
# Systemd startet den Dienst automatisch neu
|
# Systemd startet den Dienst automatisch neu
|
||||||
@@ -1992,6 +2057,7 @@ function flash(id,cls,msg){
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
cleanup_stale_mounts()
|
||||||
load_state()
|
load_state()
|
||||||
threading.Thread(target=usb_monitor, daemon=True).start()
|
threading.Thread(target=usb_monitor, daemon=True).start()
|
||||||
threading.Thread(target=wifi_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