Files
5ACConfigurer/5ACConfig.py
Atlaskor a7defe8da6 Upload files to "/"
An easier way to edit and maneuver configs for a 5AC gen 2.
2025-11-04 16:22:40 +00:00

393 lines
15 KiB
Python

#!/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("<Configure>", self._on_frame_configure)
self.canvas.bind("<Configure>", 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("<MouseWheel>", self._on_mousewheel, add="+")
# Linux/X11
widget.bind_all("<Button-4>", self._on_mousewheel, add="+")
widget.bind_all("<Button-5>", self._on_mousewheel, add="+")
# Prevent scroll hijack when switching tabs
widget.bind_all("<<NotebookTabChanged>>", 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()