diff --git a/liberate-v0.5.sh b/liberate-v0.5.sh new file mode 100644 index 0000000..f1ae8cc --- /dev/null +++ b/liberate-v0.5.sh @@ -0,0 +1,672 @@ +#!/bin/bash +# liberate.sh — AdaCam Liberation Script v0.5 +# +# Usage: ssh root@192.168.0.10 'bash -s' < liberate.sh +# +# v0.5 changes from v0.4: +# - NO AP/SSID/IP changes (keep factory 192.168.0.10) +# - NO firewall rules +# - NO reverse tunnel masking +# - Adds: SSH key deployment (safe path, no sshd_config changes) +# - Adds: adacam services install (capture, forwarder, wigle) embedded inline +# +# Philosophy: get working first, harden later. +# +# CRITICAL: Only /data is reliably writable. /etc may be writable. Assume /usr, /var read-only. + +set -euo pipefail + +ADACAM_DATA="/data/adacam" + +log() { echo "[liberate] $1"; } +ok() { echo "[liberate] ✓ $1"; } +warn() { echo "[liberate] ⚠ $1"; } +die() { echo "[liberate] ✗ FATAL: $1"; exit 1; } + +log "" +log "╔══════════════════════════════════════════╗" +log "║ AdaCam Liberation Script v0.5 ║" +log "║ Liberating bees from the hive ║" +log "╚══════════════════════════════════════════╝" +log "" + +[ "$(id -u)" = "0" ] || die "must run as root" + +if [ -f "$ADACAM_DATA/liberated" ]; then + die "device already liberated (marker exists at $ADACAM_DATA/liberated)" +fi + +# ── WRITABILITY CHECK ───────────────────────────────────────────────────────── +log "=== Checking filesystem writability ===" + +DATA_WRITABLE=false; ETC_WRITABLE=false; OPT_WRITABLE=false + +for d in /data /etc /opt; do + if touch "${d}/.adacam_test" 2>/dev/null; then + rm -f "${d}/.adacam_test" + ok "Writable: $d" + case "$d" in + /data) DATA_WRITABLE=true ;; + /etc) ETC_WRITABLE=true ;; + /opt) OPT_WRITABLE=true ;; + esac + else + warn "Read-only or missing: $d" + fi +done + +[ "$DATA_WRITABLE" = "true" ] || die "/data is not writable — cannot proceed" +[ -f /opt/odc-api/odc-api-bee.js ] || warn "odc-api not found — may not be a factory device" +ok "running on: $(uname -n)" + +# ── DETECT SERIAL ───────────────────────────────────────────────────────────── +log "" +log "=== Detecting device serial ===" + +SERIAL="" +SERIAL=$(cat /proc/device-tree/serial-number 2>/dev/null | tr -d '\0') || true +[ -z "$SERIAL" ] && SERIAL=$(ip link show wlp1s0f0 2>/dev/null | grep link/ether | awk '{print $2}' | tr -d ':') || true +[ -z "$SERIAL" ] && SERIAL=$(cat /sys/class/net/wlan0/address 2>/dev/null | tr -d ':') || true +[ -z "$SERIAL" ] && die "Cannot determine device serial" + +ok "device serial: $SERIAL" +API_TOKEN=$(echo -n "adacam-api-${SERIAL}-token" | sha256sum | cut -c1-32) +ok "API token: $API_TOKEN" + +# ── SETUP /data/adacam ──────────────────────────────────────────────────────── +log "" +log "=== Setting up /data/adacam ===" + +mkdir -p "$ADACAM_DATA/logs" "$ADACAM_DATA/cache" +echo "$SERIAL" > "$ADACAM_DATA/device_serial" +echo "$API_TOKEN" > "$ADACAM_DATA/api_token" +chmod 600 "$ADACAM_DATA/device_serial" "$ADACAM_DATA/api_token" + +cat > "$ADACAM_DATA/config.json" << CONFIG +{ + "device_id": "$SERIAL", + "ap_ip": "192.168.0.10", + "adamaps_key": "adamaps-ingest-2026", + "adamaps_api": "https://api.adamaps.org", + "liberated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", + "liberate_version": "0.5" +} +CONFIG +ok "config written" + +# ── PHASE 1: KILL HIVEMAPPER SERVICES ───────────────────────────────────────── +log "" +log "=== Phase 1: Stopping Hivemapper services ===" + +# NOTE: usb-updater is NOT touched — recovery path +KILL_SERVICES="odc-api mitmproxy mender-client here-plugin model-zoo collectd + hivemapper-data-logger hivemapper-folder-purger beekeeper-plugin + video-processor cpu-mem-logger vnstat vnstatd rm_vpu_daemon + dnf-automatic dnf-automatic-download dnf-automatic-install dnf-makecache" + +for svc in $KILL_SERVICES; do + if systemctl list-unit-files "${svc}.service" 2>/dev/null | grep -q "$svc"; then + systemctl stop "${svc}.service" 2>/dev/null || true + systemctl disable "${svc}.service" 2>/dev/null || true + systemctl mask "${svc}.service" 2>/dev/null || true + ok "killed: $svc" + fi +done + +# ── PHASE 2: BLOCK HIVEMAPPER DOMAINS ───────────────────────────────────────── +log "" +log "=== Phase 2: Blocking Hivemapper endpoints ===" + +if [ "$ETC_WRITABLE" = "true" ]; then + BLOCK_HOSTS="hivemapper.com api.hivemapper.com beemaps.com api.trybeekeeper.ai + docker.mender.io s3.mender.io direct.data.api.platform.here.com + api-lookup.data.api.platform.here.com account.api.here.com + edge.hereapi.com olp.here.com + dashcam-firmware.s3.us-west-2.amazonaws.com cfapi.cloudflare.com" + for host in $BLOCK_HOSTS; do + if ! grep -q "$host" /etc/hosts 2>/dev/null; then + echo "0.0.0.0 $host" >> /etc/hosts && ok "blocked: $host" || warn "failed: $host" + fi + done +else + warn "/etc not writable — skipping host blocking" +fi + +# ── PHASE 3: SSH KEYS ───────────────────────────────────────────────────────── +log "" +log "=== Phase 3: Deploying SSH keys ===" + +# Safe path — no sshd_config changes, no overlay touches +# Writing to /home/root/.ssh/ which persists (bind-mounted from /data/home at runtime) +SSH_DIR="/home/root/.ssh" +AUTH_KEYS="$SSH_DIR/authorized_keys" + +mkdir -p "$SSH_DIR" +chmod 700 "$SSH_DIR" + +# Write keys — these are the canonical AdaCam authorized keys +cat > "$AUTH_KEYS" << 'KEYS' +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK87jxvlXvo60pxwdtyJsXeFsb4KsAiFx4FnyXz81kh7 cobb@adacam +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOQxwJU91TCxds34P18D3xRbu7rxlrgTUoml/H8nxeDK kayos@openclaw +KEYS +chmod 600 "$AUTH_KEYS" +ok "SSH keys written to $AUTH_KEYS" +ok "Password auth preserved (no sshd_config changes)" + +# ── PHASE 4: INSTALL ADACAM SERVICES ────────────────────────────────────────── +log "" +log "=== Phase 4: Installing adacam services ===" + +# Determine install dir — prefer /opt/adacam, fall back to /data/adacam/bin +if [ "$OPT_WRITABLE" = "true" ]; then + INSTALL_DIR="/opt/adacam" + ok "Installing to /opt/adacam" +else + INSTALL_DIR="/data/adacam/bin" + warn "/opt not writable — installing to $INSTALL_DIR" +fi + +mkdir -p "$INSTALL_DIR/capture" +mkdir -p "$INSTALL_DIR/wigle" +mkdir -p /tmp/adacam/pics +mkdir -p /tmp/recording/pics + +# ── adacam-capture.py ───────────────────────────────────────────────────────── +cat > "$INSTALL_DIR/capture/adacam-capture.py" << 'PYEOF' +#!/usr/bin/env python3 +"""adacam-capture: GStreamer camera capture for AdaCam/Keem Bay.""" +import json, logging, os, signal, subprocess, sys, time +from pathlib import Path + +logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', stream=sys.stdout) +log = logging.getLogger('adacam-capture') + +CONFIG_PATH = Path('/data/adacam/config.json') +FRAME_DIR = Path('/tmp/adacam/pics') +PIPE_FRAME = Path('/tmp/recording/pics/cam0pipe.jpg') +VPU_FWNAME = '/sys/devices/platform/soc/soc:vpusmm/fwname' +MAX_FILES, MAX_BYTES = 1300, 500 * 1024 * 1024 +MODULES = ['kmb_flash', 'kmb_lens', 'kmb_cam', 'pwm_keembay', 'hantro'] +GST_PIPELINE = '''kmbcamsrc num-frames=-1 name=kmbsrc transform-hub=full awb-mode=daylight + stride-align=64 scanline-align=64 +! capsfilter caps="video/x-raw(memory:DMABuf),format=NV12,width=2028,height=1024,framerate=30/1" +! vaapijpegenc ! multifilesink location=/tmp/recording/pics/cam0pipe.jpg''' + +running, redis_client = True, None + +def load_config(): + if CONFIG_PATH.exists(): + with open(CONFIG_PATH) as f: return json.load(f) + return {'device_id': 'unknown', 'adamaps_key': '', 'adamaps_api': ''} + +def get_redis(): + global redis_client + if redis_client: + try: redis_client.ping(); return redis_client + except: redis_client = None + try: + import redis + redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True) + redis_client.ping(); log.info('Connected to Redis'); return redis_client + except Exception as e: log.warning(f'Redis unavailable: {e}'); return None + +def load_modules(): + try: + with open('/proc/modules') as f: loaded = {l.split()[0] for l in f} + except: return + for mod in MODULES: + if mod not in loaded: + try: subprocess.run(['modprobe', mod], check=True, capture_output=True) + except: log.warning(f'Failed to load {mod}') + +def load_vpu_firmware(): + if os.path.exists(VPU_FWNAME): + try: + with open(VPU_FWNAME, 'w') as f: f.write('luxonis_vpu.bin') + log.info('VPU firmware loaded') + except Exception as e: log.warning(f'VPU firmware error: {e}') + else: + try: subprocess.run(['StartVpu', 'luxonis_vpu.bin'], check=True, capture_output=True); log.info('VPU loaded via StartVpu') + except: log.warning('StartVpu not available') + +def start_gstreamer(): + PIPE_FRAME.parent.mkdir(parents=True, exist_ok=True) + FRAME_DIR.mkdir(parents=True, exist_ok=True) + log.info('Starting GStreamer pipeline') + return subprocess.Popen(['gst-launch-1.0', '-e'] + GST_PIPELINE.split(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + +def cleanup_frames(): + try: frames = sorted(FRAME_DIR.glob('*.jpg'), key=lambda p: p.stat().st_mtime) + except: return + while len(frames) > MAX_FILES: + try: frames[0].unlink(); frames.pop(0) + except: break + total = sum(f.stat().st_size for f in frames if f.exists()) + while total > MAX_BYTES and frames: + try: sz = frames[0].stat().st_size; frames[0].unlink(); frames.pop(0); total -= sz + except: break + +def process_frame(): + if not PIPE_FRAME.exists(): return False + try: + ts = int(time.time() * 1000) + dest = FRAME_DIR / f'{ts}.jpg' + os.rename(PIPE_FRAME, dest) + r = get_redis() + if r: + try: r.publish('adacam:frames', str(dest)) + except Exception as e: log.warning(f'Redis publish failed: {e}') + return True + except FileNotFoundError: return False + except Exception as e: log.error(f'Frame error: {e}'); return False + +def handle_signal(signum, frame): + global running + running = False + +def main(): + global running + signal.signal(signal.SIGTERM, handle_signal); signal.signal(signal.SIGINT, handle_signal) + config = load_config() + log.info(f"AdaCam Capture starting (device: {config.get('device_id', 'unknown')})") + load_modules(); load_vpu_firmware() + gst_proc, last_cleanup = None, 0 + while running: + if gst_proc is None or gst_proc.poll() is not None: + if gst_proc: time.sleep(2) + gst_proc = start_gstreamer() + frame_found = process_frame() + now = time.time() + if now - last_cleanup > 60: cleanup_frames(); last_cleanup = now + time.sleep(0.01 if frame_found else 0.05) + if gst_proc and gst_proc.poll() is None: + gst_proc.terminate() + try: gst_proc.wait(timeout=5) + except: gst_proc.kill() + +if __name__ == '__main__': main() +PYEOF +chmod +x "$INSTALL_DIR/capture/adacam-capture.py" +ok "adacam-capture.py installed" + +# ── adacam-forwarder.py ─────────────────────────────────────────────────────── +cat > "$INSTALL_DIR/capture/adacam-forwarder.py" << 'PYEOF' +#!/usr/bin/env python3 +"""adacam-forwarder: GPS-tagged frame forwarder for AdaMaps.""" +import json, logging, signal, sqlite3, sys, threading, time +from pathlib import Path +import requests + +logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s', stream=sys.stdout) +log = logging.getLogger('adacam-forwarder') + +CONFIG_PATH = Path('/data/adacam/config.json') +QUEUE_DB = Path('/data/adacam/forward_queue.db') +ODC_DB = Path('/data/recording/odc-api.db') +GNSS_KEYS = ['GNSSFusion30Hz', 'GnssData', 'GnssPvt', 'GnssPosition'] + +config, redis_client = {}, None +running = True + +def _handle_signal(signum, frame): + global running; running = False + +signal.signal(signal.SIGTERM, _handle_signal); signal.signal(signal.SIGINT, _handle_signal) + +def load_config(): + if CONFIG_PATH.exists(): + with open(CONFIG_PATH) as f: return json.load(f) + return {'device_id': 'unknown', 'adamaps_key': '', 'adamaps_api': 'https://api.adamaps.org'} + +def init_queue_db(): + QUEUE_DB.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(QUEUE_DB)) + conn.execute('CREATE TABLE IF NOT EXISTS queue (id INTEGER PRIMARY KEY, payload TEXT, created_at REAL, attempts INTEGER DEFAULT 0)') + conn.commit(); return conn + +def get_redis(): + global redis_client + if redis_client: + try: redis_client.ping(); return redis_client + except: redis_client = None + try: + import redis + redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True) + redis_client.ping(); return redis_client + except Exception as e: log.warning(f'Redis unavailable: {e}'); return None + +def get_latest_gps(r=None): + if ODC_DB.exists(): + try: + conn = sqlite3.connect(str(ODC_DB)) + row = conn.execute('SELECT latitude, longitude, altitude, hdop, satellites_used, time FROM framekms WHERE latitude IS NOT NULL AND latitude != 0 ORDER BY time DESC LIMIT 1').fetchone() + conn.close() + if row: return {'lat': row[0], 'lon': row[1], 'alt': row[2], 'hdop': row[3], 'satellites': row[4], 'ts': row[5]} + except Exception as e: log.debug(f'odc-api.db GPS read failed: {e}') + if r: + for key in GNSS_KEYS: + try: + for getter in [lambda k: r.zrevrange(k, 0, 0), lambda k: [r.get(k)]]: + items = getter(key) + if items and items[0]: + data = json.loads(items[0]) + lat = data.get('latitude') or data.get('lat_deg') + lon = data.get('longitude') or data.get('lon_deg') + if lat and lon: + return {'lat': lat, 'lon': lon, 'alt': data.get('altitude') or data.get('alt_m', 0), + 'hdop': data.get('hdop', 99), 'satellites': data.get('satellites_used') or data.get('num_satellites', 0), + 'ts': int(data.get('unix_milliseconds', time.time() * 1000))} + except: continue + return None + +def queue_payload(db, payload): + try: db.execute('INSERT INTO queue (payload, created_at) VALUES (?, ?)', (json.dumps(payload), time.time())); db.commit() + except Exception as e: log.error(f'Queue error: {e}') + +def post_to_adamaps(payload): + if not config.get('adamaps_key'): return False + url = f"{config.get('adamaps_api', 'https://api.adamaps.org')}/api/ingest" + try: + resp = requests.post(url, json=payload, headers={'X-AdaMaps-Key': config['adamaps_key']}, timeout=10) + return resp.status_code == 200 + except: return False + +def process_queue(db): + try: + for row_id, payload_json, attempts in db.execute('SELECT id, payload, attempts FROM queue ORDER BY created_at LIMIT 50').fetchall(): + if attempts > 10: db.execute('DELETE FROM queue WHERE id = ?', (row_id,)); continue + if post_to_adamaps(json.loads(payload_json)): db.execute('DELETE FROM queue WHERE id = ?', (row_id,)) + else: db.execute('UPDATE queue SET attempts = ? WHERE id = ?', (attempts + 1, row_id)) + time.sleep(0.1) + db.commit() + except Exception as e: log.error(f'Queue process error: {e}') + +def queue_worker(db): + while running: time.sleep(60); running and process_queue(db) + +def handle_frame(frame_path, db): + path = Path(frame_path) + if not path.exists(): return + r = get_redis(); gps = get_latest_gps(r) + try: frame_size = path.stat().st_size + except: frame_size = 0 + try: ts = int(path.stem) + except: ts = int(time.time() * 1000) + payload = {'device_id': config.get('device_id', 'unknown'), 'ts': ts, + 'lat': gps['lat'] if gps else None, 'lon': gps['lon'] if gps else None, + 'alt': gps['alt'] if gps else None, 'frame_path': str(path), 'frame_size': frame_size} + if not post_to_adamaps(payload): queue_payload(db, payload) + +def main(): + global config, running + config = load_config() + log.info(f"AdaCam Forwarder starting (device: {config.get('device_id', 'unknown')})") + db = init_queue_db() + threading.Thread(target=queue_worker, args=(db,), daemon=True).start() + backoff = 1 + while running: + r = get_redis() + if not r: time.sleep(backoff); backoff = min(backoff * 2, 30); continue + backoff = 1 + try: + import redis as redis_module + pubsub = r.pubsub(); pubsub.subscribe('adacam:frames') + for msg in pubsub.listen(): + if not running: break + if msg['type'] == 'message': handle_frame(msg['data'], db) + except: time.sleep(1) + +if __name__ == '__main__': + try: main() + except KeyboardInterrupt: running = False +PYEOF +chmod +x "$INSTALL_DIR/capture/adacam-forwarder.py" +ok "adacam-forwarder.py installed" + +# ── adacam-wigle.py ─────────────────────────────────────────────────────────── +cat > "$INSTALL_DIR/wigle/adacam-wigle.py" << 'PYEOF' +#!/usr/bin/env python3 +"""AdaCam WiGLE wardriving service — WiFi scanning + WiGLE upload.""" +import base64, json, logging, os, signal, sqlite3, subprocess, time +from datetime import datetime +import redis, requests + +logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') +log = logging.getLogger('adacam-wigle') + +DB_PATH = '/data/adacam/wigle.db' +ODC_DB = '/data/recording/odc-api.db' +INTERFACE = 'wlp1s0f1' +WIGLE_UPLOAD_URL = 'https://api.wigle.net/api/v2/file/upload' +WIGLE_CSV_HEADER = 'WigleWifi-1.4,appRelease=adacam-1.0,model=HivemapperBee,release=1.0,device=AdaCam,display=AdaCam,board=KeemBay,brand=AdaMaps\nMAC,SSID,AuthMode,FirstSeen,Channel,RSSI,CurrentLatitude,CurrentLongitude,AltitudeMeters,AccuracyMeters,Type' +GNSS_KEYS = ['GNSSFusion30Hz', 'GnssData', 'GnssPvt', 'GnssPosition'] +running = True + +def _handle_signal(signum, frame): + global running; running = False + +signal.signal(signal.SIGTERM, _handle_signal); signal.signal(signal.SIGINT, _handle_signal) + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.execute('''CREATE TABLE IF NOT EXISTS wigle_records (id INTEGER PRIMARY KEY AUTOINCREMENT, bssid TEXT NOT NULL, ssid TEXT, auth_mode TEXT, first_seen TEXT, channel INTEGER, rssi INTEGER, lat REAL, lon REAL, alt REAL, accuracy REAL, uploaded INTEGER DEFAULT 0, created_at INTEGER)''') + conn.execute('''CREATE TABLE IF NOT EXISTS wigle_config (key TEXT PRIMARY KEY, value TEXT)''') + conn.commit(); return conn + +def get_config(conn): + cfg = dict(conn.execute('SELECT key, value FROM wigle_config').fetchall()) + return {'enabled': cfg.get('enabled', '0') == '1', 'api_name': cfg.get('api_name', ''), 'api_token': cfg.get('api_token', ''), + 'scan_interval_seconds': int(cfg.get('scan_interval_seconds', '30')), 'upload_interval_seconds': int(cfg.get('upload_interval_seconds', '300'))} + +def get_latest_gps(r=None): + if os.path.exists(ODC_DB): + try: + conn = sqlite3.connect(ODC_DB) + row = conn.execute('SELECT latitude, longitude, altitude, hdop, satellites_used FROM framekms WHERE latitude IS NOT NULL AND latitude != 0 ORDER BY time DESC LIMIT 1').fetchone() + conn.close() + if row: return {'lat': row[0], 'lon': row[1], 'alt': row[2], 'hdop': row[3], 'satellites_used': row[4]} + except: pass + if r: + for key in GNSS_KEYS: + try: + for val in [r.zrange(key, -1, -1), [r.get(key)]]: + item = val[0] if val else None + if item: + d = json.loads(item) + lat = d.get('latitude') or d.get('lat_deg') + lon = d.get('longitude') or d.get('lon_deg') + if lat and lon: return {'lat': lat, 'lon': lon, 'alt': d.get('altitude') or d.get('alt_m', 0), 'hdop': d.get('hdop', 99), 'satellites_used': d.get('satellites_used') or d.get('num_satellites', 0)} + except: continue + return None + +def freq_to_channel(freq): + if 2412 <= freq <= 2484: return (freq - 2407) // 5 + if 5170 <= freq <= 5825: return (freq - 5000) // 5 + return 0 + +def parse_auth_mode(flags): + modes = [] + if 'WPA3' in flags: modes.append('WPA3-PSK-SAE') + elif 'WPA2' in flags: modes.append('WPA2-PSK-CCMP') + elif 'WPA' in flags: modes.append('WPA-PSK-TKIP') + if 'WPS' in flags: modes.append('WPS') + if not modes: modes.append('ESS') + return '[' + ']['.join(modes) + ']' + +def get_scan_results(): + try: + subprocess.run(['wpa_cli', '-i', INTERFACE, 'scan'], capture_output=True, timeout=5) + time.sleep(4) + result = subprocess.run(['wpa_cli', '-i', INTERFACE, 'scan_results'], capture_output=True, text=True, timeout=5) + networks = [] + for line in result.stdout.strip().split('\n')[1:]: + parts = line.split('\t') + if len(parts) >= 5: + networks.append({'bssid': parts[0], 'frequency': int(parts[1]) if parts[1].isdigit() else 0, + 'signal_dbm': int(parts[2]) if parts[2].lstrip('-').isdigit() else -100, + 'flags': parts[3], 'ssid': parts[4]}) + return networks + except Exception as e: log.warning(f'Scan failed: {e}'); return [] + +def store_records(conn, networks, gps): + now, first_seen = int(time.time()), datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + accuracy = gps.get('hdop', 5) * 5 + for n in networks: + conn.execute('INSERT INTO wigle_records (bssid, ssid, auth_mode, first_seen, channel, rssi, lat, lon, alt, accuracy, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)', + (n['bssid'], n['ssid'], parse_auth_mode(n['flags']), first_seen, freq_to_channel(n['frequency']), n['signal_dbm'], gps['lat'], gps['lon'], gps.get('alt', 0), accuracy, now)) + conn.commit(); log.info(f'Stored {len(networks)} networks') + +def upload_to_wigle(csv_content, api_name, api_token): + creds = base64.b64encode(f'{api_name}:{api_token}'.encode()).decode() + try: + resp = requests.post(WIGLE_UPLOAD_URL, headers={'Authorization': f'Basic {creds}'}, + files={'file': ('adacam-scan.csv', csv_content.encode(), 'text/csv')}, data={'donate': 'false'}, timeout=30) + return resp.ok, resp.json() if resp.ok else resp.text + except Exception as e: return False, str(e) + +def upload_pending(conn, cfg): + if not cfg['api_name'] or not cfg['api_token']: return + rows = conn.execute('SELECT id, bssid, ssid, auth_mode, first_seen, channel, rssi, lat, lon, alt, accuracy FROM wigle_records WHERE uploaded=0 LIMIT 10000').fetchall() + if not rows: return + ids = [r[0] for r in rows] + csv_lines = [WIGLE_CSV_HEADER] + [f'{r[1]},{r[2]},{r[3]},{r[4]},{r[5]},{r[6]},{r[7]},{r[8]},{r[9]},{r[10]},WIFI' for r in rows] + ok_flag, result = upload_to_wigle('\n'.join(csv_lines), cfg['api_name'], cfg['api_token']) + if ok_flag: + conn.execute(f'UPDATE wigle_records SET uploaded=1 WHERE id IN ({",".join("?" * len(ids))})', ids) + conn.commit(); log.info(f'Uploaded {len(ids)} records to WiGLE') + else: log.warning(f'WiGLE upload failed: {result}') + +def main(): + global running + log.info('AdaCam WiGLE service starting') + conn, r, last_upload = init_db(), None, 0 + while running: + try: + if r is None: r = redis.Redis(host='localhost', port=6379, decode_responses=True) + cfg = get_config(conn) + if not cfg['enabled']: time.sleep(10); continue + gps = get_latest_gps(r) + if gps and gps.get('hdop', 99) < 5 and gps.get('satellites_used', 0) >= 4: + networks = get_scan_results() + if networks: store_records(conn, networks, gps) + now = time.time() + if now - last_upload >= cfg['upload_interval_seconds']: upload_pending(conn, cfg); last_upload = now + time.sleep(cfg['scan_interval_seconds']) + except redis.RedisError: r = None; time.sleep(5) + except Exception as e: log.error(f'Error: {e}'); time.sleep(10) + +if __name__ == '__main__': main() +PYEOF +chmod +x "$INSTALL_DIR/wigle/adacam-wigle.py" +ok "adacam-wigle.py installed" + +# ── INSTALL PYTHON DEPS ─────────────────────────────────────────────────────── +log "" +log "=== Installing Python dependencies ===" +if command -v pip3 &>/dev/null; then + pip3 install redis requests --quiet && ok "Python deps installed" || warn "pip3 install failed — may need manual install" +else + warn "pip3 not found — skipping dep install" +fi + +# ── INSTALL SYSTEMD UNITS ───────────────────────────────────────────────────── +log "" +log "=== Installing systemd units ===" + +if [ "$ETC_WRITABLE" = "true" ] || [ -d /etc/systemd/system ]; then + cat > /etc/systemd/system/adacam-capture.service << SVCEOF +[Unit] +Description=AdaCam Camera Capture Service +After=network.target redis.service +Wants=redis.service + +[Service] +Type=simple +ExecStart=${INSTALL_DIR}/capture/adacam-capture.py +Restart=always +RestartSec=5 +MemoryMax=256M +CPUQuota=80% + +[Install] +WantedBy=multi-user.target +SVCEOF + + cat > /etc/systemd/system/adacam-forwarder.service << SVCEOF +[Unit] +Description=AdaCam Frame Forwarder Service +After=network.target redis.service adacam-capture.service +Wants=redis.service + +[Service] +Type=simple +ExecStart=${INSTALL_DIR}/capture/adacam-forwarder.py +Restart=always +RestartSec=5 +MemoryMax=128M +CPUQuota=20% + +[Install] +WantedBy=multi-user.target +SVCEOF + + cat > /etc/systemd/system/adacam-wigle.service << SVCEOF +[Unit] +Description=AdaCam WiGLE Wardriving Service +After=network.target redis.service +Wants=redis.service + +[Service] +Type=simple +ExecStart=/usr/bin/python3 ${INSTALL_DIR}/wigle/adacam-wigle.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +SVCEOF + + systemctl daemon-reload + systemctl enable adacam-capture adacam-forwarder adacam-wigle + ok "systemd units installed and enabled" +else + warn "/etc/systemd/system not writable — units not installed" +fi + +# ── MARK LIBERATED ──────────────────────────────────────────────────────────── +log "" +log "=== Marking device as liberated ===" +cat > "$ADACAM_DATA/liberated" << MARKER +liberated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) +serial=$SERIAL +version=0.5 +MARKER +ok "liberation marker written" + +# ── SUMMARY ─────────────────────────────────────────────────────────────────── +log "" +echo "════════════════════════════════════════════════════════" +echo " AdaCam Liberation Complete (v0.5)" +echo "════════════════════════════════════════════════════════" +echo "" +echo " Device Serial: $SERIAL" +echo " API Token: $API_TOKEN" +echo " AP IP: 192.168.0.10 (UNCHANGED — factory)" +echo " SSH: root@192.168.0.10 (password + key auth)" +echo "" +echo " Services installed:" +echo " adacam-capture → camera → /tmp/adacam/pics/" +echo " adacam-forwarder → frames + GPS → ADAMaps queue" +echo " adacam-wigle → WiFi scan → WiGLE (disabled by default)" +echo "" +echo " Config: /data/adacam/config.json" +echo " Data: /data/adacam/" +echo "" +echo " REBOOT to start services." +echo " Services autostart on next boot." +echo "" +echo " NO firewall changes. NO AP changes. NO IP changes." +echo "════════════════════════════════════════════════════════" +echo "" +log "Run: reboot"