diff --git a/launch_xiao.sh b/launch_xiao.sh new file mode 100755 index 0000000..b68522b --- /dev/null +++ b/launch_xiao.sh @@ -0,0 +1,9 @@ +#!/bin/bash +XIAO=$(ls /dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_* 2>/dev/null | head -1) +if [ -z "$XIAO" ]; then + echo "XIAO ESP32-C5 not found — plug it in and try again" + read -p "Press Enter to close..." + exit 1 +fi +cd /home/fusedstamen/python/WatchDogsGo +exec sudo -E .venv/bin/python3 -m watchdogs "$XIAO" diff --git a/plugins/wardrive_upload.py b/plugins/wardrive_upload.py index 1bb280d..ed5f9dc 100644 --- a/plugins/wardrive_upload.py +++ b/plugins/wardrive_upload.py @@ -402,7 +402,8 @@ def _pending_sessions(self) -> list[Path]: # Session has data if any of these exist has_data = any((d / f).is_file() for f in ["wardriving.csv", "adsb_aircraft.csv", "meshcore_nodes.csv"]) - if has_data and d.name not in self._uploaded_sessions: + active = Path(self.app.loot.session_path).name if self.app.loot else "" + if has_data and d.name not in self._uploaded_sessions and d.name != active: pending.append(d) return pending @@ -628,7 +629,7 @@ def _upload_worker(self, sessions: list[Path]): req = Request(self._api_url, data=payload, method="POST") req.add_header("Content-Type", "application/json") req.add_header("X-API-Key", self._api_key) - with _open(req, timeout=30) as resp: + with _open(req, timeout=90) as resp: result = json.loads(resp.read().decode()) imp = result.get("imported", 0) dup = result.get("duplicates", 0) @@ -672,7 +673,7 @@ def _upload_worker(self, sessions: list[Path]): req = Request(self._api_url, data=payload, method="POST") req.add_header("Content-Type", "application/json") req.add_header("X-API-Key", self._api_key) - with _open(req, timeout=30) as resp: + with _open(req, timeout=90) as resp: result = json.loads(resp.read().decode()) imp = result.get("imported", 0) self._log_add(f" Retry OK: +{imp}", 11) @@ -689,6 +690,14 @@ def _upload_worker(self, sessions: list[Path]): self._log_add(f" HTTP {e.code}: {body}", 8) except URLError as e: self._log_add(f" Connection error: {e.reason}", 8) + except (TimeoutError, OSError) as e: + # Upload likely succeeded but response timed out — mark as + # uploaded to prevent re-sending duplicate data on retry. + self._log_add( + f" Timeout — server may have received data. " + f"Marking uploaded to avoid duplicates.", 10) + self._mark_uploaded(session_dir) + uploaded += 1 except Exception as e: self._log_add(f" Error: {e}", 8) diff --git a/run.sh b/run.sh old mode 100644 new mode 100755 index 6a288b9..5665a45 --- a/run.sh +++ b/run.sh @@ -53,4 +53,10 @@ if [ "$(id -u)" -ne 0 ]; then fi fi -exec sudo -E "$VENV_PYTHON" -m watchdogs "$@" +# Wait for bridge PTY to be ready +for i in $(seq 1 10); do + [ -e /tmp/esp32-pty ] && break + sleep 0.5 +done + +exec sudo -E "$VENV_PYTHON" -m watchdogs "${@:-/tmp/esp32-pty}" diff --git a/setup.sh b/setup.sh old mode 100644 new mode 100755 diff --git a/watchdogs/app.py b/watchdogs/app.py index bf647cd..b014852 100644 --- a/watchdogs/app.py +++ b/watchdogs/app.py @@ -2507,6 +2507,18 @@ def _poll_sdr(self): if ac.icao not in self._sdr_aircraft_xp: self._sdr_aircraft_xp.add(ac.icao) self.gain_xp(10) + if self.loot: + lat = getattr(ac, 'lat', 0.0) or 0.0 + lon = getattr(ac, 'lon', 0.0) or 0.0 + self.loot.save_adsb_aircraft( + icao=ac.icao, + callsign=getattr(ac, 'callsign', '') or '', + lat=lat, + lon=lon, + alt_ft=int(getattr(ac, 'altitude', 0) or 0), + speed_kt=int(getattr(ac, 'speed', 0) or 0), + heading=int(getattr(ac, 'heading', 0) or 0), + ) elif etype == "sensor_new": self.gain_xp(5) except Exception: @@ -3757,6 +3769,7 @@ def _load_loot_totals(self): "et_captures": t.get("et_captures", 0), "mc_nodes": t.get("mc_nodes", 0), "mc_msgs": t.get("mc_messages", 0), + "adsb": t.get("adsb", 0), } except Exception: pass @@ -5541,7 +5554,7 @@ def _nfc_read(): lines = self._flipper.storage_list("/ext/nfc") files = [] for l in lines: - if l.startswith("[F]") and ".nfc" in l: + if "[F]" in l and ".nfc" in l: fname = l.split("]")[-1].strip().split(" ")[0] path = f"/ext/nfc/{fname}" display = fname.replace(".nfc", "") @@ -5579,7 +5592,7 @@ def _load_flipper_sd(self): name = l.split("/ext/subghz/")[-1] if "/" not in name and name not in ("assets", "playlist", "remote"): folders.add(name) - elif l.startswith("[F] /ext/subghz/") and ".sub" in l: + elif "[F]" in l and "/ext/subghz/" in l and ".sub" in l: path = l.split(" ")[1] if " " in l else l[4:] path = path.split(" ")[0] # remove size fname = path.split("/")[-1] @@ -5610,7 +5623,7 @@ def _load_flipper_folder(self, folder: str): lines = self._flipper.storage_list(f"/ext/subghz/{folder}") files = [] for l in lines: - if l.startswith("[F]") and ".sub" in l: + if "[F]" in l and ".sub" in l: fname = l.split("]")[-1].strip().split(" ")[0] path = f"/ext/subghz/{folder}/{fname}" files.append((path, fname)) @@ -7492,6 +7505,17 @@ def _draw_loot_screen(self): y += ROW_H pyxel.text(col1, y, f"GPS points", C_DIM) pyxel.text(col1 + 80, y, f"{len(self.loot_points)}", C_TEXT) + y += ROW_H + # Aircraft — prefer server-side deduped count from wardrive plugin + _ac_server = 0 + for _p in self._plugins: + if hasattr(_p, '_user_stats'): + _ac_server = _p._user_stats.get('aircraft', 0) + break + _ac_local = t.get('adsb', 0) + _ac_total = _ac_server if _ac_server > 0 else _ac_local + pyxel.text(col1, y, f"Aircraft", C_DIM) + pyxel.text(col1 + 80, y, f"{_ac_total}", 9) # orange # ─── COLUMN 2: THIS SESSION ─── y = 22 @@ -7517,6 +7541,10 @@ def _draw_loot_screen(self): y += ROW_H pyxel.text(col2, y, f"Hacked", C_DIM) pyxel.text(col2 + 70, y, f"{n_pwn}", C_SUCCESS) + y += ROW_H + n_ac_ses = len(self._sdr.aircraft) if self._sdr and self._sdr.running else 0 + pyxel.text(col2, y, f"Aircraft", C_DIM) + pyxel.text(col2 + 70, y, f"{n_ac_ses}", 9) # orange y += 14 # XP bar lv = self.level_title diff --git a/watchdogs/config.py b/watchdogs/config.py index 97baca8..68426b6 100644 --- a/watchdogs/config.py +++ b/watchdogs/config.py @@ -44,7 +44,7 @@ def _env(*keys: str, default: str = "") -> str: _secrets = _load_secrets() -BAUD_RATE = 115200 +BAUD_RATE = 460800 SCAN_TIMEOUT = 15 READ_TIMEOUT = 2 SNIFFER_UPDATE_INTERVAL = 1 @@ -151,7 +151,7 @@ def _env(*keys: str, default: str = "") -> str: # GPS module — default: AIO UART on uConsole # Override with env vars: WDG_GPS_DEVICE, WDG_GPS_BAUD (legacy: JANOS_GPS_*) -GPS_DEVICE = _env("WDG_GPS_DEVICE", "JANOS_GPS_DEVICE", default="/dev/ttyAMA0") +GPS_DEVICE = _env("WDG_GPS_DEVICE", "JANOS_GPS_DEVICE", default="/tmp/gps-pty") GPS_BAUD_RATE = int(_env("WDG_GPS_BAUD", "JANOS_GPS_BAUD", default="9600")) GPS_PRIVACY_NOISE_DEG = 0.01 # ±0.01° ≈ ±1.1km randomization in private mode diff --git a/watchdogs/flipper_manager.py b/watchdogs/flipper_manager.py index 13bc378..8fbbdf8 100644 --- a/watchdogs/flipper_manager.py +++ b/watchdogs/flipper_manager.py @@ -125,7 +125,7 @@ def ensure_connected(self) -> bool: pass return self.reconnect() - def send(self, cmd: str) -> list[str]: + def send(self, cmd: str, timeout: float = 2.0) -> list[str]: """Send command and return response lines (blocking, short timeout).""" if not self.connected: return [] @@ -138,7 +138,7 @@ def send(self, cmd: str) -> list[str]: self._ser.flush() time.sleep(0.3) lines = [] - deadline = time.time() + 2 + deadline = time.time() + timeout while time.time() < deadline: if self._ser.in_waiting: raw = self._ser.readline().decode(errors="replace").strip() @@ -247,7 +247,7 @@ def subghz_stop(self) -> None: def storage_list(self, path: str = "/ext/subghz") -> list[str]: """List files on Flipper SD card.""" - return self.send(f"storage list {path}") + return self.send(f"storage list {path}", timeout=5.0) def storage_read(self, path: str) -> str: """Read file content from Flipper SD card.""" @@ -304,7 +304,7 @@ def nfc_emulate(self, filepath: str) -> list[str]: """ self.send_async("nfc") time.sleep(0.5) - self.send_async(f"emulate {filepath}") + self.send_async(f"emulate -f {filepath}") def nfc_field(self, on: bool = True) -> None: """Toggle NFC field on/off.""" diff --git a/watchdogs/loot_manager.py b/watchdogs/loot_manager.py index 1206988..5596276 100644 --- a/watchdogs/loot_manager.py +++ b/watchdogs/loot_manager.py @@ -248,7 +248,7 @@ def _scan_session_dir(self, session_path: Path) -> dict: """Count loot items in a single session directory.""" counts = {"pcap": 0, "hccapx": 0, "hc22000": 0, "passwords": 0, "et_captures": 0, "mc_nodes": 0, "mc_messages": 0, "bt_devices": 0, "bt_airtags": 0, - "bt_smarttags": 0, "bt_devices_gps": 0, "wardriving": 0, + "bt_smarttags": 0, "bt_devices_gps": 0, "wardriving": 0, "adsb": 0, "mitm_pcaps": 0} hs_dir = session_path / "handshakes" if hs_dir.is_dir(): @@ -339,6 +339,12 @@ def _scan_session_dir(self, session_path: Path) -> dict: counts["wardriving"] = max(0, lines - 2) # minus pre-header + header except OSError: pass + adsb_file = session_path / "adsb_aircraft.csv" + if adsb_file.is_file(): + try: + counts["adsb"] = max(0, sum(1 for _ in open(adsb_file, encoding="utf-8")) - 1) # minus header + except OSError: + pass mitm_dir = session_path / "mitm" if mitm_dir.is_dir(): try: @@ -353,7 +359,7 @@ def _recalc_totals(self, db: dict) -> None: """Recalculate totals from all session entries.""" keys = ("pcap", "hccapx", "hc22000", "passwords", "et_captures", "mc_nodes", "mc_messages", "bt_devices", "bt_airtags", "bt_smarttags", - "bt_devices_gps", "wardriving") + "bt_devices_gps", "wardriving", "adsb") totals: dict = {k: 0 for k in keys} totals["sessions"] = len(db["sessions"]) for session_counts in db["sessions"].values(): @@ -1192,6 +1198,31 @@ def save_bt_airtag_event(self, airtags: int, smarttags: int) -> None: _sync_append(path, f"[{ts}] AirTags:{airtags} | SmartTags:{smarttags}\n") self.update_session_loot() + def save_adsb_aircraft(self, icao: str, callsign: str = "", + lat: float = 0.0, lon: float = 0.0, + alt_ft: int = 0, speed_kt: int = 0, + heading: int = 0) -> None: + """Append an ADS-B aircraft to adsb_aircraft.csv (dedup by ICAO). fsync'd.""" + if not self._session_active or not icao: + return + path = self._session / "adsb_aircraft.csv" + if path.is_file(): + try: + existing = path.read_text(encoding="utf-8") + except OSError: + existing = "" + if f",{icao}," in existing or f"\n{icao}," in existing: + return # already recorded this session + else: + _sync_write(path, "timestamp,icao,callsign,lat,lon,alt_ft,speed_kt,heading\n") + ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + _sync_append( + path, + f"{ts},{icao},{callsign or ''}," + f"{round(lat, 7)},{round(lon, 7)}," + f"{alt_ft},{speed_kt},{heading}\n") + self.update_session_loot() + # ------------------------------------------------------------------ # GPS point collection (for Map tab) # ------------------------------------------------------------------ diff --git a/wdg_wifi_bridge(packet_sniff).py b/wdg_wifi_bridge(packet_sniff).py new file mode 100644 index 0000000..cef1187 --- /dev/null +++ b/wdg_wifi_bridge(packet_sniff).py @@ -0,0 +1,483 @@ +#!/usr/bin/env python3 +""" +WatchDogsGo Linux WiFi + BLE Bridge +Emulates an ESP32 projectZero device over a virtual serial port, +using the host's WiFi adapter for scanning and built-in BT for BLE. + +Usage: + sudo python3 wdg_wifi_bridge.py --iface wlan1 --bt-iface hci1 --sniffer-iface wlan2 --pty /tmp/esp32-pty + +Then launch game with: + sudo ./run.sh /tmp/esp32-pty +""" + +import argparse +import asyncio +import logging +import os +import re +import subprocess +import sys +import time +import threading +import pty +import tty +import termios + +try: + from bleak import BleakScanner + BLEAK_AVAILABLE = True +except ImportError: + BLEAK_AVAILABLE = False + +log = logging.getLogger("wdg_bridge") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +FIRMWARE_VERSION = "1.0.0" +BOOT_BANNER = f"WatchDogsGo version: v{FIRMWARE_VERSION}\r\n" + +# Auth type mapping from iw scan output +def _auth_from_iw(security: str) -> str: + s = security.upper() + if "WPA3" in s: + return "WPA3" + if "WPA2" in s and "WPA " in s: + return "WPA/WPA2" + if "WPA2" in s: + return "WPA2" + if "WPA" in s: + return "WPA" + if "WEP" in s: + return "WEP" + return "OPEN" + +# Band from frequency +def _band_from_freq(freq_mhz: int) -> str: + if freq_mhz >= 5925: + return "6GHz" + if freq_mhz >= 5000: + return "5GHz" + return "2.4GHz" + +# OUI vendor lookup (minimal, offline) +_OUI = { + "00:50:F2": "Microsoft", + "00:0C:E7": "Apple", + "3C:5A:B4": "Google", + "74:FE:CE": "Netgear", + "C8:3A:35": "Tenda", + "00:1A:2B": "Cisco", +} + +def _vendor_from_bssid(bssid: str) -> str: + prefix = bssid.upper()[:8] + return _OUI.get(prefix, "") + +def scan_wifi(iface: str) -> list[dict]: + """Run iw scan on iface and return list of network dicts.""" + try: + result = subprocess.run( + ["iw", "dev", iface, "scan"], + capture_output=True, text=True, timeout=15 + ) + except subprocess.TimeoutExpired: + log.warning("iw scan timed out") + return [] + except Exception as e: + log.error("iw scan failed: %s", e) + return [] + + networks = [] + current = {} + + for line in result.stdout.splitlines(): + line = line.strip() + + # New BSS block + m = re.match(r"BSS ([0-9a-f:]{17})", line, re.IGNORECASE) + if m: + if current.get("bssid"): + networks.append(current) + current = {"bssid": m.group(1).upper(), "ssid": "", "channel": "0", + "rssi": "-80", "security": "", "freq": 2412} + continue + + if not current: + continue + + m = re.match(r"SSID: (.+)", line) + if m: + current["ssid"] = m.group(1).strip() + continue + + m = re.match(r"freq: (\d+)", line) + if m: + current["freq"] = int(m.group(1)) + continue + + m = re.match(r"DS Parameter set: channel (\d+)", line) + if m: + current["channel"] = m.group(1) + continue + + m = re.match(r"\* primary channel: (\d+)", line) + if m: + current["channel"] = m.group(1) + continue + + m = re.match(r"signal: ([-\d.]+) dBm", line) + if m: + current["rssi"] = str(int(float(m.group(1)))) + continue + + if "WPA" in line or "RSN" in line or "WEP" in line: + current["security"] += " " + line.strip() + + if current.get("bssid"): + networks.append(current) + + return networks + + +def format_network_csv(index: int, net: dict) -> str: + """Format a network dict as the CSV line WatchDogsGo expects.""" + ssid = net.get("ssid", "") or "" + bssid = net.get("bssid", "") + channel = net.get("channel", "0") + rssi = net.get("rssi", "-80") + auth = _auth_from_iw(net.get("security", "")) + band = _band_from_freq(net.get("freq", 2412)) + vendor = _vendor_from_bssid(bssid) + return f'"{index}","{ssid}","{vendor}","{bssid}","{channel}","{auth}","{rssi}","{band}"\r\n' + + +async def _ble_scan_async(bt_iface: str, duration: float = 8.0) -> list[dict]: + """Async BLE scan using bleak 3.x API.""" + devices = [] + try: + results = await BleakScanner.discover( + timeout=duration, + adapter=bt_iface, + return_adv=True, + ) + for addr, (device, adv_data) in results.items(): + rssi = adv_data.rssi if adv_data.rssi is not None else -99 + name = device.name or adv_data.local_name or "" + devices.append({ + "mac": addr, + "rssi": rssi, + "name": name, + }) + except Exception as e: + log.warning("BLE scan error: %s", e) + return devices + + +def scan_ble(bt_iface: str, duration: float = 8.0) -> list[dict]: + """Run BLE scan and return list of device dicts.""" + if not BLEAK_AVAILABLE: + log.warning("bleak not installed — BLE scanning disabled") + return [] + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + result = loop.run_until_complete(_ble_scan_async(bt_iface, duration)) + loop.close() + return result + except Exception as e: + log.error("BLE scan failed: %s", e) + return [] + + +def format_ble_line(index: int, device: dict) -> str: + """Format a BLE device as the line WatchDogsGo expects. + Format: '1. AA:BB:CC:DD:EE:FF RSSI: -65 dBm Name: DeviceName' + """ + mac = device.get("mac", "00:00:00:00:00:00") + rssi = device.get("rssi", -99) + name = device.get("name", "") + if name: + return f"{index}. {mac} RSSI: {rssi} dBm Name: {name}\r\n" + return f"{index}. {mac} RSSI: {rssi} dBm\r\n" + + +def set_monitor_mode(iface: str) -> bool: + """Put interface into monitor mode. Returns True on success.""" + try: + subprocess.run(["ip", "link", "set", iface, "down"], check=True) + subprocess.run(["iw", "dev", iface, "set", "type", "monitor"], check=True) + subprocess.run(["ip", "link", "set", iface, "up"], check=True) + log.info("Set %s to monitor mode", iface) + return True + except subprocess.CalledProcessError as e: + log.error("Failed to set monitor mode: %s", e) + return False + + +def restore_managed_mode(iface: str): + """Restore interface to managed mode.""" + try: + subprocess.run(["ip", "link", "set", iface, "down"], check=False) + subprocess.run(["iw", "dev", iface, "set", "type", "managed"], check=False) + subprocess.run(["ip", "link", "set", iface, "up"], check=False) + log.info("Restored %s to managed mode", iface) + except Exception as e: + log.warning("Could not restore managed mode: %s", e) + + +class WifiBridge: + def __init__(self, iface: str, pty_path: str, bt_iface: str = "hci0", + sniffer_iface: str = "wlan2", loot_dir: str = ""): + self.iface = iface + self.pty_path = pty_path + self.bt_iface = bt_iface + self.sniffer_iface = sniffer_iface + self.loot_dir = loot_dir or os.path.expanduser("~/python/WatchDogsGo/loot") + self._master_fd = None + self._slave_fd = None + self._running = False + self._scan_requested = False + self._lock = threading.Lock() + # Packet sniff state + self._pkt_proc = None + self._pkt_sniff_active = False + self._pkt_count = 0 + self._pkt_file = "" + + def start(self): + # Create PTY + self._master_fd, self._slave_fd = pty.openpty() + slave_name = os.ttyname(self._slave_fd) + + # Symlink to requested path + if os.path.exists(self.pty_path) or os.path.islink(self.pty_path): + os.unlink(self.pty_path) + os.symlink(slave_name, self.pty_path) + log.info("PTY: %s -> %s", self.pty_path, slave_name) + + # Set raw mode + tty.setraw(self._master_fd) + + self._running = True + + # Send boot banner so game detects firmware version + self._write(BOOT_BANNER) + + # Start reader thread + t = threading.Thread(target=self._read_loop, daemon=True) + t.start() + + log.info("Bridge running. Waiting for game commands on %s", self.pty_path) + + try: + while self._running: + time.sleep(0.1) + except KeyboardInterrupt: + pass + finally: + self.stop() + + def stop(self): + self._running = False + self._stop_pkt_sniff() + if os.path.islink(self.pty_path): + os.unlink(self.pty_path) + if self._master_fd: + os.close(self._master_fd) + + def _write(self, data: str): + try: + os.write(self._master_fd, data.encode()) + except OSError: + pass + + def _read_loop(self): + buf = b"" + while self._running: + try: + chunk = os.read(self._master_fd, 256) + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + cmd = line.decode("utf-8", errors="replace").strip() + if cmd: + self._handle_command(cmd) + except OSError: + break + except Exception as e: + log.debug("Read error: %s", e) + + def _handle_command(self, cmd: str): + log.info("CMD: %s", cmd) + + if cmd == "scan_networks": + threading.Thread(target=self._do_scan, daemon=True).start() + + elif cmd == "ping": + self._write(f"pong v{FIRMWARE_VERSION}\r\n") + + elif cmd == "stop": + self._stop_pkt_sniff() + self._write("all stopped\r\n") + + elif cmd == "scan_bt": + threading.Thread(target=self._do_ble_scan, daemon=True).start() + + elif cmd in ("start_pkt_sniff", "start_sniffer"): + threading.Thread(target=self._do_pkt_sniff, daemon=True).start() + + elif cmd in ("stop_pkt_sniff", "stop_sniffer", "sniffer_stop"): + self._stop_pkt_sniff() + + else: + log.debug("Unknown command: %s", cmd) + + def _do_scan(self): + log.info("Scanning on %s...", self.iface) + networks = scan_wifi(self.iface) + log.info("Found %d networks", len(networks)) + + for i, net in enumerate(networks): + line = format_network_csv(i, net) + self._write(line) + time.sleep(0.01) + + self._write("scan results printed\r\n") + + def _do_ble_scan(self): + log.info("BLE scanning on %s...", self.bt_iface) + devices = scan_ble(self.bt_iface, duration=8.0) + log.info("Found %d BLE devices", len(devices)) + + for i, device in enumerate(devices): + line = format_ble_line(i + 1, device) + self._write(line) + time.sleep(0.01) + + self._write("BLE scan done\r\n") + + # ------------------------------------------------------------------ + # Packet sniffer (AWUS036ACM / wlan2 in monitor mode) + # ------------------------------------------------------------------ + + def _do_pkt_sniff(self): + """Put sniffer_iface in monitor mode and capture packets with tcpdump.""" + if self._pkt_sniff_active: + self._write("pkt_sniff already running\r\n") + return + + log.info("Starting packet sniff on %s", self.sniffer_iface) + + if not set_monitor_mode(self.sniffer_iface): + self._write(f"pkt_sniff error: could not set {self.sniffer_iface} to monitor mode\r\n") + return + + # Build output path inside current loot session dir + ts = time.strftime("%Y%m%d_%H%M%S") + out_dir = self.loot_dir + os.makedirs(out_dir, exist_ok=True) + self._pkt_file = os.path.join(out_dir, f"pkt_sniff_{ts}.pcapng") + + try: + self._pkt_proc = subprocess.Popen( + ["tcpdump", "-i", self.sniffer_iface, "-w", self._pkt_file, + "--immediate-mode", "-U"], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except Exception as e: + log.error("tcpdump failed to start: %s", e) + self._write(f"pkt_sniff error: {e}\r\n") + restore_managed_mode(self.sniffer_iface) + return + + self._pkt_sniff_active = True + self._pkt_count = 0 + self._write(f"sniffer start — saving to {self._pkt_file}\r\n") + log.info("tcpdump running, saving to %s", self._pkt_file) + + # Count packets by polling tcpdump stderr for stats + threading.Thread(target=self._pkt_counter_loop, daemon=True).start() + + def _pkt_counter_loop(self): + """Read tcpdump stderr for packet counts and relay to game.""" + if not self._pkt_proc: + return + try: + for line in self._pkt_proc.stderr: + if not self._pkt_sniff_active: + break + text = line.decode("utf-8", errors="replace").strip() + log.debug("tcpdump: %s", text) + # tcpdump prints "N packets captured" on exit + m = re.search(r"(\d+) packets captured", text) + if m: + self._pkt_count = int(m.group(1)) + self._write(f"pkt_count {self._pkt_count}\r\n") + except Exception: + pass + + def _stop_pkt_sniff(self): + """Stop packet capture and restore managed mode.""" + if not self._pkt_sniff_active: + return + self._pkt_sniff_active = False + if self._pkt_proc: + try: + self._pkt_proc.terminate() + self._pkt_proc.wait(timeout=3) + except Exception: + try: + self._pkt_proc.kill() + except Exception: + pass + self._pkt_proc = None + restore_managed_mode(self.sniffer_iface) + self._write(f"pkt_sniff stopped — saved to {self._pkt_file}\r\n") + log.info("Packet sniff stopped, file: %s", self._pkt_file) + + +def main(): + parser = argparse.ArgumentParser(description="WatchDogsGo Linux WiFi + BLE Bridge") + parser.add_argument("--iface", default="wlan1", + help="WiFi interface for scanning (default: wlan1)") + parser.add_argument("--bt-iface", default="hci0", + help="Bluetooth interface for BLE scanning (default: hci0)") + parser.add_argument("--sniffer-iface", default="wlan2", + help="WiFi interface for packet sniff/HS capture (default: wlan2)") + parser.add_argument("--pty", default="/tmp/esp32-pty", + help="PTY symlink path for WatchDogsGo (default: /tmp/esp32-pty)") + parser.add_argument("--no-monitor", action="store_true", + help="Skip setting monitor mode on --iface (wlan1)") + parser.add_argument("--loot-dir", default="", + help="Directory to save packet captures (default: ~/python/WatchDogsGo/loot)") + args = parser.parse_args() + + if os.geteuid() != 0: + print("ERROR: Must run as root (sudo)", file=sys.stderr) + sys.exit(1) + + if not BLEAK_AVAILABLE: + log.warning("bleak not installed — BLE scanning disabled. Install with: pip install bleak --break-system-packages") + + if not args.no_monitor: + if not set_monitor_mode(args.iface): + print(f"ERROR: Could not set {args.iface} to monitor mode", file=sys.stderr) + sys.exit(1) + + bridge = WifiBridge( + iface=args.iface, + pty_path=args.pty, + bt_iface=args.bt_iface, + sniffer_iface=args.sniffer_iface, + loot_dir=args.loot_dir, + ) + try: + bridge.start() + finally: + if not args.no_monitor: + restore_managed_mode(args.iface) + + +if __name__ == "__main__": + main() diff --git a/wdg_wifi_bridge.py b/wdg_wifi_bridge.py new file mode 100644 index 0000000..46bbee3 --- /dev/null +++ b/wdg_wifi_bridge.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +""" +WatchDogsGo Linux WiFi + BLE Bridge +Emulates an ESP32 projectZero device over a virtual serial port, +using the host's WiFi adapter for scanning and built-in BT for BLE. + +Usage: + sudo python3 wdg_wifi_bridge.py --iface wlan1 --bt-iface hci0 --sniffer-iface wlan2 --pty /tmp/esp32-pty + +Then launch game with: + sudo ./run.sh /tmp/esp32-pty + +Changes from original (FusedStamen fork): + - BLE fast-fail detection: times each scan and logs a warning if it completes + in under 2 seconds with 0 results, indicating the Bluetooth adapter has dropped + - Packet sniffer: start_sniffer/stop_sniffer commands put wlan2 (AWUS036ACM) + in monitor mode and capture raw 802.11 frames to pcapng via tcpdump, + saved to the loot session directory + - Handshake capture: start_handshake/start_handshake_serial commands use + airodump-ng on wlan2 for WPA handshake and PMKID capture; polls with + hcxpcapngtool every 10 seconds to detect new hashes and fires the game's + handshake event (SSID:/AP: format) triggering 200 XP + badge + map marker + - wlan1 restore: after stopping handshake capture, wlan1 is brought back up + since airodump-ng can bring it down during capture + - --sniffer-iface: new argument for dedicated capture interface (default wlan2), + separate from the wardriving interface (wlan1) + - --loot-dir: new argument to specify where packet captures are saved + +Dependencies: bleak, airodump-ng, hcxpcapngtool, tcpdump, iw +""" + +import argparse +import asyncio +import logging +import os +import re +import subprocess +import sys +import time +import threading +import pty +import tty +import termios + +try: + from bleak import BleakScanner + BLEAK_AVAILABLE = True +except ImportError: + BLEAK_AVAILABLE = False + +log = logging.getLogger("wdg_bridge") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +FIRMWARE_VERSION = "1.0.0" +BOOT_BANNER = f"WatchDogsGo version: v{FIRMWARE_VERSION}\r\n" + +def _auth_from_iw(security: str) -> str: + s = security.upper() + if "WPA3" in s: + return "WPA3" + if "WPA2" in s and "WPA " in s: + return "WPA/WPA2" + if "WPA2" in s: + return "WPA2" + if "WPA" in s: + return "WPA" + if "WEP" in s: + return "WEP" + return "OPEN" + +def _band_from_freq(freq_mhz: int) -> str: + if freq_mhz >= 5925: + return "6GHz" + if freq_mhz >= 5000: + return "5GHz" + return "2.4GHz" + +_OUI = { + "00:50:F2": "Microsoft", + "00:0C:E7": "Apple", + "3C:5A:B4": "Google", + "74:FE:CE": "Netgear", + "C8:3A:35": "Tenda", + "00:1A:2B": "Cisco", +} + +def _vendor_from_bssid(bssid: str) -> str: + prefix = bssid.upper()[:8] + return _OUI.get(prefix, "") + +def scan_wifi(iface: str) -> list[dict]: + try: + result = subprocess.run( + ["iw", "dev", iface, "scan"], + capture_output=True, text=True, timeout=15 + ) + except subprocess.TimeoutExpired: + log.warning("iw scan timed out") + return [] + except Exception as e: + log.error("iw scan failed: %s", e) + return [] + + networks = [] + current = {} + + for line in result.stdout.splitlines(): + line = line.strip() + m = re.match(r"BSS ([0-9a-f:]{17})", line, re.IGNORECASE) + if m: + if current.get("bssid"): + networks.append(current) + current = {"bssid": m.group(1).upper(), "ssid": "", "channel": "0", + "rssi": "-80", "security": "", "freq": 2412} + continue + if not current: + continue + m = re.match(r"SSID: (.+)", line) + if m: + current["ssid"] = m.group(1).strip() + continue + m = re.match(r"freq: (\d+)", line) + if m: + current["freq"] = int(m.group(1)) + continue + m = re.match(r"DS Parameter set: channel (\d+)", line) + if m: + current["channel"] = m.group(1) + continue + m = re.match(r"\* primary channel: (\d+)", line) + if m: + current["channel"] = m.group(1) + continue + m = re.match(r"signal: ([-\d.]+) dBm", line) + if m: + current["rssi"] = str(int(float(m.group(1)))) + continue + if "WPA" in line or "RSN" in line or "WEP" in line: + current["security"] += " " + line.strip() + + if current.get("bssid"): + networks.append(current) + return networks + + +def format_network_csv(index: int, net: dict) -> str: + ssid = net.get("ssid", "") or "" + bssid = net.get("bssid", "") + channel = net.get("channel", "0") + rssi = net.get("rssi", "-80") + auth = _auth_from_iw(net.get("security", "")) + band = _band_from_freq(net.get("freq", 2412)) + vendor = _vendor_from_bssid(bssid) + return f'"{index}","{ssid}","{vendor}","{bssid}","{channel}","{auth}","{rssi}","{band}"\r\n' + + +async def _ble_scan_async(bt_iface: str, duration: float = 8.0) -> list[dict]: + devices = [] + try: + results = await BleakScanner.discover( + timeout=duration, + adapter=bt_iface, + return_adv=True, + ) + for addr, (device, adv_data) in results.items(): + rssi = adv_data.rssi if adv_data.rssi is not None else -99 + name = device.name or adv_data.local_name or "" + devices.append({"mac": addr, "rssi": rssi, "name": name}) + except Exception as e: + log.warning("BLE scan error: %s", e) + return devices + + +def scan_ble(bt_iface: str, duration: float = 8.0) -> list[dict]: + if not BLEAK_AVAILABLE: + log.warning("bleak not installed — BLE scanning disabled") + return [] + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + result = loop.run_until_complete(_ble_scan_async(bt_iface, duration)) + loop.close() + return result + except Exception as e: + log.error("BLE scan failed: %s", e) + return [] + + +def format_ble_line(index: int, device: dict) -> str: + mac = device.get("mac", "00:00:00:00:00:00") + rssi = device.get("rssi", -99) + name = device.get("name", "") + if name: + return f"{index}. {mac} RSSI: {rssi} dBm Name: {name}\r\n" + return f"{index}. {mac} RSSI: {rssi} dBm\r\n" + + +def set_monitor_mode(iface: str) -> bool: + try: + subprocess.run(["ip", "link", "set", iface, "down"], check=True) + subprocess.run(["iw", "dev", iface, "set", "type", "monitor"], check=True) + subprocess.run(["ip", "link", "set", iface, "up"], check=True) + log.info("Set %s to monitor mode", iface) + return True + except subprocess.CalledProcessError as e: + log.error("Failed to set monitor mode: %s", e) + return False + + +def restore_managed_mode(iface: str): + try: + subprocess.run(["ip", "link", "set", iface, "down"], check=False) + subprocess.run(["iw", "dev", iface, "set", "type", "managed"], check=False) + subprocess.run(["ip", "link", "set", iface, "up"], check=False) + log.info("Restored %s to managed mode", iface) + except Exception as e: + log.warning("Could not restore managed mode: %s", e) + + +class WifiBridge: + def __init__(self, iface: str, pty_path: str, bt_iface: str = "hci0", + sniffer_iface: str = "wlan2", loot_dir: str = ""): + self.iface = iface + self.pty_path = pty_path + self.bt_iface = bt_iface + self.sniffer_iface = sniffer_iface + self.loot_dir = loot_dir or os.path.expanduser("~/python/WatchDogsGo/loot") + self._master_fd = None + self._slave_fd = None + self._running = False + self._scan_requested = False + self._lock = threading.Lock() + # Packet sniff state + self._pkt_proc = None + self._pkt_sniff_active = False + self._pkt_count = 0 + self._pkt_file = "" + # HS capture state + self._hs_proc = None + self._hs_active = False + self._hs_file = "" + self._hs_count = 0 + + def start(self): + self._master_fd, self._slave_fd = pty.openpty() + slave_name = os.ttyname(self._slave_fd) + if os.path.exists(self.pty_path) or os.path.islink(self.pty_path): + os.unlink(self.pty_path) + os.symlink(slave_name, self.pty_path) + log.info("PTY: %s -> %s", self.pty_path, slave_name) + tty.setraw(self._master_fd) + self._running = True + self._write(BOOT_BANNER) + t = threading.Thread(target=self._read_loop, daemon=True) + t.start() + log.info("Bridge running. Waiting for game commands on %s", self.pty_path) + try: + while self._running: + time.sleep(0.1) + except KeyboardInterrupt: + pass + finally: + self.stop() + + def stop(self): + self._running = False + self._stop_pkt_sniff() + self._stop_hs_capture() + if os.path.islink(self.pty_path): + os.unlink(self.pty_path) + if self._master_fd: + os.close(self._master_fd) + + def _write(self, data: str): + try: + os.write(self._master_fd, data.encode()) + except OSError: + pass + + def _read_loop(self): + buf = b"" + while self._running: + try: + chunk = os.read(self._master_fd, 256) + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + cmd = line.decode("utf-8", errors="replace").strip() + if cmd: + self._handle_command(cmd) + except OSError: + break + except Exception as e: + log.debug("Read error: %s", e) + + def _handle_command(self, cmd: str): + log.info("CMD: %s", cmd) + + if cmd == "scan_networks": + threading.Thread(target=self._do_scan, daemon=True).start() + elif cmd == "ping": + self._write(f"pong v{FIRMWARE_VERSION}\r\n") + elif cmd == "stop": + self._stop_pkt_sniff() + self._stop_hs_capture() + self._write("all stopped\r\n") + elif cmd == "scan_bt": + threading.Thread(target=self._do_ble_scan, daemon=True).start() + elif cmd in ("start_pkt_sniff", "start_sniffer"): + threading.Thread(target=self._do_pkt_sniff, daemon=True).start() + elif cmd in ("stop_pkt_sniff", "stop_sniffer", "sniffer_stop"): + self._stop_pkt_sniff() + elif cmd in ("start_handshake", "start_handshake_serial"): + threading.Thread(target=self._do_hs_capture, daemon=True).start() + elif cmd == "stop_handshake": + self._stop_hs_capture() + else: + log.debug("Unknown command: %s", cmd) + + def _do_scan(self): + log.info("Scanning on %s...", self.iface) + networks = scan_wifi(self.iface) + log.info("Found %d networks", len(networks)) + for i, net in enumerate(networks): + self._write(format_network_csv(i, net)) + time.sleep(0.01) + self._write("scan results printed\r\n") + + def _do_ble_scan(self): + log.info("BLE scanning on %s...", self.bt_iface) + t_start = time.time() + devices = scan_ble(self.bt_iface, duration=8.0) + elapsed = time.time() - t_start + # Fast-fail detection: if scan completed in under 2s with 0 results + # the adapter has likely dropped out + if elapsed < 2.0 and len(devices) == 0: + log.warning("BLE scan completed in %.1fs with 0 results — adapter may have dropped (hci=%s)", + elapsed, self.bt_iface) + self._write(f"BLE adapter warning: scan returned in {elapsed:.1f}s with 0 devices\r\n") + else: + log.info("Found %d BLE devices in %.1fs", len(devices), elapsed) + for i, device in enumerate(devices): + self._write(format_ble_line(i + 1, device)) + time.sleep(0.01) + self._write("BLE scan done\r\n") + + # ------------------------------------------------------------------ + # Packet sniffer (AWUS036ACM / wlan2 in monitor mode via tcpdump) + # ------------------------------------------------------------------ + + def _do_pkt_sniff(self): + if self._pkt_sniff_active: + self._write("pkt_sniff already running\r\n") + return + log.info("Starting packet sniff on %s", self.sniffer_iface) + if not set_monitor_mode(self.sniffer_iface): + self._write(f"pkt_sniff error: could not set {self.sniffer_iface} to monitor mode\r\n") + return + ts = time.strftime("%Y%m%d_%H%M%S") + os.makedirs(self.loot_dir, exist_ok=True) + self._pkt_file = os.path.join(self.loot_dir, f"pkt_sniff_{ts}.pcapng") + try: + self._pkt_proc = subprocess.Popen( + ["tcpdump", "-i", self.sniffer_iface, "-w", self._pkt_file, + "--immediate-mode", "-U"], + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + except Exception as e: + log.error("tcpdump failed to start: %s", e) + self._write(f"pkt_sniff error: {e}\r\n") + restore_managed_mode(self.sniffer_iface) + return + self._pkt_sniff_active = True + self._pkt_count = 0 + self._write(f"sniffer start — saving to {self._pkt_file}\r\n") + log.info("tcpdump running, saving to %s", self._pkt_file) + threading.Thread(target=self._pkt_counter_loop, daemon=True).start() + + def _pkt_counter_loop(self): + if not self._pkt_proc: + return + try: + for line in self._pkt_proc.stderr: + if not self._pkt_sniff_active: + break + text = line.decode("utf-8", errors="replace").strip() + log.debug("tcpdump: %s", text) + m = re.search(r"(\d+) packets captured", text) + if m: + self._pkt_count = int(m.group(1)) + self._write(f"pkt_count {self._pkt_count}\r\n") + except Exception: + pass + + def _stop_pkt_sniff(self): + if not self._pkt_sniff_active: + return + self._pkt_sniff_active = False + if self._pkt_proc: + try: + self._pkt_proc.terminate() + self._pkt_proc.wait(timeout=3) + except Exception: + try: + self._pkt_proc.kill() + except Exception: + pass + self._pkt_proc = None + restore_managed_mode(self.sniffer_iface) + self._write(f"pkt_sniff stopped — saved to {self._pkt_file}\r\n") + log.info("Packet sniff stopped, file: %s", self._pkt_file) + + # ------------------------------------------------------------------ + # Handshake capture (AWUS036ACM / wlan2 via hcxdumptool) + # ------------------------------------------------------------------ + + def _do_hs_capture(self): + if self._hs_active: + self._write("handshake capture already running\r\n") + return + if not self._check_tool("airodump-ng"): + self._write("handshake error: airodump-ng not installed\r\n") + return + log.info("Starting HS capture on %s", self.sniffer_iface) + ts = time.strftime("%Y%m%d_%H%M%S") + hs_dir = os.path.join(self.loot_dir, "handshakes") + os.makedirs(hs_dir, exist_ok=True) + # airodump-ng appends -01.pcapng so we give it a prefix + self._hs_prefix = os.path.join(hs_dir, f"hs_{ts}") + self._hs_file = self._hs_prefix + "-01.cap" + self._hs_count = 0 + # Put interface in monitor mode first + if not set_monitor_mode(self.sniffer_iface): + self._write(f"handshake error: could not set {self.sniffer_iface} to monitor mode\r\n") + return + try: + self._hs_proc = subprocess.Popen( + ["airodump-ng", self.sniffer_iface, + "-w", self._hs_prefix, + "--output-format", "pcapng"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as e: + log.error("airodump-ng failed to start: %s", e) + self._write(f"handshake error: {e}\r\n") + restore_managed_mode(self.sniffer_iface) + return + self._hs_active = True + self._write(f"handshake capture started — saving to {self._hs_file}\r\n") + log.info("airodump-ng running, saving to %s", self._hs_file) + threading.Thread(target=self._hs_output_loop, daemon=True).start() + + def _hs_output_loop(self): + """Poll hcxpcapngtool every 10s to detect new handshakes/PMKIDs.""" + poll_interval = 10.0 + last_count = 0 + seen_bssids = set() + hash_file = self._hs_file + ".hc22000" + + while self._hs_active: + time.sleep(poll_interval) + if not self._hs_active: + break + if not os.path.exists(self._hs_file): + continue + try: + result = subprocess.run( + ["hcxpcapngtool", self._hs_file, "-o", hash_file], + capture_output=True, text=True, timeout=15 + ) + output = result.stdout + result.stderr + eapol_m = re.search(r"EAPOL pairs written to 22000 hash file[^:]*:\s*(\d+)", output) + pmkid_m = re.search(r"PMKID written to 22000 hash file[^:]*:\s*(\d+)", output) + eapol_count = int(eapol_m.group(1)) if eapol_m else 0 + pmkid_count = int(pmkid_m.group(1)) if pmkid_m else 0 + total = eapol_count + pmkid_count + + if total > last_count: + new_captures = total - last_count + last_count = total + log.info("New hashes: %d EAPOL + %d PMKID", eapol_count, pmkid_count) + + if os.path.exists(hash_file): + try: + with open(hash_file, "r") as hf: + for line in hf: + parts = line.strip().split("*") + if len(parts) >= 4: + bssid_hex = parts[1] + essid_hex = parts[3] + try: + bssid = ":".join( + bssid_hex[i:i+2] + for i in range(0, 12, 2) + ).upper() + essid = bytes.fromhex(essid_hex).decode("utf-8", errors="replace") + except Exception: + bssid = bssid_hex + essid = "" + if bssid not in seen_bssids: + seen_bssids.add(bssid) + self._hs_count += 1 + cap_type = "PMKID" if parts[0].startswith("22301") else "EAPOL" + log.info("Handshake: %s %s (%s)", bssid, essid, cap_type) + # Game triggers on: line starts with SSID: and contains AP: + self._write(f"SSID:{essid} AP:{bssid}\r\n") + except Exception as e: + log.debug("Hash file parse error: %s", e) + for _ in range(new_captures): + self._write("SSID:unknown AP:00:00:00:00:00:00\r\n") + self._hs_count += 1 + except subprocess.TimeoutExpired: + log.warning("hcxpcapngtool timed out") + except Exception as e: + log.debug("HS poll error: %s", e) + + def _stop_hs_capture(self): + if not self._hs_active: + return + self._hs_active = False + if self._hs_proc: + try: + self._hs_proc.terminate() + self._hs_proc.wait(timeout=3) + except Exception: + try: + self._hs_proc.kill() + except Exception: + pass + self._hs_proc = None + # Restore wlan2 to managed mode and bring wlan1 back up + # (hcxdumptool takes down all interfaces during capture) + restore_managed_mode(self.sniffer_iface) + subprocess.run(["ip", "link", "set", self.iface, "up"], check=False) + self._write( + f"handshake capture stopped — {self._hs_count} captured, " + f"saved to {self._hs_file}\r\n") + log.info("HS capture stopped, %d captured, file: %s", self._hs_count, self._hs_file) + + @staticmethod + def _check_tool(name: str) -> bool: + import shutil + return shutil.which(name) is not None + + +def main(): + parser = argparse.ArgumentParser(description="WatchDogsGo Linux WiFi + BLE Bridge") + parser.add_argument("--iface", default="wlan1", + help="WiFi interface for scanning (default: wlan1)") + parser.add_argument("--bt-iface", default="hci0", + help="Bluetooth interface for BLE scanning (default: hci0)") + parser.add_argument("--sniffer-iface", default="wlan2", + help="WiFi interface for packet sniff/HS capture (default: wlan2)") + parser.add_argument("--pty", default="/tmp/esp32-pty", + help="PTY symlink path for WatchDogsGo (default: /tmp/esp32-pty)") + parser.add_argument("--no-monitor", action="store_true", + help="Skip setting monitor mode on --iface (wlan1)") + parser.add_argument("--loot-dir", default="", + help="Directory to save captures (default: ~/python/WatchDogsGo/loot)") + args = parser.parse_args() + + if os.geteuid() != 0: + print("ERROR: Must run as root (sudo)", file=sys.stderr) + sys.exit(1) + + if not BLEAK_AVAILABLE: + log.warning("bleak not installed — BLE scanning disabled. " + "Install with: pip install bleak --break-system-packages") + + if not args.no_monitor: + if not set_monitor_mode(args.iface): + print(f"ERROR: Could not set {args.iface} to monitor mode", file=sys.stderr) + sys.exit(1) + + bridge = WifiBridge( + iface=args.iface, + pty_path=args.pty, + bt_iface=args.bt_iface, + sniffer_iface=args.sniffer_iface, + loot_dir=args.loot_dir, + ) + try: + bridge.start() + finally: + if not args.no_monitor: + restore_managed_mode(args.iface) + + +if __name__ == "__main__": + main() diff --git a/whitelist.json b/whitelist.json new file mode 100644 index 0000000..92c0a1e --- /dev/null +++ b/whitelist.json @@ -0,0 +1,68 @@ +[ + { + "type": "wifi", + "mac": "74:FE:CE:13:AC:F0", + "name": "StamenLand", + "added_date": "2026-05-02 02:32" + }, + { + "type": "ble", + "mac": "C5:B6:EA:F5:CE:E7", + "name": "Hue bulb", + "added_date": "2026-05-02 02:32" + }, + { + "type": "ble", + "mac": "55:B6:18:DC:6F:D3", + "name": "Nanoleaf Strip 222", + "added_date": "2026-05-02 02:32" + }, + { + "type": "ble", + "mac": "F4:82:1C:25:3D:85", + "name": "S&B VOLCANO H", + "added_date": "2026-05-02 02:32" + }, + { + "type": "ble", + "mac": "74:51:EE:7B:76:91", + "name": "LG OLED65C1", + "added_date": "2026-05-02 02:33" + }, + { + "type": "ble", + "mac": "4A:2A:64:3F:C1:B2", + "name": "LG OLED55C9", + "added_date": "2026-05-02 02:33" + }, + { + "type": "wifi", + "mac": "B6:FE:CE:13:AC:EF", + "name": "StamenTech", + "added_date": "2026-05-02 02:34" + }, + { + "type": "wifi", + "mac": "74:FE:CE:13:AC:EF", + "name": "StamenLand", + "added_date": "2026-05-02 02:38" + }, + { + "type": "wifi", + "mac": "74:FE:CE:13:AC:EE", + "name": "StamenLand", + "added_date": "2026-05-02 02:39" + }, + { + "type": "wifi", + "mac": "18:EE:86:31:11:5F", + "name": "StamenMobile", + "added_date": "2026-05-02 02:39" + }, + { + "type": "wifi", + "mac": "18:EE:86:31:11:60", + "name": "StamenMobile", + "added_date": "2026-05-02 02:40" + } +] \ No newline at end of file