You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

216 lines
8.2 KiB

"""Config backup + factory-default-reseed detection + restore (sqlite board_config)."""
import json
import os
import shutil
import sqlite3
import time
CONFIG_KEYS = ("device_config", "protocol_config")
def _read(db_path, key):
con = sqlite3.connect(db_path, timeout=5.0)
try:
row = con.execute("SELECT value FROM board_config WHERE key=?", (key,)).fetchone()
return row[0] if row else None
finally:
con.close()
def _write(db_path, key, value):
con = sqlite3.connect(db_path, timeout=5.0)
try:
con.execute(
"INSERT INTO board_config(key,value) VALUES(?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
con.commit()
finally:
con.close()
def _write_atomic(db_path, key_value_pairs):
"""Write multiple board_config keys in a single SQLite transaction.
v1.5.4.4 STAB-1 fix: detect_and_restore가 device_config + protocol_config을
별도 connection / commit으로 write하던 결함을 차단. 단일 connection +
명시적 BEGIN IMMEDIATE로 두 키를 묶어 원자성을 보장 — 두 번째 write 실패 시
SQLite가 첫 번째 write도 rollback 처리. DBManager.update_config 패턴과 일관.
Args:
db_path: SQLite DB path.
key_value_pairs: iterable of (key, value) tuples. 빈 입력은 no-op.
Raises:
sqlite3.Error: 어느 한 write라도 실패하면 rollback 후 그대로 전파.
"""
if not key_value_pairs:
return
con = sqlite3.connect(db_path, timeout=5.0, isolation_level=None)
try:
con.execute("BEGIN IMMEDIATE")
try:
for key, value in key_value_pairs:
con.execute(
"INSERT INTO board_config(key,value) VALUES(?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
con.execute("COMMIT")
except Exception:
try:
con.execute("ROLLBACK")
except sqlite3.Error:
pass
raise
finally:
con.close()
def _coerce(v):
"""Type-insensitive canonical form for comparing one config value.
Note: list comparison is order-sensitive by design — a reordered list
reads as a change, which fails safe toward no-restore.
bool check must come before int because bool is a subclass of int."""
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
if isinstance(v, float):
return str(int(v)) if v.is_integer() else repr(v)
if isinstance(v, (dict, list)):
return json.dumps(v, sort_keys=True, ensure_ascii=False)
if v is None:
return ""
return str(v).strip()
def _as_dict(text):
if text is None:
return None
try:
d = json.loads(text)
except (ValueError, TypeError):
return None
return d if isinstance(d, dict) else None
class ResetAssessment:
def __init__(self, is_reset, reason, reverted, user_changed, considered):
self.is_reset = is_reset
self.reason = reason
self.reverted = reverted # keys reverted to factory default
self.user_changed = user_changed # keys holding a genuinely new value
self.considered = considered # user-distinguishing keys judged
def assess_reset(current_value, backup_value, default_value):
"""Field-level reseed detection. current/backup/default are JSON strings.
A "reseed" = the user's distinguishing fields were wholesale reverted to
factory defaults AND nothing looks like a genuine new edit (do-no-harm).
Robust to dpworldapp re-serializing the default differently than the file.
"""
cur, bak, dft = _as_dict(current_value), _as_dict(backup_value), _as_dict(default_value)
if cur is None or bak is None or dft is None:
return ResetAssessment(False, "missing or invalid config json", [], [], [])
considered, reverted, user_changed = [], [], []
for k in (set(bak) & set(dft)):
bv, dv = _coerce(bak.get(k)), _coerce(dft.get(k))
if bv == dv:
continue # not user-distinguishing (user value already equals default)
considered.append(k)
if k not in cur:
reverted.append(k) # device dropped the key on reseed -> treat as reverted
continue
cv = _coerce(cur.get(k))
if cv == dv:
reverted.append(k) # user value lost back to factory default
elif cv != bv:
user_changed.append(k) # a genuinely new value (not backup, not default)
# else cv == bv -> user value preserved
n = len(considered)
is_reset = (
n >= 2
and len(user_changed) == 0
and len(reverted) >= max(2, (n * 4 + 4) // 5) # >= ceil(0.8 * n), min 2
)
if is_reset:
reason = "reseed: %d/%d user fields reverted to factory default, 0 new edits" % (len(reverted), n)
else:
reason = "not a clean reseed (reverted=%d, user_changed=%d, considered=%d)" % (
len(reverted), len(user_changed), n)
return ResetAssessment(is_reset, reason, reverted, user_changed, considered)
def backup_config(db_path, extra_files, out_dir, stamp=None):
stamp = stamp or time.strftime("%Y%m%d-%H%M%S")
dest = os.path.join(out_dir, stamp)
os.makedirs(dest, exist_ok=True)
saved = []
for key in CONFIG_KEYS:
val = _read(db_path, key)
if val is not None:
with open(os.path.join(dest, key + ".json"), "w", encoding="utf-8") as f:
f.write(val)
saved.append(key)
for fp in extra_files or []:
if os.path.isfile(fp):
shutil.copy2(fp, dest)
with open(os.path.join(dest, "manifest.json"), "w", encoding="utf-8") as f:
json.dump({"stamp": stamp, "keys": saved, "db": db_path}, f)
return dest
def latest_backup(out_dir):
if not os.path.isdir(out_dir):
return None
subs = [os.path.join(out_dir, d) for d in os.listdir(out_dir)]
subs = [d for d in subs if os.path.isdir(d)]
return max(subs, key=os.path.getmtime, default=None)
class RestoreResult:
def __init__(self, restored, reason, backup=None):
self.restored = restored
self.reason = reason
self.backup = backup
def detect_and_restore(db_path, default_file_path, backups_dir):
current = _read(db_path, "device_config")
backup = latest_backup(backups_dir)
if backup is None:
return RestoreResult(False, "no backup available")
backup_file = os.path.join(backup, "device_config.json")
if not os.path.isfile(backup_file):
return RestoreResult(False, "backup has no device_config", backup)
with open(backup_file, "r", encoding="utf-8") as f:
backup_value = f.read()
if not os.path.isfile(default_file_path):
return RestoreResult(False, "default config file not found", backup)
with open(default_file_path, "r", encoding="utf-8") as f:
default_value = f.read()
assessment = assess_reset(current, backup_value, default_value)
if not assessment.is_reset:
return RestoreResult(False, assessment.reason, backup)
if _as_dict(backup_value) is None:
return RestoreResult(False, "backup JSON invalid", backup)
# Validate protocol_config BEFORE writing anything — prevent a half-write.
proto = os.path.join(backup, "protocol_config.json")
proto_value = None
if os.path.isfile(proto):
with open(proto, "r", encoding="utf-8") as f:
proto_value = f.read()
if _as_dict(proto_value) is None:
return RestoreResult(False, "backup protocol_config invalid", backup)
# Both validated — now write atomically.
# v1.5.4.4 STAB-1 fix: 이전엔 두 _write 호출이 별도 connection / commit이라
# 두 번째 write 실패 시 device_config만 복원된 zombie state가 발생할 수 있었다.
# 이제 단일 BEGIN IMMEDIATE transaction으로 묶어 atomicity 보장.
pairs = [("device_config", backup_value)]
if proto_value is not None:
pairs.append(("protocol_config", proto_value))
_write_atomic(db_path, pairs)
return RestoreResult(True, "restored from backup (%s)" % assessment.reason, backup)