#!/usr/bin/env python3 import tkinter as tk from tkinter import ttk, filedialog, messagebox import datetime, ipaddress, re APP_TITLE = "Ubiquiti 5AC Gen2 Config Studio (airOS system.cfg)" WINDOW_W, WINDOW_H = 980, 820 # ----------------- helpers ----------------- BOOL_TRUE = {"enabled","on","true","1","yes"} BOOL_FALSE = {"disabled","off","false","0","no"} def str2bool(s: str): t = str(s).strip().lower() if t in BOOL_TRUE: return True if t in BOOL_FALSE: return False return None def bool2enabled(b: bool) -> str: return "enabled" if bool(b) else "disabled" def coerce_ip(v: str) -> str: v = v.strip().strip('"') if not v: return v ipaddress.ip_address(v) return v def coerce_netmask(v: str) -> str: v = v.strip().strip('"') if not v: return v ipaddress.IPv4Network(f"10.0.0.0/{v}") return v def guess_type(key: str, value: str) -> str: s = value.strip().strip('"') if str2bool(s) is not None: return "bool" if re.search(r"(?:^|\.)(ip|gateway|nameserver)\b", key): try: coerce_ip(s); return "ip" except: pass if re.search(r"(?:^|\.)(netmask)\b", key): try: coerce_netmask(s); return "netmask" except: pass try: if "." in s: float(s); return "float" int(s); return "int" except: pass return "str" SECTION_MAP = [ ("System", r"^(system\.|country\.|users\.|cfg\.)"), ("Services", r"^(ssh\.|telnet\.|httpd\.|https\.|ping\.|dropbear\.|discovery\.)"), ("NTP/SNMP", r"^(ntpclient\.|snmp\.)"), ("Network", r"^(netconf\.|resolv\.)"), ("Bridge/VLAN", r"^(bridge\.)"), ("Wireless", r"^(wireless\.)"), ("Radio", r"^(radio\.)"), ("airMAX/QoS", r"^(airmax\.|qos\.)"), ("Watchdog", r"^(watchdog\.)"), ("Misc", r"^(statusled\.|gps\.|poe\.)"), ] def guess_section(key: str) -> str: for name, pat in SECTION_MAP: if re.search(pat, key): return name return "Misc" # ----------------- base schema (minimal; importer learns the rest) ------------- SCHEMA = { "system.host.name": {"type":"str","default":"ubnt-5ac","label":"Device Name","section":"System"}, "system.timezone": {"type":"str","default":"UTC","label":"Timezone","section":"System"}, "ssh.status": {"type":"bool","default":True,"label":"SSH","section":"Services"}, "httpd.status": {"type":"bool","default":True,"label":"HTTP","section":"Services"}, "https.status": {"type":"bool","default":True,"label":"HTTPS","section":"Services"}, "netconf.1.proto": {"type":"str","default":"dhcp","label":"IP Mode (dhcp/static)","section":"Network"}, "wireless.1.mode": {"type":"str","default":"ap","label":"Wireless Mode (ap/station)","section":"Wireless"}, "wireless.1.ssid": {"type":"str","default":"AirMAX-AP","label":"SSID","section":"Wireless"}, "radio.1.freq": {"type":"str","default":"5180","label":"Center Freq MHz","section":"Radio"}, } SECTIONS_ORDER = [ "System","Services","NTP/SNMP","Network","Bridge/VLAN","Wireless","Radio", "airMAX/QoS","Watchdog","Misc","Custom / Unknown" ] # ----------------- Scrollable tab helper ----------------- class ScrollableTab(ttk.Frame): """A vertically scrollable frame for Notebook tabs.""" def __init__(self, master, *args, **kwargs): super().__init__(master, *args, **kwargs) self.canvas = tk.Canvas(self, highlightthickness=0) self.vscroll = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview) self.canvas.configure(yscrollcommand=self.vscroll.set) self.canvas.grid(row=0, column=0, sticky="nsew") self.vscroll.grid(row=0, column=1, sticky="ns") self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) # Inner content frame self.content = ttk.Frame(self.canvas) self.content_id = self.canvas.create_window((0, 0), window=self.content, anchor="nw") # Resize handling self.content.bind("", self._on_frame_configure) self.canvas.bind("", self._on_canvas_configure) # Mouse wheel on all platforms self._bind_mousewheel(self) def _on_frame_configure(self, event): # Update scrollregion to match inner frame self.canvas.configure(scrollregion=self.canvas.bbox("all")) def _on_canvas_configure(self, event): # Match inner frame width to canvas width canvas_w = event.width self.canvas.itemconfig(self.content_id, width=canvas_w) def _bind_mousewheel(self, widget): # Windows / macOS widget.bind_all("", self._on_mousewheel, add="+") # Linux/X11 widget.bind_all("", self._on_mousewheel, add="+") widget.bind_all("", self._on_mousewheel, add="+") # Prevent scroll hijack when switching tabs widget.bind_all("<>", self._on_tab_changed, add="+") def _on_mousewheel(self, event): if event.num == 4: # Linux scroll up self.canvas.yview_scroll(-3, "units") elif event.num == 5: # Linux scroll down self.canvas.yview_scroll(3, "units") else: # Windows/macOS # event.delta positive up, negative down; typical delta=120 multiples self.canvas.yview_scroll(int(-1*(event.delta/120)), "units") def _on_tab_changed(self, event): # Reset focus so wheel goes to visible tab self.focus_set() # ----------------- App ----------------- class ConfigStudio(tk.Tk): def __init__(self): super().__init__() self.title(APP_TITLE) self.geometry(f"{WINDOW_W}x{WINDOW_H}") self.minsize(900, 700) # model self.values = {k: SCHEMA[k].get("default","") for k in SCHEMA} self.dynamic_schema = {} # keys discovered from imports self.custom_lines = [] # lines without '=' # UI self._build_ui() # ---------- UI scaffold ---------- def _build_ui(self): topbar = ttk.Frame(self) topbar.pack(fill="x", padx=10, pady=8) ttk.Button(topbar, text="Import system.cfg…", command=self.on_import).pack(side="left") ttk.Button(topbar, text="Save system.cfg…", command=self.on_export).pack(side="left", padx=8) ttk.Button(topbar, text="Preview", command=self.on_preview).pack(side="left") ttk.Button(topbar, text="Reset", command=self.on_reset).pack(side="left", padx=8) ttk.Label(topbar, text="Search keys").pack(side="left", padx=(20,6)) self.search_var = tk.StringVar() ttk.Entry(topbar, textvariable=self.search_var, width=28).pack(side="left") ttk.Button(topbar, text="Go", command=self.on_search).pack(side="left", padx=6) ttk.Button(topbar, text="Clear", command=self.on_search_clear).pack(side="left") self.nb = ttk.Notebook(self) self.nb.pack(fill="both", expand=True, padx=10, pady=(0,10)) # Build scrollable tabs self.section_tabs = {} self.section_frames = {} # points to the scrollable content frame for sec in SECTIONS_ORDER: tab = ScrollableTab(self.nb) self.nb.add(tab, text=sec) self.section_tabs[sec] = tab self.section_frames[sec] = tab.content # use .content for widgets # Custom / Unknown raw editor self.custom_text = tk.Text(self.section_frames["Custom / Unknown"], wrap="none") self.custom_text.pack(fill="both", expand=True, padx=8, pady=8) foot = ttk.Label(self, foreground="#666", text="Imports learn ALL keys and render them as editable fields. Only lines without '=' go to Custom / Unknown. Each tab scrolls.") foot.pack(fill="x", padx=10, pady=(0,10)) self._rebuild_controls() # ---------- controls ---------- def _combined_schema(self): merged = dict(SCHEMA) merged.update(self.dynamic_schema) return merged def _rebuild_controls(self, filter_text: str|None=None): # clear non-custom sections for sec, frm in self.section_frames.items(): if sec == "Custom / Unknown": continue for w in frm.winfo_children(): w.destroy() self.widgets = [] rows = {sec:0 for sec in self.section_frames} for key, meta in self._combined_schema().items(): sec = meta.get("section","Misc") if sec not in self.section_frames: sec = "Misc" if filter_text: ft = filter_text.lower() if ft not in key.lower() and ft not in meta.get("label", key).lower(): continue frm = self.section_frames[sec] # label over entry ttk.Label(frm, text=meta.get("label", key)).grid(row=rows[sec], column=0, sticky="w", pady=(10,2)) rows[sec]+=1 t = meta["type"] if t == "bool": bvar = tk.BooleanVar(value=bool(self.values.get(key, meta.get("default", False)))) w = ttk.Checkbutton(frm, variable=bvar) w._bindvar = bvar else: w = ttk.Entry(frm) w.insert(0, str(self.values.get(key, meta.get("default","")))) w._key = key w._meta = meta w.grid(row=rows[sec], column=0, sticky="we") frm.grid_columnconfigure(0, weight=1) rows[sec]+=1 ttk.Label(frm, text=key, foreground="#777").grid(row=rows[sec], column=0, sticky="w") rows[sec]+=1 self.widgets.append(w) self._refresh_custom_text() def _refresh_custom_text(self): self.custom_text.delete("1.0","end") for ln in self.custom_lines: self.custom_text.insert("end", ln + "\n") # ---------- import / export ---------- def on_import(self): path = filedialog.askopenfilename(title="Open system.cfg", filetypes=[("Config","*.cfg"),("All Files","*.*")]) if not path: return try: with open(path, "r", encoding="utf-8", errors="ignore") as f: lines = f.read().splitlines() known_count = 0 dynamic_added = 0 self.custom_lines = [] for raw in lines: line = raw.strip() if not line or line.startswith("#"): continue if "=" not in line: self.custom_lines.append(line) continue k, v = line.split("=", 1) k = k.strip() v = v.strip().strip('"') if k not in SCHEMA and k not in self.dynamic_schema: t = guess_type(k, v) sec = guess_section(k) self.dynamic_schema[k] = {"type": t, "default":"", "label": k, "section": sec} dynamic_added += 1 meta = self._combined_schema()[k] t = meta["type"] if t == "bool": b = str2bool(v) self.values[k] = bool(b) if b is not None else (v != "") elif t == "ip": try: self.values[k] = coerce_ip(v) except: self.values[k] = v elif t == "netmask": try: self.values[k] = coerce_netmask(v) except: self.values[k] = v else: self.values[k] = v known_count += 1 self._rebuild_controls() messagebox.showinfo("Imported", f"Loaded {known_count} keys.\n" f"Learned {dynamic_added} new keys into the UI.\n" f"{len(self.custom_lines)} line(s) kept as raw (no '=').") except Exception as e: messagebox.showerror("Import failed", str(e)) def _read_widgets_into_values(self): for w in self.widgets: key = w._key meta = w._meta t = meta["type"] if t == "bool": self.values[key] = bool(w._bindvar.get()) else: self.values[key] = w.get().strip() if self.values.get("netconf.1.proto","").lower() == "static": for need in ("netconf.1.ip","netconf.1.netmask","netconf.1.gateway"): if need in self._combined_schema(): if not str(self.values.get(need,"")).strip(): raise ValueError(f"{need} is required for static IP") def _generate_config_text(self) -> str: self._read_widgets_into_values() out = [] merged = self._combined_schema() ordered_keys = list(SCHEMA.keys()) + sorted([k for k in merged.keys() if k not in SCHEMA]) for k in ordered_keys: meta = merged[k] val = self.values.get(k, "") t = meta["type"] if t == "bool": out.append(f"{k}={bool2enabled(bool(val))}") else: s = str(val).strip() if s != "": out.append(f"{k}={s}") for ln in [ln.strip() for ln in self.custom_text.get("1.0","end").splitlines() if ln.strip()]: out.append(ln) out.append(f"# generated_by={APP_TITLE.replace(' ','_')}") out.append(f"# generated_at={datetime.datetime.utcnow().isoformat()}Z") return "\n".join(out) + "\n" def on_export(self): try: cfg = self._generate_config_text() except Exception as e: messagebox.showerror("Validation error", str(e)); return path = filedialog.asksaveasfilename(title="Save system.cfg", defaultextension=".cfg", initialfile="system.cfg", filetypes=[("Config","*.cfg"),("All Files","*.*")]) if not path: return try: with open(path, "w", encoding="utf-8") as f: f.write(cfg) messagebox.showinfo("Saved", f"Wrote {path}") except Exception as e: messagebox.showerror("Save failed", str(e)) def on_preview(self): try: cfg = self._generate_config_text() except Exception as e: messagebox.showerror("Validation error", str(e)); return win = tk.Toplevel(self); win.title("Preview: system.cfg"); win.geometry("820x600") t = tk.Text(win, wrap="none"); t.insert("1.0", cfg); t.configure(state="disabled"); t.pack(fill="both", expand=True) ttk.Button(win, text="Close", command=win.destroy).pack(pady=6) def on_reset(self): self.values = {k: SCHEMA[k].get("default","") for k in SCHEMA} self.dynamic_schema = {} self.custom_lines = [] self._rebuild_controls() # ---------- search ---------- def on_search(self): q = self.search_var.get().strip().lower() self._rebuild_controls(q if q else None) def on_search_clear(self): self.search_var.set("") self._rebuild_controls() # ----------------- main ----------------- if __name__ == "__main__": app = ConfigStudio() app.mainloop()