Upload files to "/"
An easier way to edit and maneuver configs for a 5AC gen 2.
This commit is contained in:
392
5ACConfig.py
Normal file
392
5ACConfig.py
Normal file
@@ -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("<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()
|
||||
Reference in New Issue
Block a user