From a7defe8da67a4e689ca23c18b9cb493f8a2591b5 Mon Sep 17 00:00:00 2001 From: Atlaskor Date: Tue, 4 Nov 2025 16:22:40 +0000 Subject: [PATCH] Upload files to "/" An easier way to edit and maneuver configs for a 5AC gen 2. --- 5ACConfig.py | 392 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 5ACConfig.py diff --git a/5ACConfig.py b/5ACConfig.py new file mode 100644 index 0000000..eef1033 --- /dev/null +++ b/5ACConfig.py @@ -0,0 +1,392 @@ +#!/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()