1106 lines
49 KiB
Python
1106 lines
49 KiB
Python
"""
|
|
main.py — WiFi Heatmap Generator GUI
|
|
Cross-platform tkinter application for collecting and visualizing WiFi signal data.
|
|
|
|
Usage:
|
|
python main.py # Launch GUI
|
|
python main.py --session file.json # Load existing session
|
|
|
|
Requirements:
|
|
pip install numpy scipy matplotlib Pillow
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
import threading
|
|
import os
|
|
import argparse
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
import numpy as np
|
|
import matplotlib
|
|
matplotlib.use("TkAgg")
|
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
from matplotlib.figure import Figure
|
|
|
|
from scanner import scan_averaged, AccessPoint
|
|
from data import Session, Measurement
|
|
from renderer import render_heatmap, SIGNAL_CMAP
|
|
|
|
|
|
# ── Palette ───────────────────────────────────────────────────────────────────
|
|
BG = "#0d0d1a"
|
|
BG2 = "#131325"
|
|
BG3 = "#1a1a35"
|
|
ACCENT = "#3d7fff"
|
|
ACCENT2 = "#00d4aa"
|
|
TEXT = "#e8e8f0"
|
|
TEXT_DIM = "#6a6a8a"
|
|
DANGER = "#ff4a6e"
|
|
SUCCESS = "#00d4aa"
|
|
WARNING = "#ffaa00"
|
|
BORDER = "#2a2a4a"
|
|
|
|
# Signal level colors (match renderer thresholds)
|
|
SIG_GREEN = "#00c853"
|
|
SIG_YELLOW = "#ffd600"
|
|
SIG_ORANGE = "#ff6d00"
|
|
SIG_RED = "#d50000"
|
|
|
|
|
|
def _sig_color(dbm: int) -> str:
|
|
if dbm >= -50: return SIG_GREEN
|
|
if dbm >= -65: return SIG_YELLOW
|
|
if dbm >= -75: return SIG_ORANGE
|
|
return SIG_RED
|
|
|
|
|
|
@dataclass
|
|
class _ApStats:
|
|
"""Running statistics accumulated across all auto-scans for one BSSID."""
|
|
last_seen_ts: float = 0.0 # time.monotonic() of last sighting
|
|
max_dbm: int = -999
|
|
min_dbm: int = 0
|
|
total_dbm: int = 0
|
|
count: int = 0
|
|
|
|
def update(self, dbm: int):
|
|
self.last_seen_ts = time.monotonic()
|
|
self.max_dbm = max(self.max_dbm, dbm)
|
|
self.min_dbm = min(self.min_dbm, dbm) if self.count > 0 else dbm
|
|
self.total_dbm += dbm
|
|
self.count += 1
|
|
|
|
@property
|
|
def avg_dbm(self) -> int:
|
|
return self.total_dbm // self.count if self.count else 0
|
|
|
|
def last_seen_str(self, now: float) -> str:
|
|
if self.last_seen_ts == 0:
|
|
return "—"
|
|
secs = int(now - self.last_seen_ts)
|
|
if secs < 60: return f"{secs}s ago"
|
|
if secs < 3600: return f"{secs // 60}m ago"
|
|
return f"{secs // 3600}h ago"
|
|
|
|
|
|
# ── AP Table columns ──────────────────────────────────────────────────────────
|
|
AP_COLUMNS = [
|
|
("sel", "✓", 38),
|
|
("ssid", "SSID", 160),
|
|
("bssid", "BSSID", 145),
|
|
("channel", "Ch", 38),
|
|
("freq", "Frequency", 90),
|
|
("width", "Ch Width", 75),
|
|
("band", "Band", 60),
|
|
("security", "Security", 160),
|
|
("vendor", "Vendor", 90),
|
|
("mode", "Mode", 90),
|
|
("level", "Level", 110),
|
|
("last_seen", "Last Seen", 75),
|
|
("max_dbm", "Max", 50),
|
|
("min_dbm", "Min", 50),
|
|
("avg_dbm", "Avg", 50),
|
|
]
|
|
|
|
|
|
class WiFiHeatmapApp:
|
|
def __init__(self, root: tk.Tk, initial_session: Optional[str] = None):
|
|
self.root = root
|
|
self.root.title("WiFi Heatmap Generator")
|
|
self.root.configure(bg=BG)
|
|
self.root.minsize(1200, 720)
|
|
|
|
self.session: Session = Session()
|
|
self.session_path: Optional[str] = None
|
|
self.scanning = False
|
|
self.last_scan: list[AccessPoint] = []
|
|
self.selected_bssids: set[str] = set() # checked on AP tab
|
|
self.selected_bssids_for_heatmap: list[str] = [] # ordered list for rendering
|
|
|
|
# Per-BSSID running statistics (accumulated across all auto-scans)
|
|
self._ap_stats: dict[str, _ApStats] = {}
|
|
|
|
# Auto-scan state
|
|
self._auto_scan_job: Optional[str] = None # root.after() handle
|
|
self._auto_scan_enabled: bool = False
|
|
|
|
self._build_ui()
|
|
self._apply_styles()
|
|
|
|
if initial_session and os.path.exists(initial_session):
|
|
self._load_session(initial_session)
|
|
else:
|
|
self._new_session()
|
|
|
|
# ── Top-level layout ──────────────────────────────────────────────────────
|
|
|
|
def _build_ui(self):
|
|
self.root.columnconfigure(1, weight=1)
|
|
self.root.rowconfigure(0, weight=1)
|
|
|
|
# Left sidebar (always visible)
|
|
self.sidebar = tk.Frame(self.root, bg=BG2, width=220)
|
|
self.sidebar.grid(row=0, column=0, sticky="nsew", padx=(0, 1))
|
|
self.sidebar.grid_propagate(False)
|
|
self._build_sidebar()
|
|
|
|
# Main content area with notebook
|
|
content = tk.Frame(self.root, bg=BG)
|
|
content.grid(row=0, column=1, sticky="nsew")
|
|
content.columnconfigure(0, weight=1)
|
|
content.rowconfigure(0, weight=1)
|
|
|
|
self.notebook = ttk.Notebook(content)
|
|
self.notebook.grid(row=0, column=0, sticky="nsew", padx=4, pady=4)
|
|
|
|
# ── Tab 1: Access Points ──────────────────────────────────────────────
|
|
self.ap_tab = tk.Frame(self.notebook, bg=BG)
|
|
self.notebook.add(self.ap_tab, text=" Access Points ")
|
|
self.ap_tab.columnconfigure(0, weight=1)
|
|
self.ap_tab.rowconfigure(1, weight=1)
|
|
self._build_ap_tab()
|
|
|
|
# ── Tab 2: Heatmap ────────────────────────────────────────────────────
|
|
self.heatmap_tab = tk.Frame(self.notebook, bg=BG)
|
|
self.notebook.add(self.heatmap_tab, text=" Heatmap ")
|
|
self.heatmap_tab.columnconfigure(0, weight=1)
|
|
self.heatmap_tab.rowconfigure(1, weight=1)
|
|
self._build_heatmap_tab()
|
|
|
|
# Start on Access Points tab
|
|
self.notebook.select(self.ap_tab)
|
|
|
|
# ── Sidebar ───────────────────────────────────────────────────────────────
|
|
|
|
def _build_sidebar(self):
|
|
f = self.sidebar
|
|
|
|
tk.Label(f, text="📡", font=("Segoe UI Emoji", 28), bg=BG2, fg=ACCENT).pack(pady=(20, 4))
|
|
tk.Label(f, text="WiFi Heatmap", font=("Georgia", 13, "bold"), bg=BG2, fg=TEXT).pack()
|
|
tk.Label(f, text="Generator", font=("Georgia", 11), bg=BG2, fg=TEXT_DIM).pack(pady=(0, 16))
|
|
|
|
ttk.Separator(f, orient="horizontal").pack(fill="x", padx=12, pady=4)
|
|
|
|
self._sidebar_btn(f, "✦ New Session", self._new_session)
|
|
self._sidebar_btn(f, "📂 Open Session", self._open_session)
|
|
self._sidebar_btn(f, "💾 Save Session", self._save_session)
|
|
self._sidebar_btn(f, "💾 Save As…", self._save_session_as)
|
|
|
|
ttk.Separator(f, orient="horizontal").pack(fill="x", padx=12, pady=8)
|
|
|
|
tk.Label(f, text="FLOORPLAN", font=("Courier", 8, "bold"), bg=BG2, fg=TEXT_DIM).pack(anchor="w", padx=14)
|
|
self.floorplan_label = tk.Label(f, text="None (grid mode)",
|
|
font=("Courier", 8), bg=BG2, fg=WARNING, wraplength=190, justify="left")
|
|
self.floorplan_label.pack(anchor="w", padx=14, pady=(2, 2))
|
|
self.floorplan_note = tk.Label(f, text="⚠ Adding a floorplan\nincreases accuracy.",
|
|
font=("Courier", 8), bg=BG2, fg=WARNING, justify="left")
|
|
self.floorplan_note.pack(anchor="w", padx=14, pady=(0, 2))
|
|
self._sidebar_btn(f, "🖼 Load Floorplan", self._load_floorplan)
|
|
self._sidebar_btn(f, "✕ Remove Floorplan", self._remove_floorplan)
|
|
|
|
ttk.Separator(f, orient="horizontal").pack(fill="x", padx=12, pady=8)
|
|
|
|
tk.Label(f, text="EXPORT", font=("Courier", 8, "bold"), bg=BG2, fg=TEXT_DIM).pack(anchor="w", padx=14)
|
|
self._sidebar_btn(f, "🖼 Export PNG", self._export_png)
|
|
|
|
ttk.Separator(f, orient="horizontal").pack(fill="x", padx=12, pady=8)
|
|
|
|
self.session_info = tk.Label(f, text="", font=("Courier", 8),
|
|
bg=BG2, fg=TEXT_DIM, wraplength=195, justify="left")
|
|
self.session_info.pack(anchor="w", padx=14, pady=4)
|
|
|
|
def _sidebar_btn(self, parent, text, command):
|
|
btn = tk.Button(parent, text=text, font=("Courier", 9), bg=BG2, fg=TEXT,
|
|
relief="flat", anchor="w", padx=14, pady=5, cursor="hand2",
|
|
activebackground=BG3, activeforeground=TEXT, command=command)
|
|
btn.pack(fill="x", padx=4, pady=1)
|
|
return btn
|
|
|
|
# ── Access Points Tab ─────────────────────────────────────────────────────
|
|
|
|
def _build_ap_tab(self):
|
|
f = self.ap_tab
|
|
|
|
# ── Toolbar ───────────────────────────────────────────────────────────
|
|
toolbar = tk.Frame(f, bg=BG3)
|
|
toolbar.grid(row=0, column=0, sticky="ew")
|
|
toolbar.grid_columnconfigure(10, weight=1)
|
|
|
|
tk.Label(toolbar, text=" Discovered Access Points",
|
|
font=("Courier", 10, "bold"), bg=BG3, fg=TEXT).grid(
|
|
row=0, column=0, padx=12, pady=(10, 2), sticky="w")
|
|
|
|
self.ap_scan_btn = tk.Button(toolbar, text="🔄 Refresh Scan",
|
|
font=("Courier", 9, "bold"), bg=ACCENT, fg="white",
|
|
relief="flat", padx=12, pady=6, cursor="hand2",
|
|
activebackground="#5590ff", activeforeground="white",
|
|
command=self._refresh_scan)
|
|
self.ap_scan_btn.grid(row=0, column=1, padx=8, pady=(8, 2))
|
|
|
|
self.ap_select_all_btn = tk.Button(toolbar, text="☑ Select All",
|
|
font=("Courier", 8), bg=BG2, fg=ACCENT2,
|
|
relief="flat", padx=8, pady=6, cursor="hand2",
|
|
command=self._ap_select_all)
|
|
self.ap_select_all_btn.grid(row=0, column=2, padx=4, pady=(8, 2))
|
|
|
|
self.ap_deselect_all_btn = tk.Button(toolbar, text="☐ Deselect All",
|
|
font=("Courier", 8), bg=BG2, fg=TEXT_DIM,
|
|
relief="flat", padx=8, pady=6, cursor="hand2",
|
|
command=self._ap_deselect_all)
|
|
self.ap_deselect_all_btn.grid(row=0, column=3, padx=4, pady=(8, 2))
|
|
|
|
self.ap_status_label = tk.Label(toolbar, text="No scan yet",
|
|
font=("Courier", 8), bg=BG3, fg=TEXT_DIM)
|
|
self.ap_status_label.grid(row=0, column=10, padx=12, sticky="e")
|
|
|
|
# Auto-scan controls row
|
|
auto_bar = tk.Frame(toolbar, bg=BG3)
|
|
auto_bar.grid(row=1, column=0, columnspan=11, sticky="ew", padx=8, pady=(0, 8))
|
|
|
|
tk.Label(auto_bar, text=" Auto-scan:",
|
|
font=("Courier", 8), bg=BG3, fg=TEXT_DIM).pack(side="left")
|
|
|
|
self._auto_scan_var = tk.BooleanVar(value=False)
|
|
self.auto_scan_chk = tk.Checkbutton(
|
|
auto_bar, variable=self._auto_scan_var,
|
|
text="enabled", font=("Courier", 8),
|
|
bg=BG3, fg=ACCENT2, selectcolor=BG2,
|
|
activebackground=BG3, activeforeground=ACCENT2,
|
|
relief="flat", cursor="hand2",
|
|
command=self._toggle_auto_scan)
|
|
self.auto_scan_chk.pack(side="left", padx=(4, 12))
|
|
|
|
tk.Label(auto_bar, text="Interval:",
|
|
font=("Courier", 8), bg=BG3, fg=TEXT_DIM).pack(side="left")
|
|
|
|
self._auto_scan_interval = tk.IntVar(value=10)
|
|
interval_spin = tk.Spinbox(
|
|
auto_bar, from_=3, to=300, increment=1,
|
|
textvariable=self._auto_scan_interval,
|
|
font=("Courier", 8), width=4,
|
|
bg=BG2, fg=TEXT, insertbackground=TEXT,
|
|
buttonbackground=BG3, relief="flat",
|
|
command=self._on_interval_change)
|
|
interval_spin.pack(side="left", padx=(4, 2))
|
|
interval_spin.bind("<FocusOut>", self._on_interval_change)
|
|
interval_spin.bind("<Return>", self._on_interval_change)
|
|
|
|
tk.Label(auto_bar, text="seconds",
|
|
font=("Courier", 8), bg=BG3, fg=TEXT_DIM).pack(side="left", padx=(2, 16))
|
|
|
|
self.auto_scan_countdown = tk.Label(auto_bar, text="",
|
|
font=("Courier", 8, "bold"), bg=BG3, fg=WARNING)
|
|
self.auto_scan_countdown.pack(side="left")
|
|
|
|
# ── Scrollable table (header + rows share one Canvas) ─────────────────
|
|
# Both the sticky header row and every data row are child frames of a
|
|
# single inner container that sits inside the Canvas window. This
|
|
# guarantees horizontal scroll moves header and data identically, and
|
|
# that pixel-exact column widths produce perfect alignment.
|
|
table_outer = tk.Frame(f, bg=BG)
|
|
table_outer.grid(row=1, column=0, sticky="nsew")
|
|
table_outer.columnconfigure(0, weight=1)
|
|
table_outer.rowconfigure(0, weight=1)
|
|
|
|
self.ap_canvas = tk.Canvas(table_outer, bg=BG, highlightthickness=0)
|
|
ap_vscroll = ttk.Scrollbar(table_outer, orient="vertical", command=self.ap_canvas.yview)
|
|
ap_hscroll = ttk.Scrollbar(table_outer, orient="horizontal", command=self.ap_canvas.xview)
|
|
|
|
self.ap_canvas.configure(yscrollcommand=ap_vscroll.set,
|
|
xscrollcommand=ap_hscroll.set)
|
|
self.ap_canvas.grid(row=0, column=0, sticky="nsew")
|
|
ap_vscroll.grid(row=0, column=1, sticky="ns")
|
|
ap_hscroll.grid(row=1, column=0, sticky="ew")
|
|
|
|
# Single inner frame: row 0 = header, rows 1+ = data
|
|
self.ap_inner = tk.Frame(self.ap_canvas, bg=BG)
|
|
self.ap_canvas_window = self.ap_canvas.create_window(
|
|
(0, 0), window=self.ap_inner, anchor="nw")
|
|
|
|
self.ap_inner.bind("<Configure>", self._on_ap_frame_configure)
|
|
self.ap_canvas.bind("<Configure>", self._on_ap_canvas_configure)
|
|
self.ap_canvas.bind("<MouseWheel>", lambda e: self.ap_canvas.yview_scroll(-1*(e.delta//120), "units"))
|
|
self.ap_canvas.bind("<Button-4>", lambda e: self.ap_canvas.yview_scroll(-1, "units"))
|
|
self.ap_canvas.bind("<Button-5>", lambda e: self.ap_canvas.yview_scroll( 1, "units"))
|
|
|
|
# Build the header as row 0 inside ap_inner
|
|
self._build_ap_header(self.ap_inner)
|
|
|
|
# Row data storage
|
|
self._ap_row_widgets: list[dict] = []
|
|
|
|
# ── Column geometry ───────────────────────────────────────────────────────
|
|
# We use fixed pixel widths throughout. CELL_PAD is the total horizontal
|
|
# padding inside every cell (left + right). Every cell widget is given
|
|
# width=col_px so the header and data columns are identical in size.
|
|
CELL_PAD = 8 # px padding on each side inside a cell label
|
|
ROW_HEIGHT = 28 # px height for every row including header
|
|
|
|
def _col_px(self, width_hint: int) -> int:
|
|
"""Return the actual pixel width for a column given its hint."""
|
|
return width_hint # hints are already in pixels; no scaling needed
|
|
|
|
def _build_ap_header(self, parent: tk.Frame):
|
|
"""Build the header as the first row of the inner frame."""
|
|
hdr = tk.Frame(parent, bg=BG3)
|
|
hdr.grid(row=0, column=0, sticky="ew")
|
|
|
|
# Checkbox placeholder — same width as the actual Checkbutton (26px)
|
|
tk.Label(hdr, text="", bg=BG3, width=3).pack(side="left", padx=(2, 0))
|
|
|
|
for col_id, label, width in AP_COLUMNS:
|
|
if col_id == "sel":
|
|
continue # handled by the placeholder above
|
|
tk.Label(hdr, text=label,
|
|
font=("Courier", 8, "bold"),
|
|
bg=BG3, fg=TEXT_DIM,
|
|
width=0, # let wraplength/padx control width
|
|
anchor="w",
|
|
padx=self.CELL_PAD // 2,
|
|
pady=6
|
|
).pack(side="left",
|
|
ipadx=0,
|
|
# Force exact pixel width via a fixed-size frame wrapper
|
|
)
|
|
# Use a fixed-pixel container frame so the label is always exactly
|
|
# `width` pixels wide — matching the data cells below.
|
|
hdr.pack_slaves()[-1].pack_forget()
|
|
container = tk.Frame(hdr, bg=BG3, width=width, height=self.ROW_HEIGHT)
|
|
container.pack(side="left")
|
|
container.pack_propagate(False)
|
|
tk.Label(container, text=label,
|
|
font=("Courier", 8, "bold"),
|
|
bg=BG3, fg=TEXT_DIM,
|
|
anchor="w", padx=4).place(relwidth=1, relheight=1)
|
|
|
|
def _on_ap_frame_configure(self, event):
|
|
self.ap_canvas.configure(scrollregion=self.ap_canvas.bbox("all"))
|
|
|
|
def _on_ap_canvas_configure(self, event):
|
|
# Do NOT force the inner frame to the canvas width — that would
|
|
# suppress horizontal scrolling. Let it be its natural width.
|
|
pass
|
|
|
|
def _known_bssids_ordered(self) -> list[str]:
|
|
"""
|
|
Return all BSSIDs ever seen, in a stable display order:
|
|
currently-visible APs first (sorted by signal desc), then
|
|
previously-seen-but-now-absent APs (sorted by last_seen desc).
|
|
"""
|
|
current_bssids = [ap.bssid for ap in self.last_scan]
|
|
current_set = set(current_bssids)
|
|
stale_bssids = sorted(
|
|
[b for b in self._ap_stats if b not in current_set],
|
|
key=lambda b: self._ap_stats[b].last_seen_ts,
|
|
reverse=True
|
|
)
|
|
return current_bssids + stale_bssids
|
|
|
|
def _populate_ap_table(self, aps: list[AccessPoint]):
|
|
"""
|
|
Rebuild data rows inside ap_inner (below the sticky header).
|
|
Shows every BSSID ever seen — current ones at full brightness,
|
|
stale ones (absent from latest scan) dimmed with a 'last seen' note.
|
|
"""
|
|
# Remove old data rows (keep row 0 = header)
|
|
for widget in list(self.ap_inner.grid_slaves()):
|
|
info = widget.grid_info()
|
|
if info.get("row", 0) > 0:
|
|
widget.destroy()
|
|
self._ap_row_widgets.clear()
|
|
|
|
# Build a lookup map for current scan
|
|
current_map: dict[str, AccessPoint] = {ap.bssid: ap for ap in aps}
|
|
now = time.monotonic()
|
|
all_bssids = self._known_bssids_ordered()
|
|
|
|
for i, bssid in enumerate(all_bssids):
|
|
ap = current_map.get(bssid)
|
|
stats = self._ap_stats.get(bssid)
|
|
is_live = ap is not None
|
|
row_num = i + 1 # row 0 is the header
|
|
|
|
row_bg = BG if i % 2 == 0 else BG2
|
|
dim_fg = "#3a3a5a" # very dim — used for stale rows
|
|
|
|
row = tk.Frame(self.ap_inner, bg=row_bg)
|
|
row.grid(row=row_num, column=0, sticky="ew")
|
|
|
|
# ── Checkbox ──────────────────────────────────────────────────────
|
|
check_var = tk.BooleanVar(value=(bssid in self.selected_bssids))
|
|
tk.Checkbutton(row, variable=check_var, bg=row_bg,
|
|
activebackground=row_bg, fg=ACCENT2, selectcolor=BG3,
|
|
relief="flat", cursor="hand2",
|
|
command=lambda b=bssid, v=check_var: self._on_ap_toggle(b, v)
|
|
).pack(side="left", padx=(4, 0))
|
|
|
|
def _cell(text, width, fg=TEXT, bold=False):
|
|
"""Pack a fixed-pixel-width cell."""
|
|
container = tk.Frame(row, bg=row_bg, width=width, height=self.ROW_HEIGHT)
|
|
container.pack(side="left")
|
|
container.pack_propagate(False)
|
|
tk.Label(container, text=str(text),
|
|
font=("Courier", 8, "bold") if bold else ("Courier", 8),
|
|
bg=row_bg, fg=fg,
|
|
anchor="w", padx=4).place(relwidth=1, relheight=1)
|
|
|
|
# Resolve display values — use last-known from stats when AP is stale
|
|
ssid = ap.ssid if ap else self._ap_stats_ssid(bssid)
|
|
channel = str(ap.channel) if ap and ap.channel else "—"
|
|
freq = ap.freq_label() if ap else "—"
|
|
width_s = ap.channel_width or "—" if ap else "—"
|
|
band = ap.band or "—" if ap else "—"
|
|
security = ap.security or "—" if ap else "—"
|
|
vendor = ap.vendor or "—" if ap else "—"
|
|
mode = ap.mode_label() if ap else "—"
|
|
|
|
text_fg = TEXT if is_live else dim_fg
|
|
dim2 = TEXT_DIM if is_live else dim_fg
|
|
|
|
_cell(ssid[:20], 160, fg=text_fg, bold=True)
|
|
_cell(bssid, 145, fg=dim2)
|
|
_cell(channel, 38, fg=text_fg)
|
|
_cell(freq, 90, fg=text_fg)
|
|
_cell(width_s, 75, fg=text_fg)
|
|
_cell(band, 60, fg=ACCENT2 if is_live else dim_fg)
|
|
_cell(security, 160, fg=text_fg)
|
|
_cell(vendor, 90, fg=dim2)
|
|
_cell(mode, 90, fg=text_fg)
|
|
|
|
# Signal bar — live APs get a colored bar; stale get a dim placeholder
|
|
bar_w = 110
|
|
bar_container = tk.Frame(row, bg=row_bg, width=bar_w, height=self.ROW_HEIGHT)
|
|
bar_container.pack(side="left")
|
|
bar_container.pack_propagate(False)
|
|
if is_live:
|
|
self._draw_signal_bar(bar_container, ap.signal_dbm, row_bg)
|
|
else:
|
|
tk.Label(bar_container, text="out of range",
|
|
font=("Courier", 7), bg=row_bg, fg=dim_fg,
|
|
anchor="w", padx=4).place(relwidth=1, relheight=1)
|
|
|
|
# Stat columns
|
|
if stats and stats.count > 0:
|
|
ls_str = stats.last_seen_str(now)
|
|
max_str = f"{stats.max_dbm}"
|
|
min_str = f"{stats.min_dbm}"
|
|
avg_str = f"{stats.avg_dbm}"
|
|
_cell(ls_str, 75, fg=TEXT_DIM if is_live else dim_fg)
|
|
_cell(max_str, 50, fg=SIG_GREEN if is_live else dim_fg)
|
|
_cell(min_str, 50, fg=SIG_RED if is_live else dim_fg)
|
|
_cell(avg_str, 50, fg=_sig_color(stats.avg_dbm) if is_live else dim_fg)
|
|
else:
|
|
for w in (75, 50, 50, 50):
|
|
_cell("—", w, fg=dim_fg)
|
|
|
|
# Hover highlight
|
|
def _on_enter(e, r=row, bg=row_bg):
|
|
for w in r.winfo_children():
|
|
try: w.config(bg="#1e1e3a")
|
|
except: pass
|
|
r.config(bg="#1e1e3a")
|
|
def _on_leave(e, r=row, bg=row_bg):
|
|
for w in r.winfo_children():
|
|
try: w.config(bg=bg)
|
|
except: pass
|
|
r.config(bg=bg)
|
|
row.bind("<Enter>", _on_enter)
|
|
row.bind("<Leave>", _on_leave)
|
|
|
|
self._ap_row_widgets.append({"bssid": bssid, "check_var": check_var, "row": row})
|
|
|
|
self.ap_inner.update_idletasks()
|
|
self.ap_canvas.configure(scrollregion=self.ap_canvas.bbox("all"))
|
|
|
|
def _ap_stats_ssid(self, bssid: str) -> str:
|
|
"""Look up SSID for a stale BSSID from session data or return the BSSID itself."""
|
|
ssid = self.session.get_ssid(bssid)
|
|
return ssid if ssid != bssid else bssid
|
|
|
|
def _draw_signal_bar(self, parent: tk.Frame, dbm: int, bg_color: str):
|
|
"""Draw a small colored bar chart showing signal strength."""
|
|
c = tk.Canvas(parent, bg=bg_color, highlightthickness=0,
|
|
width=108, height=22)
|
|
c.pack(fill="both", expand=True)
|
|
|
|
color = _sig_color(dbm)
|
|
pct = max(0, min(100, (dbm + 90) * 100 // 60))
|
|
bar_w = int(pct * 68 / 100)
|
|
|
|
# Background track
|
|
c.create_rectangle(2, 6, 72, 18, fill="#1a1a30", outline="")
|
|
# Signal bar
|
|
if bar_w > 0:
|
|
c.create_rectangle(2, 6, 2 + bar_w, 18, fill=color, outline="")
|
|
# dBm label
|
|
c.create_text(80, 12, text=f"{dbm}", font=("Courier", 7, "bold"),
|
|
fill=color, anchor="w")
|
|
|
|
def _on_ap_toggle(self, bssid: str, var: tk.BooleanVar):
|
|
if var.get():
|
|
self.selected_bssids.add(bssid)
|
|
else:
|
|
self.selected_bssids.discard(bssid)
|
|
self._sync_heatmap_bssids()
|
|
self._update_heatmap_ap_selector()
|
|
|
|
def _ap_select_all(self):
|
|
self.selected_bssids = {r["bssid"] for r in self._ap_row_widgets}
|
|
for row in self._ap_row_widgets:
|
|
row["check_var"].set(True)
|
|
self._sync_heatmap_bssids()
|
|
self._update_heatmap_ap_selector()
|
|
|
|
def _ap_deselect_all(self):
|
|
self.selected_bssids.clear()
|
|
for row in self._ap_row_widgets:
|
|
row["check_var"].set(False)
|
|
self._sync_heatmap_bssids()
|
|
self._update_heatmap_ap_selector()
|
|
|
|
def _sync_heatmap_bssids(self):
|
|
"""Keep selected_bssids_for_heatmap in sync with the AP tab checkboxes.
|
|
Preserves a stable order: scan order first, then session-only BSSIDs."""
|
|
ordered = [ap.bssid for ap in self.last_scan if ap.bssid in self.selected_bssids]
|
|
scan_set = {ap.bssid for ap in self.last_scan}
|
|
for bssid in self.session.get_all_bssids():
|
|
if bssid in self.selected_bssids and bssid not in scan_set:
|
|
ordered.append(bssid)
|
|
self.selected_bssids_for_heatmap = ordered
|
|
|
|
# ── Heatmap Tab ───────────────────────────────────────────────────────────
|
|
|
|
def _build_heatmap_tab(self):
|
|
f = self.heatmap_tab
|
|
|
|
# Toolbar
|
|
toolbar = tk.Frame(f, bg=BG3, height=48)
|
|
toolbar.grid(row=0, column=0, columnspan=2, sticky="ew")
|
|
toolbar.grid_columnconfigure(99, weight=1)
|
|
|
|
tk.Label(toolbar, text=" Click canvas to record a measurement",
|
|
font=("Courier", 9), bg=BG3, fg=TEXT_DIM).grid(row=0, column=0, padx=8, pady=12, sticky="w")
|
|
|
|
self.scan_btn = tk.Button(toolbar, text="▶ Scan & Place",
|
|
font=("Courier", 9, "bold"), bg=ACCENT, fg="white",
|
|
relief="flat", padx=12, pady=6, cursor="hand2",
|
|
activebackground="#5590ff", activeforeground="white",
|
|
command=self._begin_placement_mode)
|
|
self.scan_btn.grid(row=0, column=1, padx=6, pady=8)
|
|
|
|
self.undo_btn = tk.Button(toolbar, text="↩ Undo",
|
|
font=("Courier", 9), bg=BG2, fg=TEXT,
|
|
relief="flat", padx=10, pady=6, cursor="hand2",
|
|
command=self._undo_last)
|
|
self.undo_btn.grid(row=0, column=2, padx=4, pady=8)
|
|
|
|
self.status_label = tk.Label(toolbar, text="Ready",
|
|
font=("Courier", 9), bg=BG3, fg=TEXT_DIM)
|
|
self.status_label.grid(row=0, column=99, padx=12, sticky="e")
|
|
|
|
# Canvas area
|
|
self.fig = Figure(figsize=(9, 6), dpi=100)
|
|
self.fig.patch.set_facecolor(BG)
|
|
self.ax = self.fig.add_subplot(111)
|
|
self.ax.set_facecolor(BG)
|
|
|
|
self.canvas_widget = FigureCanvasTkAgg(self.fig, master=f)
|
|
self.canvas_widget.get_tk_widget().grid(row=1, column=0, sticky="nsew")
|
|
self.canvas_widget.mpl_connect("button_press_event", self._on_canvas_click)
|
|
f.columnconfigure(0, weight=1)
|
|
|
|
# Right panel — AP selector + measurements (full window height)
|
|
right = tk.Frame(f, bg=BG2, width=260)
|
|
right.grid(row=1, column=1, sticky="nsew", padx=(1, 0))
|
|
right.grid_propagate(False)
|
|
right.columnconfigure(0, weight=1)
|
|
right.rowconfigure(3, weight=1) # measurements listbox expands
|
|
self._build_heatmap_right(right)
|
|
|
|
self._placement_mode = False
|
|
self._pending_scan: Optional[list[AccessPoint]] = None
|
|
|
|
def _build_heatmap_right(self, f: tk.Frame):
|
|
# ── Active AP display ─────────────────────────────────────────────────
|
|
tk.Label(f, text="ACTIVE NETWORKS", font=("Courier", 8, "bold"),
|
|
bg=BG2, fg=TEXT_DIM).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 2))
|
|
|
|
tk.Label(f,
|
|
text="Check networks on the Access\nPoints tab to include them here.\n"
|
|
"1 selected = single BSSID mode.\n"
|
|
"2+ selected = averaged mode.",
|
|
font=("Courier", 7), bg=BG2, fg=TEXT_DIM, justify="left", wraplength=230
|
|
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 6))
|
|
|
|
# Scrollable list showing the currently active BSSIDs
|
|
active_frame = tk.Frame(f, bg=BG3, relief="flat",
|
|
highlightthickness=1,
|
|
highlightbackground=BORDER)
|
|
active_frame.grid(row=2, column=0, sticky="ew", padx=10, pady=(0, 4))
|
|
active_frame.columnconfigure(0, weight=1)
|
|
|
|
active_scroll = ttk.Scrollbar(active_frame, orient="vertical")
|
|
active_scroll.grid(row=0, column=1, sticky="ns")
|
|
|
|
self.active_bssid_listbox = tk.Listbox(
|
|
active_frame,
|
|
font=("Courier", 7), bg=BG3, fg=ACCENT2,
|
|
relief="flat", borderwidth=0,
|
|
highlightthickness=0,
|
|
activestyle="none", height=5,
|
|
selectbackground=BG3, selectforeground=ACCENT2,
|
|
yscrollcommand=active_scroll.set)
|
|
self.active_bssid_listbox.grid(row=0, column=0, sticky="ew")
|
|
active_scroll.config(command=self.active_bssid_listbox.yview)
|
|
|
|
self.heatmap_mode_label = tk.Label(f, text="",
|
|
font=("Courier", 7, "bold"), bg=BG2, fg=WARNING,
|
|
wraplength=230, justify="left")
|
|
self.heatmap_mode_label.grid(row=3, column=0, sticky="w", padx=14, pady=(2, 4))
|
|
|
|
ttk.Separator(f, orient="horizontal").grid(row=4, column=0, sticky="ew", padx=8, pady=6)
|
|
|
|
# ── Measurements list (full remaining height) ──────────────────────
|
|
tk.Label(f, text="MEASUREMENTS", font=("Courier", 8, "bold"),
|
|
bg=BG2, fg=TEXT_DIM).grid(row=5, column=0, sticky="w", padx=14, pady=(4, 2))
|
|
|
|
self.meas_count_label = tk.Label(f, text="0 points collected",
|
|
font=("Courier", 8), bg=BG2, fg=ACCENT2)
|
|
self.meas_count_label.grid(row=6, column=0, sticky="w", padx=14, pady=(0, 4))
|
|
|
|
meas_frame = tk.Frame(f, bg=BG2)
|
|
meas_frame.grid(row=7, column=0, sticky="nsew", padx=8, pady=(0, 4))
|
|
meas_frame.columnconfigure(0, weight=1)
|
|
meas_frame.rowconfigure(0, weight=1)
|
|
f.rowconfigure(7, weight=1)
|
|
|
|
meas_scroll = ttk.Scrollbar(meas_frame, orient="vertical")
|
|
meas_scroll.grid(row=0, column=1, sticky="ns")
|
|
|
|
self.meas_listbox = tk.Listbox(meas_frame,
|
|
font=("Courier", 8),
|
|
bg=BG3, fg=TEXT_DIM, selectbackground=DANGER,
|
|
relief="flat", borderwidth=0,
|
|
highlightthickness=1, highlightcolor=BORDER, highlightbackground=BORDER,
|
|
activestyle="none",
|
|
yscrollcommand=meas_scroll.set)
|
|
self.meas_listbox.grid(row=0, column=0, sticky="nsew")
|
|
meas_scroll.config(command=self.meas_listbox.yview)
|
|
|
|
self.del_meas_btn = tk.Button(f, text="🗑 Delete Selected",
|
|
font=("Courier", 8), bg=BG3, fg=DANGER,
|
|
relief="flat", padx=8, pady=4, cursor="hand2",
|
|
command=self._delete_measurement)
|
|
self.del_meas_btn.grid(row=8, column=0, sticky="w", padx=12, pady=(0, 10))
|
|
|
|
# ── Active BSSID display on Heatmap tab ───────────────────────────────────
|
|
|
|
def _update_heatmap_ap_selector(self):
|
|
"""Reflect the current AP tab selection in the Heatmap tab status panel."""
|
|
self._sync_heatmap_bssids()
|
|
bssids = self.selected_bssids_for_heatmap
|
|
|
|
self.active_bssid_listbox.delete(0, tk.END)
|
|
|
|
if not bssids:
|
|
self.active_bssid_listbox.insert(tk.END, " — none selected —")
|
|
self.heatmap_mode_label.config(
|
|
text="⚠ No networks selected.\nGo to Access Points tab and\ncheck at least one.",
|
|
fg=DANGER)
|
|
else:
|
|
for bssid in bssids:
|
|
ssid = self.session.get_ssid(bssid)
|
|
self.active_bssid_listbox.insert(tk.END, f" {ssid} {bssid}")
|
|
if len(bssids) == 1:
|
|
self.heatmap_mode_label.config(
|
|
text=f"Mode: single BSSID", fg=ACCENT2)
|
|
else:
|
|
self.heatmap_mode_label.config(
|
|
text=f"Mode: averaging {len(bssids)} BSSIDs", fg=WARNING)
|
|
|
|
self._redraw()
|
|
|
|
# ── Scanning (AP Tab) ─────────────────────────────────────────────────────
|
|
|
|
def _refresh_scan(self):
|
|
if self.scanning: return
|
|
self.scanning = True
|
|
self.ap_scan_btn.config(text="⏳ Scanning…", state="disabled")
|
|
self.ap_status_label.config(text="Scanning…", fg=WARNING)
|
|
threading.Thread(target=self._do_scan, daemon=True).start()
|
|
|
|
def _do_scan(self):
|
|
try:
|
|
aps = scan_averaged(samples=5, delay=0.4)
|
|
self.root.after(0, lambda: self._scan_complete(aps))
|
|
except Exception as e:
|
|
self.root.after(0, lambda: self._scan_error(str(e)))
|
|
|
|
def _scan_complete(self, aps: list[AccessPoint]):
|
|
self.scanning = False
|
|
self.last_scan = aps
|
|
self.ap_scan_btn.config(text="🔄 Refresh Scan", state="normal")
|
|
|
|
# Accumulate running stats for every AP seen in this scan
|
|
for ap in aps:
|
|
if ap.bssid not in self._ap_stats:
|
|
self._ap_stats[ap.bssid] = _ApStats()
|
|
self._ap_stats[ap.bssid].update(ap.signal_dbm)
|
|
|
|
self.ap_status_label.config(
|
|
text=f"Found {len(aps)} network{'s' if len(aps) != 1 else ''}",
|
|
fg=SUCCESS)
|
|
# Auto-select all on first scan
|
|
if not self.selected_bssids:
|
|
self.selected_bssids = {ap.bssid for ap in aps}
|
|
self._populate_ap_table(aps)
|
|
self._sync_heatmap_bssids()
|
|
self._update_heatmap_ap_selector()
|
|
self._set_status(f"Scan complete — {len(aps)} networks found")
|
|
|
|
# Schedule next auto-scan if enabled
|
|
if self._auto_scan_enabled:
|
|
self._schedule_auto_scan()
|
|
|
|
def _scan_error(self, err: str):
|
|
self.scanning = False
|
|
self.ap_scan_btn.config(text="🔄 Refresh Scan", state="normal")
|
|
self.ap_status_label.config(text="Scan failed", fg=DANGER)
|
|
self._set_status(f"Scan failed: {err}", error=True)
|
|
# Keep auto-scanning even after an error
|
|
if self._auto_scan_enabled:
|
|
self._schedule_auto_scan()
|
|
messagebox.showerror("Scan Error",
|
|
f"WiFi scan failed:\n{err}\n\n"
|
|
"On Linux, try: sudo nmcli dev wifi list --rescan yes\n"
|
|
"On Windows, run as Administrator if needed.")
|
|
|
|
# ── Auto-scan ─────────────────────────────────────────────────────────────
|
|
|
|
def _toggle_auto_scan(self):
|
|
self._auto_scan_enabled = self._auto_scan_var.get()
|
|
if self._auto_scan_enabled:
|
|
self._schedule_auto_scan()
|
|
self.ap_scan_btn.config(state="disabled") # manual scan disabled while auto is on
|
|
else:
|
|
self._cancel_auto_scan()
|
|
self.ap_scan_btn.config(state="normal")
|
|
self.auto_scan_countdown.config(text="")
|
|
|
|
def _on_interval_change(self, event=None):
|
|
"""User changed the interval spinbox — restart the countdown if running."""
|
|
if self._auto_scan_enabled:
|
|
self._cancel_auto_scan()
|
|
self._schedule_auto_scan()
|
|
|
|
def _schedule_auto_scan(self):
|
|
"""Start the countdown ticker then fire the scan at the end."""
|
|
self._cancel_auto_scan()
|
|
interval = max(3, self._auto_scan_interval.get())
|
|
self._auto_scan_remaining = interval
|
|
self._tick_countdown()
|
|
|
|
def _cancel_auto_scan(self):
|
|
if self._auto_scan_job:
|
|
self.root.after_cancel(self._auto_scan_job)
|
|
self._auto_scan_job = None
|
|
|
|
def _tick_countdown(self):
|
|
"""Called every second to update the countdown label."""
|
|
if not self._auto_scan_enabled:
|
|
self.auto_scan_countdown.config(text="")
|
|
return
|
|
if self._auto_scan_remaining <= 0:
|
|
self.auto_scan_countdown.config(text="scanning…", fg=WARNING)
|
|
self._fire_auto_scan()
|
|
return
|
|
self.auto_scan_countdown.config(
|
|
text=f"next scan in {self._auto_scan_remaining}s", fg=TEXT_DIM)
|
|
self._auto_scan_remaining -= 1
|
|
self._auto_scan_job = self.root.after(1000, self._tick_countdown)
|
|
|
|
def _fire_auto_scan(self):
|
|
"""Trigger the actual scan for the auto-scan cycle."""
|
|
if self.scanning:
|
|
# A placement scan is in progress — reschedule without firing
|
|
self._schedule_auto_scan()
|
|
return
|
|
self.scanning = True
|
|
self.ap_status_label.config(text="Auto-scanning…", fg=WARNING)
|
|
threading.Thread(target=self._do_scan, daemon=True).start()
|
|
|
|
# ── Scanning (Heatmap placement) ──────────────────────────────────────────
|
|
|
|
def _begin_placement_mode(self):
|
|
if self.scanning: return
|
|
if not self.selected_bssids_for_heatmap:
|
|
messagebox.showinfo("No APs Selected",
|
|
"Please select at least one access point\non the Access Points tab first.")
|
|
self.notebook.select(self.ap_tab)
|
|
return
|
|
self.scanning = True
|
|
self.scan_btn.config(text="⏳ Scanning…", state="disabled")
|
|
self._set_status("Scanning… click your position on the map when ready.")
|
|
threading.Thread(target=self._scan_for_placement, daemon=True).start()
|
|
|
|
def _scan_for_placement(self):
|
|
try:
|
|
aps = scan_averaged(samples=6, delay=0.4)
|
|
self.root.after(0, lambda: self._ready_to_place(aps))
|
|
except Exception as e:
|
|
self.root.after(0, lambda: self._scan_error(str(e)))
|
|
|
|
def _ready_to_place(self, aps: list[AccessPoint]):
|
|
self.scanning = False
|
|
self._pending_scan = aps
|
|
self._placement_mode = True
|
|
# Also refresh AP tab
|
|
if aps:
|
|
for ap in aps:
|
|
if ap.bssid not in {r["bssid"] for r in self._ap_row_widgets}:
|
|
pass # new APs from this scan; full repopulate next refresh
|
|
self.scan_btn.config(text="📍 Click your location", state="normal", bg=ACCENT2)
|
|
self._set_status("✓ Scan done — click your position on the map")
|
|
self.canvas_widget.get_tk_widget().config(cursor="crosshair")
|
|
|
|
def _on_canvas_click(self, event):
|
|
if event.inaxes != self.ax: return
|
|
if not self._placement_mode or not self._pending_scan: return
|
|
x, y = event.xdata, event.ydata
|
|
if x is None or y is None: return
|
|
|
|
signals = {ap.bssid: ap.signal_dbm for ap in self._pending_scan}
|
|
ssid_map = {ap.bssid: ap.ssid for ap in self._pending_scan}
|
|
|
|
self.session.add_measurement(Measurement(x=x, y=y, signals=signals, ssid_map=ssid_map))
|
|
|
|
self._placement_mode = False
|
|
self._pending_scan = None
|
|
self.scan_btn.config(text="▶ Scan & Place", bg=ACCENT)
|
|
self.canvas_widget.get_tk_widget().config(cursor="")
|
|
|
|
n = len(self.session.measurements)
|
|
self._set_status(f"✓ Point #{n} recorded at ({int(x)}, {int(y)})")
|
|
self._refresh_everything()
|
|
|
|
def _undo_last(self):
|
|
if not self.session.measurements: return
|
|
self.session.remove_measurement(len(self.session.measurements) - 1)
|
|
self._set_status("Last measurement removed")
|
|
self._refresh_everything()
|
|
|
|
def _delete_measurement(self):
|
|
sel = self.meas_listbox.curselection()
|
|
if not sel: return
|
|
idx = sel[0]
|
|
self.session.remove_measurement(idx)
|
|
self._refresh_everything()
|
|
self._set_status(f"Measurement {idx + 1} deleted")
|
|
|
|
# ── Rendering ─────────────────────────────────────────────────────────────
|
|
|
|
def _redraw(self):
|
|
if self.selected_bssids_for_heatmap:
|
|
try:
|
|
render_heatmap(self.session, self.selected_bssids_for_heatmap, self.fig, self.ax)
|
|
except Exception as e:
|
|
self.ax.clear()
|
|
self.ax.text(0.5, 0.5, f"Render error:\n{e}",
|
|
transform=self.ax.transAxes, ha="center", va="center",
|
|
color="red", fontsize=10)
|
|
else:
|
|
self._draw_empty_canvas()
|
|
self.canvas_widget.draw()
|
|
|
|
def _draw_empty_canvas(self):
|
|
self.ax.clear()
|
|
self.ax.set_facecolor(BG)
|
|
self.fig.patch.set_facecolor(BG)
|
|
w, h = self.session.canvas_width, self.session.canvas_height
|
|
|
|
if self.session.floorplan_path:
|
|
from PIL import Image
|
|
try:
|
|
img = Image.open(self.session.floorplan_path).convert("RGBA")
|
|
img = img.resize((w, h), Image.LANCZOS)
|
|
self.ax.imshow(np.array(img), extent=[0, w, h, 0], aspect="auto", alpha=0.4)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
spacing = max(w, h) // 10
|
|
for x in range(0, w + 1, spacing):
|
|
self.ax.axvline(x, color="#1e2a4a", linewidth=0.5)
|
|
for y in range(0, h + 1, spacing):
|
|
self.ax.axhline(y, color="#1e2a4a", linewidth=0.5)
|
|
|
|
if not self.selected_bssids_for_heatmap:
|
|
msg = "← Select networks on the\nAccess Points tab first"
|
|
else:
|
|
n = len(self.selected_bssids_for_heatmap)
|
|
msg = (f"{n} BSSID{'s' if n > 1 else ''} selected.\n"
|
|
f"Click ▶ Scan & Place to begin.")
|
|
if n > 1:
|
|
msg = f"Averaging {n} BSSIDs.\n" + msg
|
|
if not self.session.floorplan_path:
|
|
msg += "\n\n⚠ No floorplan — adding one\nincreases accuracy."
|
|
|
|
self.ax.text(w / 2, h / 2, msg, ha="center", va="center",
|
|
color="#4a4a7a", fontsize=12, linespacing=1.8,
|
|
bbox=dict(boxstyle="round,pad=0.8", facecolor="#10101e", alpha=0.7))
|
|
self.ax.set_xlim(0, w); self.ax.set_ylim(h, 0)
|
|
self.ax.set_xticks([]); self.ax.set_yticks([])
|
|
|
|
# ── Session management ────────────────────────────────────────────────────
|
|
|
|
def _new_session(self):
|
|
if self.session.measurements:
|
|
if not messagebox.askyesno("New Session", "Discard current session?"): return
|
|
self.session = Session(name="New Session")
|
|
self.session_path = None
|
|
self.selected_bssids_for_heatmap = []
|
|
self._ap_stats.clear()
|
|
self._refresh_everything()
|
|
self._set_status("New session started")
|
|
|
|
def _open_session(self):
|
|
path = filedialog.askopenfilename(title="Open Session",
|
|
filetypes=[("Heatmap Session", "*.json"), ("All Files", "*.*")])
|
|
if path: self._load_session(path)
|
|
|
|
def _load_session(self, path: str):
|
|
try:
|
|
self.session = Session.load(path)
|
|
self.session_path = path
|
|
self._refresh_everything()
|
|
self._set_status(f"Loaded: {os.path.basename(path)}")
|
|
except Exception as e:
|
|
messagebox.showerror("Load Error", str(e))
|
|
|
|
def _save_session(self):
|
|
if not self.session_path: self._save_session_as(); return
|
|
try:
|
|
self.session.save(self.session_path)
|
|
self._set_status(f"Saved: {os.path.basename(self.session_path)}")
|
|
except Exception as e:
|
|
messagebox.showerror("Save Error", str(e))
|
|
|
|
def _save_session_as(self):
|
|
path = filedialog.asksaveasfilename(title="Save Session As",
|
|
defaultextension=".json", filetypes=[("Heatmap Session", "*.json")])
|
|
if path:
|
|
self.session_path = path
|
|
self._save_session()
|
|
|
|
def _load_floorplan(self):
|
|
path = filedialog.askopenfilename(title="Load Floorplan Image",
|
|
filetypes=[("Images", "*.png *.jpg *.jpeg *.bmp *.gif *.tiff"), ("All Files", "*.*")])
|
|
if path:
|
|
self.session.floorplan_path = path
|
|
self._update_floorplan_label()
|
|
self._redraw()
|
|
self._set_status("Floorplan loaded")
|
|
|
|
def _remove_floorplan(self):
|
|
self.session.floorplan_path = None
|
|
self._update_floorplan_label()
|
|
self._redraw()
|
|
self._set_status("Floorplan removed — using grid mode")
|
|
|
|
def _update_floorplan_label(self):
|
|
if self.session.floorplan_path:
|
|
self.floorplan_label.config(text=f"✓ {os.path.basename(self.session.floorplan_path)}", fg=SUCCESS)
|
|
self.floorplan_note.config(text="")
|
|
else:
|
|
self.floorplan_label.config(text="None (grid mode)", fg=WARNING)
|
|
self.floorplan_note.config(text="⚠ Adding a floorplan\nincreases accuracy.")
|
|
|
|
def _export_png(self):
|
|
if not self.selected_bssids_for_heatmap:
|
|
messagebox.showinfo("Export", "Select at least one access point first."); return
|
|
path = filedialog.asksaveasfilename(title="Export Heatmap",
|
|
defaultextension=".png",
|
|
filetypes=[("PNG Image", "*.png"), ("JPEG Image", "*.jpg")])
|
|
if path:
|
|
try:
|
|
render_heatmap(self.session, self.selected_bssids_for_heatmap, export_path=path)
|
|
self._set_status(f"Exported: {os.path.basename(path)}")
|
|
messagebox.showinfo("Export", f"Saved to:\n{path}")
|
|
except Exception as e:
|
|
messagebox.showerror("Export Error", str(e))
|
|
|
|
# ── Refresh helpers ───────────────────────────────────────────────────────
|
|
|
|
def _refresh_everything(self):
|
|
self._update_measurements_list()
|
|
self._update_session_info()
|
|
self._update_floorplan_label()
|
|
self._update_heatmap_ap_selector()
|
|
self._redraw()
|
|
|
|
def _update_measurements_list(self):
|
|
self.meas_listbox.delete(0, tk.END)
|
|
for i, m in enumerate(self.session.measurements):
|
|
self.meas_listbox.insert(
|
|
tk.END, f"#{i+1:>3} ({int(m.x):>4}, {int(m.y):>4}) {len(m.signals)} APs")
|
|
count = len(self.session.measurements)
|
|
self.meas_count_label.config(
|
|
text=f"{count} point{'s' if count != 1 else ''} collected")
|
|
|
|
def _update_session_info(self):
|
|
fp = "✓ floorplan" if self.session.floorplan_path else "grid mode"
|
|
path = os.path.basename(self.session_path) if self.session_path else "unsaved"
|
|
self.session_info.config(
|
|
text=f"Session: {self.session.name}\nFile: {path}\nMode: {fp}")
|
|
|
|
def _set_status(self, msg: str, error: bool = False):
|
|
self.status_label.config(text=msg, fg=DANGER if error else TEXT_DIM)
|
|
|
|
# ── Styles ────────────────────────────────────────────────────────────────
|
|
|
|
def _apply_styles(self):
|
|
style = ttk.Style()
|
|
style.theme_use("clam")
|
|
style.configure("TSeparator", background=BORDER)
|
|
style.configure("TScrollbar",
|
|
background=BG3, troughcolor=BG2,
|
|
bordercolor=BORDER, arrowcolor=TEXT_DIM)
|
|
style.configure("TNotebook",
|
|
background=BG2, borderwidth=0, tabmargins=0)
|
|
style.configure("TNotebook.Tab",
|
|
background=BG3, foreground=TEXT_DIM,
|
|
font=("Courier", 9, "bold"),
|
|
padding=(14, 8), borderwidth=0)
|
|
style.map("TNotebook.Tab",
|
|
background=[("selected", BG)],
|
|
foreground=[("selected", ACCENT2)])
|
|
style.configure("TCombobox",
|
|
fieldbackground=BG3, background=BG3,
|
|
foreground=TEXT, selectbackground=ACCENT,
|
|
bordercolor=BORDER, arrowcolor=TEXT_DIM)
|
|
|
|
|
|
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="WiFi Heatmap Generator")
|
|
parser.add_argument("--session", help="Path to a .json session file to load on startup")
|
|
args = parser.parse_args()
|
|
|
|
root = tk.Tk()
|
|
root.title("WiFi Heatmap Generator")
|
|
try:
|
|
root.iconbitmap(default="")
|
|
except Exception:
|
|
pass
|
|
|
|
WiFiHeatmapApp(root, initial_session=args.session)
|
|
root.protocol("WM_DELETE_WINDOW", lambda: (root.destroy()))
|
|
root.mainloop()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|