""" support_bundle.py — Assemble a diagnostics zip for the Home dashboard. Bundles status snapshot, configs (WiFi passwords masked), the newest dpworldapp log, and OS diagnostics text. Intended to be emailed to support. """ import copy import io import json import os import zipfile import system_status MAX_LOG_BYTES = 5 * 1024 * 1024 # cap the bundled log at 5 MB def mask_passwords(device_config): """Return a deep copy of device_config with every wifi_passwd masked. Coerces a non-dict device_config (e.g. a corrupted board_config row that decoded to a string, int, or list) to an empty dict rather than propagating an AttributeError from .get(). """ cfg = copy.deepcopy(device_config if isinstance(device_config, dict) else {}) for entry in cfg.get("WIFI_SSID", []) or []: if isinstance(entry, dict) and "wifi_passwd" in entry: entry["wifi_passwd"] = "***" return cfg def build_support_bundle(db): """Build the diagnostics zip. Returns the zip file content as bytes.""" buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: # status snapshot try: status = system_status.get_system_status(db) except Exception as e: # never let one failure abort the bundle status = {"error": str(e)} z.writestr("status.json", json.dumps(status, indent=2, ensure_ascii=False)) # configs (passwords masked) device = mask_passwords(db.get_config("device_config") or {}) z.writestr("config-device.json", json.dumps(device, indent=2, ensure_ascii=False)) protocol = db.get_config("protocol_config") or {} z.writestr("config-protocol.json", json.dumps(protocol, indent=2, ensure_ascii=False)) # newest dpworldapp log (capped) log_path = system_status._find_newest_dpworld_log(system_status.LOG_DIR) if log_path: try: with open(log_path, "rb") as f: z.writestr("dpworldapp-latest.log", f.read(MAX_LOG_BYTES)) except OSError: pass # OS diagnostics text z.writestr("ip-addr.txt", system_status._run(["ip", "addr"]) + "\n--- routes ---\n" + system_status._run(["ip", "route"])) z.writestr("systemctl-status.txt", system_status._run(["systemctl", "status", system_status.DPWORLDAPP_UNIT]) + "\n--- web-configurator ---\n" + system_status._run(["systemctl", "status", "web-configurator.service"])) z.writestr("dmesg.txt", system_status._run(["dmesg"])[-200000:]) # v1.6.0 (§9): 네트워크 저널 tail — 부재 시 항목 생략 (기존 fail-soft 패턴) try: from network.journal import Journal journal_path = os.path.join(os.environ.get("LOG_DIR", "/opt/log/dpworldapp"), "network_journal.jsonl") sections_net = Journal(journal_path).tail(200) z.writestr("network_journal_tail.json", json.dumps(sections_net, indent=2, ensure_ascii=False)) except Exception: # noqa: BLE001 z.writestr("network_journal_tail.json", "[]") buf.seek(0) return buf.read()