From a03b2259efab05fed12098ad004a0ebda4b9b752 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 15 Oct 2026 16:56:38 +0100 Subject: [PATCH 01/21] Porting to python so it's easier to implement a full switch-emulator with state --- tools/httpd_sim.py | 504 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100755 tools/httpd_sim.py diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py new file mode 100755 index 00000000..f785aec2 --- /dev/null +++ b/tools/httpd_sim.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +"""Serve files from the current directory and provide the example device API.""" + +import os +import secrets +import time +import re +from email.parser import BytesParser +from email.policy import default +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlsplit + +HOST = "0.0.0.0" +PORT = 8081 +PASSWORD = "1234" + +PORTS = 6 +NSFP = 1 +N_COUNTERS = 52 +VLAN_MAX_ID = 4094 +L2_MAX_TRANSFER = 5 + +ROOT = Path.cwd().resolve() + +phys_to_log_port = [] + +if PORTS == 9: + phys_to_log_port = [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ] +else: + phys_to_log_port = [ 4, 5, 6, 7, 3, 8 ] + +l2_entries = [ + { "mac": "00:01:2f:00:00:01", "vlan":"001", "type":'l', "port":7, "idx":0x10 }, + { "mac": "00:06:78:00:00:02", "vlan":"001", "type":'l', "port":7, "idx":0x20 }, + { "mac": "00:01:2e:00:00:03", "vlan":"001", "type":'s', "port":7, "idx":0x30 }, + { "mac": "60:7d:09:00:00:04", "vlan":"001", "type":'l', "port":7, "idx":0x40 }, + { "mac": "1c:2a:a3:00:00:05", "vlan":"001", "type":'l', "port":7, "idx":0x50 }, + { "mac": "1c:2a:a3:00:00:06", "vlan":"001", "type":'s', "port":7, "idx":0x60 }, + { "mac": "02:e0:4c:00:00:07", "vlan":"001", "type":'l', "port":7, "idx":0x70 }, + { "mac": "00:e0:4c:00:00:08", "vlan":"001", "type":'l', "port":4, "idx":0x80 }, + { "mac": "1c:2a:a3:00:00:09", "vlan":"001", "type":'l', "port":9, "idx":0x90 }, # CPU-Port + { "mac": "3c:8c:f8:00:00:0a", "vlan":"001", "type":'l', "port":7, "idx":0xa0 }, + { "mac": "00:01:2e:00:00:0b", "vlan":"001", "type":'l', "port":7, "idx":0xb0 }, + { "mac": "00:01:2f:00:01:01", "vlan":"001", "type":'l', "port":7, "idx":0xc0 } +] + +state = { + "lldp_enabled": False, + "vlans": {1: {"active": True, "name": ""}} +} + +cmd_history = [] +tx_g = [0] * PORTS +rx_g = [0] * PORTS +tx_b = [0] * PORTS +rx_b = [0] * PORTS +last_called = int(time.time()) + +sessions = set() +uploaded_config = None + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(ROOT), **kwargs) + + def api_handlers(self,query): + return { + "/status.json": self._status, + "/eee.json": self._eee, + "/bandwidth.json": self._bandwidth, + "/information.json": self._information, + "/mirror.json": self._mirror, + "/lag.json": self._lag, + "/l2.json": lambda: self._l2(query), + "/mtu.json": self._mtu, + "/vlan.json": lambda: self._vlan(query), + "/vlanlist": self._vlanlist, + "/lldp.json": self._lldp, + "/cmd_log": self._cmd_log, + "/counters.json": self._counters, + } + + def _send(self, status, body=b"", content_type="text/plain", + headers=()): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + for name, value in headers: + self.send_header(name, value) + self.end_headers() + if body: + self.wfile.write(body) + + def _json(self, value, headers=()): + import json + + body = json.dumps(value, separators=(",", ":")).encode("utf-8") + return self._send( + 200, body, "application/json", headers=headers + ) + + def _redirect(self, location, headers=()): + return self._send( + 302, headers=(("Location", location), *headers) + ) + + def _authenticated(self): + for item in self.headers.get("Cookie", "").split(";"): + name, _, value = item.strip().partition("=") + if name == "session" and value in sessions: + return True + return False + + def _protected(self): + if not self._authenticated(): + self._send(401, b"Unauthorized\n") + return True + return False + + @staticmethod + def _int_param(query, name): + try: + return int(query.get(name, ["0"])[0], 0) + except ValueError: + return 0 + + def _api_response(self, path, query): + handler = self.api_handlers(query).get(path) + return handler() if handler else {} + + def do_GET(self): + global uploaded_config + + parsed = urlsplit(self.path) + path = unquote(parsed.path) + query = parse_qs(parsed.query) + + if path in self.api_handlers(None).keys(): + if self._protected(): + return + result = self._api_response(path, query) + if isinstance(result, tuple): + body, content_type = result + return self._send(200, body, content_type) + return self._json( + result, + headers=(("Cache-Control", "no-cache"),) + if path in ("/status.json", "/counters.json") + else (), + ) + + if path == "/config" and uploaded_config is not None: + if self._protected(): + return + return self._send(200, uploaded_config, "text/plain") + + if not self._authenticated() and path not in ("/login.html", "/favicon.ico"): + return self._redirect("/login.html") + + # Resolve files under ROOT only; reject path traversal. + relative = path.lstrip("/") or "index.html" + target = (ROOT / relative).resolve() + if target != ROOT and ROOT not in target.parents: + return self._send(404, b"Not found\n") + + if not target.is_file(): + return self._send(404, b"Not found\n") + + try: + data = target.read_bytes() + except OSError: + return self._send(404, b"Not found\n") + + import mimetypes + + mime = mimetypes.guess_type(str(target))[0] or "application/octet-stream" + return self._send( + 200, + data, + f"{mime}", + headers=(("Cache-Control", "max-age=60, must-revalidate"),), + ) + + def do_POST(self): + global uploaded_config + + path = urlsplit(self.path).path + + if path == "/login": + form = self._read_urlencoded_form() + if form.get("pwd", [""])[0] == PASSWORD: + token = secrets.token_urlsafe(32) + sessions.add(token) + return self._redirect( + "/index.html", + headers=(( + "Set-Cookie", + f"session={token}; Path=/; SameSite=Strict; HttpOnly", + ),), + ) + return self._redirect("/login.html") + + if self._protected(): + return + + if path == "/cmd": + form = self._read_urlencoded_form() + command = form.get("cmd", [""])[0] + cmd_history.append(command) + + self.parse_cmd(command); + + return self._send( + 200, + b" Upload OK" + b"

Command executed successully

", + "text/html", + ) + + if path in ("/upload", "/config"): + if self.headers.get_content_type() != "multipart/form-data": + return self._send(400, b"Expected multipart/form-data\n") + + parts = self._read_multipart() + if parts is None: + return self._send(400, b"Bad multipart request\n") + + file_data = next( + ( + data for content_type, data in parts + if content_type == "application/octet-stream" + ), + None, + ) + if file_data is None: + return self._send( + 400, b"No application/octet-stream part\n" + ) + + if path == "/config": + uploaded_config = file_data + + return self._send( + 200, + b" Upload OK" + b"

File uploaded successfully

", + "text/html", + ) + + return self._send(404, b"Not found\n") + + def _read_urlencoded_form(self): + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8", errors="replace") + return parse_qs(raw) + + def _read_multipart(self): + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length) + content_type = self.headers.get("Content-Type", "") + message = BytesParser(policy=default).parsebytes( + b"Content-Type: " + + content_type.encode("utf-8") + + b"\r\nMIME-Version: 1.0\r\n\r\n" + + raw + ) + + if not message.is_multipart(): + return None + + parts = [] + for part in message.iter_parts(): + parts.append(( + part.get_content_type(), + part.get_payload(decode=True) or b"", + )) + return parts + + def parse_cmd(self, cmd): + pieces = cmd.split(" ") + handler = self.get_parsers().get(pieces[0]) + return handler(pieces[1:]) if handler else {} + + def get_parsers(self): + return { + "vlan": self._parse_vlan, + "lldp": self._parse_lldp + } + + def _parse_vlan(self, pieces): #vlan 2 name 6 3 8t or vlan 2 6 3 8t or vlan 2 d + global state + vid = int(pieces[1]); + startIndex=2 + if pieces[2] == "d": + return remove_vlan(vid) + elif re.match(pieces[2], '^[A-Za-z]+$'): + name = pieces[2] + startIndex=3 + + state.vlans[vid] = {"active":True, "name":name, "untagged":[], "tagged":[]} + # list of ports in either 3 or 5t form + for i in pieces[startIndex:]: + if pieces[i][-1] == "t": + state.vlans[vid].tagged.append(pieces[i]) + else: + state.vlans[vid].untagged.append(pieces[i]) + + def _parse_lldp(self,pieces): + global state + state.lldp_enabled = (pieces[0] == "on") + + def _status(self): + global last_called + now = max(int(time.time()), last_called + 1) + elapsed = now - last_called + ports = [] + + for i in range(1, PORTS + 1): + n = i - 1 + if i % 2: + rate = 2_400_000_000 if i == 1 else 950_000_000 + tx_g[n] += rate * elapsed + rx_g[n] += rate * elapsed + tx_b[n] += rate * elapsed // 10_000_000 + rx_b[n] += rate * elapsed // 10_000_000 + + item = { + "portNum": i, + "logPort": phys_to_log_port[n], + "isSFP": int(i > PORTS - NSFP), + "enabled": int(i % 4 != 0), + "link": (5 if i == 1 else 2) if i % 2 else 0, + "txG": f"0x{tx_g[n]:016x}", + "txB": f"0x{tx_b[n]:016x}", + "rxG": f"0x{rx_g[n]:016x}", + "rxB": f"0x{rx_b[n]:016x}", + } + + if i >= PORTS - NSFP: + item.update({ + "sfp_vendor": "OEM", + "sfp_model": "10G-SFP+", + "sfp_serial": "12345678", + "sfp_options": "0x68", + "sfp_temp": f"0x{0x28fb + secrets.randbelow(100):04x}", + "sfp_vcc": f"0x{0x7eda + secrets.randbelow(100):04x}", + "sfp_txbias": f"0x{0x0d24 + secrets.randbelow(100):04x}", + "sfp_txpower": f"0x{0x14bd + secrets.randbelow(100):04x}", + "sfp_rxpower": "0x0000", + "sfp_laser": "0x0000", + }) + else: + item["adv"] = ( + "100000" if i == 1 + else "000011" if i == 2 + else "000100" + ) + + ports.append(item) + + last_called = now + return ports + + def _eee(self): + return [ + { + "portNum": i, + "isSFP": int(i >= 5), + "eee": "00000010", + "eee_lp": "00000100", + "active": int(i % 2 == 1), + } + for i in range(1, PORTS + 1) + ] + + def _bandwidth(self): + ports = [] + for i in range(1, PORTS + 1): + ingress_limited = i % 2 == 1 + egress_limited = i % 4 != 0 + ports.append({ + "portNum": i, + "iLimited": int(ingress_limited), + "iFC": int(i % 4 != 0) if ingress_limited else 0, + "iBW": f"{0x100 if ingress_limited else 0xfffff:08x}", + "eLimited": int(egress_limited), + "eBW": f"{0x1000 if egress_limited else 0xfffff:08x}", + }) + return ports + + def _mirror(self): + return { + "mPort": 1, + "enabled": 1, + "mirror_tx": f"{0b000110:016b}", + "mirror_rx": f"{0b000010:016b}", + } + + def _lag(self): + return [ + { + "lagNum": i, + "members": f"{(0x30 << (i * 2)) & 0x1f8:016b}", + "hash": "0x7e", + } + for i in range(4) + ] + + def _mtu(self): + return [ + { + "portNum": i, + "mtu": f"0x{16383 if i % 2 else 1522:04x}", + } + for i in range(1, PORTS + 1) + ] + + def _lldp(self): + return { + "on": 1, + "portStatus": [ + { + "portNum": i, + "logPort": i - 1, + "name": "", + "permitted": 1, + } + for i in range(1, PORTS + 1) + ], + } + + def _counters(self): + return [f"0x{0x1234 + i:016x}" for i in range(N_COUNTERS)] + + def _vlanlist(self): + global state + vlan_list = [] + for i in state.get("vlans").keys(): + if i in state.get("vlans") and state.get("vlans")[i]["active"]: + vlan_list.append({ + "id": i, + "name": state.get("vlans")[i]["name"], + }) + return {"mgmt": 1, "vlan": vlan_list} + + def _vlan(self,query): + global state + vlan_id = self._int_param(query, "vid") + if vlan_id == 1: + return { + "members": "0x0207ffff", + "name": "native", + "pvid": "0x01ff", + } + + vlan = state.get("vlans").get(vlan_id) + if vlan and vlan.get("active"): + return { + "name": vlan["name"], + "members": "0x0000", + "pvid": "0x0000", + } + return {} + + def _l2(self,query): + idx = self._int_param(query, "idx") + entries = sorted(l2_entries, key=lambda entry: entry["idx"]) + start = next( + (n for n, entry in enumerate(entries) if entry["idx"] >= idx), + len(entries), + ) + result = [] + for entry in entries[start:start + L2_MAX_TRANSFER]: + result.append({ + "mac": entry["mac"], + "vlan": entry["vlan"], + "type": "l" if entry.get("type") == "l" else "s", + "port": entry["port"], + "idx": f"{entry['idx']:04x}", + }) + return result + + def _cmd_log(self): + text = "\n".join(cmd_history) + if cmd_history: + text += "\n" + return text.encode("utf-8"), "text/plain" + + def _information(self): + return { + "ip_address":"192.168.10.247", + "ip_gateway":"192.168.2.22", + "ip_netmask":"255.255.255.0", + "mac_address":"1c:2a:a3:23:00:02", + "sw_ver":"0.1-sim", + "hw_ver":"SWGT024-V2.0" + } + + +if __name__ == "__main__": + print(f"Serving {ROOT} on http://{HOST}:{PORT}") + ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() From e83ff4d095ffa543920b1692c86a234ba32e8168 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Fri, 16 Oct 2026 09:51:46 +0100 Subject: [PATCH 02/21] Renaming to _api for anything that is called from the http handler to help seperate out the parsers that'll be coming in for cmd putting all state under one global and implementing something resembling vlan parsing --- tools/httpd_sim.py | 146 +++++++++++++++++++++++++++------------------ 1 file changed, 89 insertions(+), 57 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index f785aec2..19845822 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -47,10 +47,11 @@ l2_entries = [ state = { "lldp_enabled": False, - "vlans": {1: {"active": True, "name": ""}} + "vlans": {1: {"active": True, "name": "", "untagged":[1,2,3,4,5,6,7,8], "tagged":[], "pvid":[1,2,3,4,5,6,7,8]}}, + "cmd_history": [] } -cmd_history = [] + tx_g = [0] * PORTS rx_g = [0] * PORTS tx_b = [0] * PORTS @@ -67,19 +68,20 @@ class Handler(SimpleHTTPRequestHandler): def api_handlers(self,query): return { - "/status.json": self._status, - "/eee.json": self._eee, - "/bandwidth.json": self._bandwidth, - "/information.json": self._information, - "/mirror.json": self._mirror, - "/lag.json": self._lag, - "/l2.json": lambda: self._l2(query), - "/mtu.json": self._mtu, - "/vlan.json": lambda: self._vlan(query), - "/vlanlist": self._vlanlist, - "/lldp.json": self._lldp, - "/cmd_log": self._cmd_log, - "/counters.json": self._counters, + "/status.json": self._api_status, + "/eee.json": self._api_eee, + "/bandwidth.json": self._api_bandwidth, + "/information.json": self._api_information, + "/mirror.json": self._api_mirror, + "/lag.json": self._api_lag, + "/l2.json": lambda: self._api_l2(query), + "/mtu.json": self._api_mtu, + "/vlan.json": lambda: self._api_vlan(query), + "/vlanlist": self._api_vlanlist, + "/lldp.json": self._api_lldp, + "/cmd_log": self._api_cmd_log, + "/counters.json": self._api_counters, + "/debug": self._api_debug, } def _send(self, status, body=b"", content_type="text/plain", @@ -184,7 +186,7 @@ class Handler(SimpleHTTPRequestHandler): ) def do_POST(self): - global uploaded_config + global uploaded_config, state path = urlsplit(self.path).path @@ -206,10 +208,9 @@ class Handler(SimpleHTTPRequestHandler): return if path == "/cmd": - form = self._read_urlencoded_form() - command = form.get("cmd", [""])[0] - cmd_history.append(command) - + command = self._read_raw() + state["cmd_history"].append(command) + print(f"Received command: '{command}'") self.parse_cmd(command); return self._send( @@ -251,6 +252,11 @@ class Handler(SimpleHTTPRequestHandler): return self._send(404, b"Not found\n") + def _read_raw(self): + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8", errors="replace") + return raw + def _read_urlencoded_form(self): length = int(self.headers.get("Content-Length", "0")) raw = self.rfile.read(length).decode("utf-8", errors="replace") @@ -281,37 +287,45 @@ class Handler(SimpleHTTPRequestHandler): def parse_cmd(self, cmd): pieces = cmd.split(" ") handler = self.get_parsers().get(pieces[0]) - return handler(pieces[1:]) if handler else {} + return handler(pieces) if handler else {} def get_parsers(self): return { "vlan": self._parse_vlan, - "lldp": self._parse_lldp + "lldp": self._parse_lldp, + "pvid": self._parse_pvid } - def _parse_vlan(self, pieces): #vlan 2 name 6 3 8t or vlan 2 6 3 8t or vlan 2 d + def _parse_vlan(self, pieces): global state vid = int(pieces[1]); startIndex=2 + name="" if pieces[2] == "d": return remove_vlan(vid) - elif re.match(pieces[2], '^[A-Za-z]+$'): + elif not re.match('^[0-9]+$', pieces[2]): name = pieces[2] startIndex=3 - state.vlans[vid] = {"active":True, "name":name, "untagged":[], "tagged":[]} - # list of ports in either 3 or 5t form - for i in pieces[startIndex:]: - if pieces[i][-1] == "t": - state.vlans[vid].tagged.append(pieces[i]) - else: - state.vlans[vid].untagged.append(pieces[i]) + state["vlans"][vid] = {"active":True, "name":name, "untagged":[], "tagged":[], "pvid":[]} - def _parse_lldp(self,pieces): + for i in pieces[startIndex:]: + if i[-1] == "t": + state["vlans"][vid]["tagged"].append(phys_to_log_port[int(i[:-1])-1]) + else: + state["vlans"][vid]["untagged"].append(phys_to_log_port[int(i)-1]) + + def _parse_pvid(self, pieces): + global state + port = int(pieces[1]) + vid = int(pieces[2]) + state["vlans"][vid]["pvid"].append(phys_to_log_port[port-1]) + + def _parse_lldp(self, pieces): global state state.lldp_enabled = (pieces[0] == "on") - def _status(self): + def _api_status(self): global last_called now = max(int(time.time()), last_called + 1) elapsed = now - last_called @@ -363,7 +377,7 @@ class Handler(SimpleHTTPRequestHandler): last_called = now return ports - def _eee(self): + def _api_eee(self): return [ { "portNum": i, @@ -375,7 +389,7 @@ class Handler(SimpleHTTPRequestHandler): for i in range(1, PORTS + 1) ] - def _bandwidth(self): + def _api_bandwidth(self): ports = [] for i in range(1, PORTS + 1): ingress_limited = i % 2 == 1 @@ -390,7 +404,7 @@ class Handler(SimpleHTTPRequestHandler): }) return ports - def _mirror(self): + def _api_mirror(self): return { "mPort": 1, "enabled": 1, @@ -398,7 +412,7 @@ class Handler(SimpleHTTPRequestHandler): "mirror_rx": f"{0b000010:016b}", } - def _lag(self): + def _api_lag(self): return [ { "lagNum": i, @@ -408,7 +422,7 @@ class Handler(SimpleHTTPRequestHandler): for i in range(4) ] - def _mtu(self): + def _api_mtu(self): return [ { "portNum": i, @@ -417,7 +431,7 @@ class Handler(SimpleHTTPRequestHandler): for i in range(1, PORTS + 1) ] - def _lldp(self): + def _api_lldp(self): return { "on": 1, "portStatus": [ @@ -431,10 +445,10 @@ class Handler(SimpleHTTPRequestHandler): ], } - def _counters(self): + def _api_counters(self): return [f"0x{0x1234 + i:016x}" for i in range(N_COUNTERS)] - def _vlanlist(self): + def _api_vlanlist(self): global state vlan_list = [] for i in state.get("vlans").keys(): @@ -445,26 +459,38 @@ class Handler(SimpleHTTPRequestHandler): }) return {"mgmt": 1, "vlan": vlan_list} - def _vlan(self,query): + def _api_vlan(self,query): global state vlan_id = self._int_param(query, "vid") - if vlan_id == 1: - return { - "members": "0x0207ffff", - "name": "native", - "pvid": "0x01ff", - } - vlan = state.get("vlans").get(vlan_id) - if vlan and vlan.get("active"): + vlan = state["vlans"][vlan_id] + if vlan and vlan["active"]: return { "name": vlan["name"], - "members": "0x0000", - "pvid": "0x0000", + "members": self.vlanMembers(vlan), + "pvid": self.vlanPvid(vlan), } return {} - def _l2(self,query): + def vlanMembers(self, vlan): + member_mask = 0 + for port in vlan["tagged"] + vlan["untagged"]: + member_mask |= 1 << port + + untagged_mask = 0 + for port in vlan["untagged"]: + untagged_mask |= 1 << port + + members = member_mask | (untagged_mask << 10) + return f"0x{members:08x}" + + def vlanPvid(self, vlan): + pvid_mask = 0 + for port in vlan["pvid"]: + pvid_mask |= 1 << port + return f"0x{pvid_mask:04x}" + + def _api_l2(self,query): idx = self._int_param(query, "idx") entries = sorted(l2_entries, key=lambda entry: entry["idx"]) start = next( @@ -482,13 +508,14 @@ class Handler(SimpleHTTPRequestHandler): }) return result - def _cmd_log(self): - text = "\n".join(cmd_history) - if cmd_history: + def _api_cmd_log(self): + global state + text = "\n".join(state["cmd_history"]) + if state["cmd_history"]: text += "\n" return text.encode("utf-8"), "text/plain" - def _information(self): + def _api_information(self): return { "ip_address":"192.168.10.247", "ip_gateway":"192.168.2.22", @@ -498,6 +525,11 @@ class Handler(SimpleHTTPRequestHandler): "hw_ver":"SWGT024-V2.0" } + def _api_debug(self): + global state + return { + "state": state + } if __name__ == "__main__": print(f"Serving {ROOT} on http://{HOST}:{PORT}") From 69dd9777081329bac3b61370da5431a3b931273b Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 17 Oct 2026 14:49:27 +0100 Subject: [PATCH 03/21] Add support for setting pvid to non existant vlans --- tools/httpd_sim.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 19845822..f8661df3 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -296,6 +296,9 @@ class Handler(SimpleHTTPRequestHandler): "pvid": self._parse_pvid } + def newVlan(self, name): + return {"active":True, "name":name, "untagged":[], "tagged":[], "pvid":[]} + def _parse_vlan(self, pieces): global state vid = int(pieces[1]); @@ -307,7 +310,7 @@ class Handler(SimpleHTTPRequestHandler): name = pieces[2] startIndex=3 - state["vlans"][vid] = {"active":True, "name":name, "untagged":[], "tagged":[], "pvid":[]} + state["vlans"][vid] = self.newVlan(name) for i in pieces[startIndex:]: if i[-1] == "t": @@ -319,7 +322,10 @@ class Handler(SimpleHTTPRequestHandler): global state port = int(pieces[1]) vid = int(pieces[2]) - state["vlans"][vid]["pvid"].append(phys_to_log_port[port-1]) + if(vid in state["vlans"]): + state["vlans"][vid]["pvid"].append(phys_to_log_port[port-1]) + else : + state["vlans"][vid] = self.newVlan("") def _parse_lldp(self, pieces): global state From 7694f1a625b7a59fed93b9d83e04d1b36ce27a47 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 17 Oct 2026 22:22:03 +0100 Subject: [PATCH 04/21] Adding management vlan state --- tools/httpd_sim.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index f8661df3..ea0de3d2 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -48,7 +48,8 @@ l2_entries = [ state = { "lldp_enabled": False, "vlans": {1: {"active": True, "name": "", "untagged":[1,2,3,4,5,6,7,8], "tagged":[], "pvid":[1,2,3,4,5,6,7,8]}}, - "cmd_history": [] + "cmd_history": [], + "mgmt_vlan": 1 } @@ -211,7 +212,7 @@ class Handler(SimpleHTTPRequestHandler): command = self._read_raw() state["cmd_history"].append(command) print(f"Received command: '{command}'") - self.parse_cmd(command); + self.parse_cmd(command.strip()); return self._send( 200, @@ -306,6 +307,9 @@ class Handler(SimpleHTTPRequestHandler): name="" if pieces[2] == "d": return remove_vlan(vid) + elif pieces[2] == "mgmt": + state["mgmt_vlan"] = vid + return elif not re.match('^[0-9]+$', pieces[2]): name = pieces[2] startIndex=3 @@ -457,13 +461,13 @@ class Handler(SimpleHTTPRequestHandler): def _api_vlanlist(self): global state vlan_list = [] - for i in state.get("vlans").keys(): - if i in state.get("vlans") and state.get("vlans")[i]["active"]: + for i in state["vlans"].keys(): + if i in state["vlans"] and state["vlans"][i]["active"]: vlan_list.append({ "id": i, - "name": state.get("vlans")[i]["name"], + "name": state["vlans"][i]["name"], }) - return {"mgmt": 1, "vlan": vlan_list} + return {"mgmt": state["mgmt_vlan"], "vlan": vlan_list} def _api_vlan(self,query): global state From 4ad0b7fd3d537e17a4a06cd2cbd03c3059a029c0 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 17 Oct 2026 22:27:03 +0100 Subject: [PATCH 05/21] Implementing removal of vlan --- tools/httpd_sim.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index ea0de3d2..83232d0d 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -297,6 +297,11 @@ class Handler(SimpleHTTPRequestHandler): "pvid": self._parse_pvid } + def remove_vlan(self, id): + global state + if id in state["vlans"]: + del state["vlans"][id] + def newVlan(self, name): return {"active":True, "name":name, "untagged":[], "tagged":[], "pvid":[]} @@ -306,7 +311,7 @@ class Handler(SimpleHTTPRequestHandler): startIndex=2 name="" if pieces[2] == "d": - return remove_vlan(vid) + return self.remove_vlan(vid) elif pieces[2] == "mgmt": state["mgmt_vlan"] = vid return From 16d61857b75fcc916acecc922d7dcfc05a8dd22c Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Tue, 20 Oct 2026 10:13:51 +0100 Subject: [PATCH 06/21] Handling "vlan 2" --- tools/httpd_sim.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 83232d0d..1b932045 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -310,14 +310,15 @@ class Handler(SimpleHTTPRequestHandler): vid = int(pieces[1]); startIndex=2 name="" - if pieces[2] == "d": - return self.remove_vlan(vid) - elif pieces[2] == "mgmt": - state["mgmt_vlan"] = vid - return - elif not re.match('^[0-9]+$', pieces[2]): - name = pieces[2] - startIndex=3 + if len(pieces) > 2: + if pieces[2] == "d": + return self.remove_vlan(vid) + elif pieces[2] == "mgmt": + state["mgmt_vlan"] = vid + return + elif not re.match('^[0-9]+$', pieces[2]): + name = pieces[2] + startIndex=3 state["vlans"][vid] = self.newVlan(name) From 8bd9231432c38765b79e8c4521d18ad767ad2b9f Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Mon, 19 Oct 2026 18:11:53 +0100 Subject: [PATCH 07/21] Adding support for renaming vlan --- tools/httpd_sim.py | 48 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 1b932045..467e8fca 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -320,7 +320,10 @@ class Handler(SimpleHTTPRequestHandler): name = pieces[2] startIndex=3 - state["vlans"][vid] = self.newVlan(name) + if not vid in state["vlans"]: + state["vlans"][vid] = self.newVlan(name) + else: + state["vlans"][vid]["name"] = name for i in pieces[startIndex:]: if i[-1] == "t": @@ -547,6 +550,49 @@ class Handler(SimpleHTTPRequestHandler): "state": state } +# Parse the provided .c file to attempt to make the simulator act more like that configuration +def loadMachineConfig(c_file): + global machine_config, PORTS, NSFP, phys_to_log_port, tx_g, rx_g, tx_b, rx_b + c_content = "" + with open(c_file, 'r') as file: + c_content = file.read() + + regexForCArrayLiteral=r"\{\s*\d\s*(?:,\s*\d\s*)*\}" + + patterns = [ + r"\.(isRTL8373)\s*=\s*(\d+)\s*", + r"\.(min_port)\s*=\s*(\d+)\s*", + r"\.(max_port)\s*=\s*(\d+)\s*", + r"\.(n_sfp)\s*=\s*(\d+)\s*", + r"\.(log_to_phys_port)\s*=\s*("+regexForCArrayLiteral+r")\s*", + r"\.(phys_to_log_port)\s*=\s*("+regexForCArrayLiteral+r")\s*", + r"\.(is_sfp)\s*=\s*("+regexForCArrayLiteral+r")\s*", + ] + + machine_config = { "file": c_file } + + for pattern in patterns: + matches = re.search(pattern, c_content) + if not matches: + return + bits = matches.groups() + value = str(bits[1]) + if value[0] == "{": + value = list(map(int, value[1:-1].split(","))) + machine_config[bits[0]] = value + + if "max_port" in machine_config and "min_port" in machine_config: + PORTS = int(machine_config["max_port"]) - int(machine_config["min_port"]) + if "n_sftp" in machine_config: + NSFP = machine_config["n_sfp"] + if "phys_to_log_port" in machine_config: + phys_to_log_port = machine_config["phys_to_log_port"] + + tx_g = [0] * PORTS + rx_g = [0] * PORTS + tx_b = [0] * PORTS + rx_b = [0] * PORTS + if __name__ == "__main__": print(f"Serving {ROOT} on http://{HOST}:{PORT}") ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() From 2ecef9826388a08e0372c06afd631e2e479d287b Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Tue, 20 Oct 2026 11:47:33 +0100 Subject: [PATCH 08/21] Updating to support \n separated commands, might need to support \r\n as well, but this'll do for now --- tools/httpd_sim.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 467e8fca..a68eb3fa 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -285,10 +285,13 @@ class Handler(SimpleHTTPRequestHandler): )) return parts - def parse_cmd(self, cmd): - pieces = cmd.split(" ") - handler = self.get_parsers().get(pieces[0]) - return handler(pieces) if handler else {} + def parse_cmd(self, cmdstring): + response = "" + for cmd in cmdstring.split("\n"): + pieces = cmd.split(" ") + handler = self.get_parsers().get(pieces[0]) + response += str(handler(pieces)) + "\n" + return response def get_parsers(self): return { From 9088e73acbea2facb4ab06ea06321a838e1f76ff Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 22 Oct 2026 20:34:41 +0100 Subject: [PATCH 09/21] Updating to mirror new macro structure used in #684 --- tools/httpd_sim.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index a68eb3fa..261ccac6 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -564,9 +564,8 @@ def loadMachineConfig(c_file): patterns = [ r"\.(isRTL8373)\s*=\s*(\d+)\s*", - r"\.(min_port)\s*=\s*(\d+)\s*", - r"\.(max_port)\s*=\s*(\d+)\s*", r"\.(n_sfp)\s*=\s*(\d+)\s*", + r"\.(MACHINE_CHIPSET)\s+(\S+)\s*", r"\.(log_to_phys_port)\s*=\s*("+regexForCArrayLiteral+r")\s*", r"\.(phys_to_log_port)\s*=\s*("+regexForCArrayLiteral+r")\s*", r"\.(is_sfp)\s*=\s*("+regexForCArrayLiteral+r")\s*", @@ -584,6 +583,15 @@ def loadMachineConfig(c_file): value = list(map(int, value[1:-1].split(","))) machine_config[bits[0]] = value + if machine_config["MACHINE_CHIPSET"] == "RTL8372": + machine_config["min_port"] = 3 + else: + machine_config["min_port"] = 0 + if machine_config["MACHINE_CHIPSET"] == "RTL8370": + machine_config["max_port"] = 7 + else: + machine_config["max_port"] = 8 + if "max_port" in machine_config and "min_port" in machine_config: PORTS = int(machine_config["max_port"]) - int(machine_config["min_port"]) if "n_sftp" in machine_config: From 813d7e7ae290948ca55280247cd7b14689551f3a Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 22 Oct 2026 21:15:08 +0100 Subject: [PATCH 10/21] Reintroducing argument parsing that I somehow lost rebasing --- tools/httpd_sim.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 261ccac6..190e7e02 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -5,6 +5,7 @@ import os import secrets import time import re +import argparse from email.parser import BytesParser from email.policy import default from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer @@ -605,5 +606,18 @@ def loadMachineConfig(c_file): rx_b = [0] * PORTS if __name__ == "__main__": + parser = argparse.ArgumentParser() + + parser.add_argument("--machine", default=None, help="Path to machine.c file to parse") + parser.add_argument("--port", default=8081, type=int, help="Port to bind to") + parser.add_argument("--bindaddr", default="127.0.0.1", help="Address to bind") + args = parser.parse_args() + + if(args.machine is not None): + loadMachineConfig(args.machine) + + HOST = args.bindaddr + PORT = args.port + print(f"Serving {ROOT} on http://{HOST}:{PORT}") ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() From 421e1f0c7dd01afa1ee66bdbe236c9948d911954 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Fri, 23 Oct 2026 17:12:00 +0100 Subject: [PATCH 11/21] The state of the tagged/untagged ports in the vlan is complete with every command, so we need to start from empty each time --- tools/httpd_sim.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 190e7e02..f042f4d6 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -329,6 +329,9 @@ class Handler(SimpleHTTPRequestHandler): else: state["vlans"][vid]["name"] = name + state["vlans"][vid]["tagged"] = [] + state["vlans"][vid]["untagged"] = [] + for i in pieces[startIndex:]: if i[-1] == "t": state["vlans"][vid]["tagged"].append(phys_to_log_port[int(i[:-1])-1]) From 29542a54148ec83421b152db7cf9db9e0a2d327a Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 24 Oct 2026 20:11:36 +0100 Subject: [PATCH 12/21] Hardcoded responses for the remaining endpoints that are duplicated from my Hi-Source HI-k0801WS --- tools/httpd_sim.py | 131 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 121 insertions(+), 10 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index f042f4d6..4f7d921d 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -70,20 +70,24 @@ class Handler(SimpleHTTPRequestHandler): def api_handlers(self,query): return { - "/status.json": self._api_status, - "/eee.json": self._api_eee, "/bandwidth.json": self._api_bandwidth, - "/information.json": self._api_information, - "/mirror.json": self._api_mirror, - "/lag.json": self._api_lag, - "/l2.json": lambda: self._api_l2(query), - "/mtu.json": self._api_mtu, - "/vlan.json": lambda: self._api_vlan(query), - "/vlanlist": self._api_vlanlist, - "/lldp.json": self._api_lldp, "/cmd_log": self._api_cmd_log, "/counters.json": self._api_counters, "/debug": self._api_debug, + "/eee.json": self._api_eee, + "/information.json": self._api_information, + "/isolation.json": self._api_isolation, + "/l2.json": lambda: self._api_l2(query), + "/lacp.json": self._api_lacp, + "/lag.json": self._api_lag, + "/lldp.json": self._api_lldp, + "/mirror.json": self._api_mirror, + "/mtu.json": self._api_mtu, + "/status.json": self._api_status, + "/storm.json": self._api_storm, + "/stp.json": self._api_stp, + "/vlan.json": lambda: self._api_vlan(query), + "/vlanlist": self._api_vlanlist, } def _send(self, status, body=b"", content_type="text/plain", @@ -351,6 +355,48 @@ class Handler(SimpleHTTPRequestHandler): global state state.lldp_enabled = (pieces[0] == "on") + def _api_storm(self): + return [ + {"portNum":1,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, + {"portNum":2,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, + {"portNum":3,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, + {"portNum":4,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, + {"portNum":5,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, + {"portNum":6,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, + {"portNum":7,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, + {"portNum":8,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, + {"portNum":9,"en":"0000","pps":"0000","rate":"000000000000000000000000"} + ] + + def _api_stp(self): + return { + "on":0, + "rstp":1, + "prio":8, + "hello":2, + "maxage":20, + "fwd":15, + "txhold":6, + "rootPrio":"8000", + "rootMac":"060f00124737", + "myMac":"060f00124737", + "cost":"00000000", + "weRoot":1, + "rootPort":0, + "tc":"0000", + "ports":[ + {"p":1,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8001","dc":"00000000"}, + {"p":2,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8002","dc":"00000000"}, + {"p":3,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8003","dc":"00000000"}, + {"p":4,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8004","dc":"00000000"}, + {"p":5,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8005","dc":"00000000"}, + {"p":6,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8006","dc":"00000000"}, + {"p":7,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8007","dc":"00000000"}, + {"p":8,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8008","dc":"00000000"}, + {"p":9,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8009","dc":"00000000"} + ] + } + def _api_status(self): global last_called now = max(int(time.time()), last_called + 1) @@ -438,6 +484,30 @@ class Handler(SimpleHTTPRequestHandler): "mirror_rx": f"{0b000010:016b}", } + def _api_lacp(self): + return { + "on": 0, + "lags": [ + {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, + {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, + {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, + {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"} + ], + "ports": [ + {"p":1,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, + {"p":2,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, + {"p":3,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, + {"p":4,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, + {"p":5,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, + {"p":6,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, + {"p":7,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, + {"p":8,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, + {"p":9,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"} + ] + } + + + def _api_lag(self): return [ { @@ -541,6 +611,47 @@ class Handler(SimpleHTTPRequestHandler): text += "\n" return text.encode("utf-8"), "text/plain" + def _api_isolation(self): + return [ + { + "portNum": 1, + "allow": "03ff" + }, + { + "portNum": 2, + "allow": "03ff" + }, + { + "portNum": 3, + "allow": "03ff" + }, + { + "portNum": 4, + "allow": "03ff" + }, + { + "portNum": 5, + "allow": "03ff" + }, + { + "portNum": 6, + "allow": "03ff" + }, + { + "portNum": 7, + "allow": "03ff" + }, + { + "portNum": 8, + "allow": "03ff" + }, + { + "portNum": 9, + "allow": "03ff" + } + ] + + def _api_information(self): return { "ip_address":"192.168.10.247", From 77fc545c6a0519ec664330035437a09aa9591d14 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 24 Oct 2026 20:27:14 +0100 Subject: [PATCH 13/21] Adding hardcoded responses for "dns" and "ntp" commands --- tools/httpd_sim.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 4f7d921d..ea1a2f54 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -217,7 +217,10 @@ class Handler(SimpleHTTPRequestHandler): command = self._read_raw() state["cmd_history"].append(command) print(f"Received command: '{command}'") - self.parse_cmd(command.strip()); + response = self.parse_cmd(command.strip()); + + if response is not None: + return self._send(200, response, "text/plain") return self._send( 200, @@ -291,18 +294,22 @@ class Handler(SimpleHTTPRequestHandler): return parts def parse_cmd(self, cmdstring): - response = "" + response = bytearray() for cmd in cmdstring.split("\n"): pieces = cmd.split(" ") handler = self.get_parsers().get(pieces[0]) - response += str(handler(pieces)) + "\n" + result = handler(pieces) + response.extend(result) + response.extend(b"\n") return response def get_parsers(self): return { "vlan": self._parse_vlan, "lldp": self._parse_lldp, - "pvid": self._parse_pvid + "pvid": self._parse_pvid, + "dns": self._parse_dns, + "ntp": self._parse_ntp, } def remove_vlan(self, id): @@ -355,6 +362,14 @@ class Handler(SimpleHTTPRequestHandler): global state state.lldp_enabled = (pieces[0] == "on") + def _parse_ntp(self, pieces): + global state + return b"NTP off, server pool.ntp.org, address 0.0.0.0, interval 60 min, timezone +00:00, dst off\nTime not synchronised" + + def _parse_dns(self,pieces): + global state + return b"DNS servers 0.0.0.0 0.0.0.0, from DHCP 0.0.0.0" + def _api_storm(self): return [ {"portNum":1,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, From 4dee3d2ad24da2404774c02e1c68b374a0e69751 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 24 Oct 2026 20:34:42 +0100 Subject: [PATCH 14/21] Driving responses to be based on number of ports in machine configuration --- tools/httpd_sim.py | 93 ++++++++++++++-------------------------------- 1 file changed, 28 insertions(+), 65 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index ea1a2f54..b8adbe95 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -50,7 +50,13 @@ state = { "lldp_enabled": False, "vlans": {1: {"active": True, "name": "", "untagged":[1,2,3,4,5,6,7,8], "tagged":[], "pvid":[1,2,3,4,5,6,7,8]}}, "cmd_history": [], - "mgmt_vlan": 1 + "mgmt_vlan": 1, + "lags": [ + {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, + {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, + {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, + {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"} + ], } @@ -371,16 +377,10 @@ class Handler(SimpleHTTPRequestHandler): return b"DNS servers 0.0.0.0 0.0.0.0, from DHCP 0.0.0.0" def _api_storm(self): + global state return [ - {"portNum":1,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, - {"portNum":2,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, - {"portNum":3,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, - {"portNum":4,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, - {"portNum":5,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, - {"portNum":6,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, - {"portNum":7,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, - {"portNum":8,"en":"0000","pps":"0000","rate":"000000000000000000000000"}, - {"portNum":9,"en":"0000","pps":"0000","rate":"000000000000000000000000"} + {"portNum":i,"en":"0000","pps":"0000","rate":"000000000000000000000000"} + for i in range(1, PORTS + 1) ] def _api_stp(self): @@ -500,29 +500,23 @@ class Handler(SimpleHTTPRequestHandler): } def _api_lacp(self): + global state return { "on": 0, - "lags": [ - {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, - {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, - {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"}, - {"cfg":"0000","aggValid":0,"agg":"000000000000","members":"0000"} - ], + "lags": state["lags"], "ports": [ - {"p":1,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, - {"p":2,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, - {"p":3,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, - {"p":4,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, - {"p":5,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, - {"p":6,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, - {"p":7,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, - {"p":8,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"}, - {"p":9,"lag":255,"a":"00","pt":"00","rs":0,"rx":"0000","psys":"000000000000"} + { + "p":i, + "lag":255, + "a":"00", + "pt":"00", + "rs":0, + "rx":"0000", + "psys":"000000000000" + } for i in range(1, PORTS + 1) ] } - - def _api_lag(self): return [ { @@ -627,45 +621,14 @@ class Handler(SimpleHTTPRequestHandler): return text.encode("utf-8"), "text/plain" def _api_isolation(self): + global state return [ - { - "portNum": 1, - "allow": "03ff" - }, - { - "portNum": 2, - "allow": "03ff" - }, - { - "portNum": 3, - "allow": "03ff" - }, - { - "portNum": 4, - "allow": "03ff" - }, - { - "portNum": 5, - "allow": "03ff" - }, - { - "portNum": 6, - "allow": "03ff" - }, - { - "portNum": 7, - "allow": "03ff" - }, - { - "portNum": 8, - "allow": "03ff" - }, - { - "portNum": 9, - "allow": "03ff" - } - ] - + { + "portNum": i, + "allow": "03ff" + } + for i in range(1, PORTS + 1) + ] def _api_information(self): return { From 183f6f713559b85ef8e7e24dea6ccabb92821b68 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 24 Oct 2026 21:04:27 +0100 Subject: [PATCH 15/21] We need to parse both the .c and .h file, so now you can specify either and it'll sort it out. adding --verbose for debugging --- tools/httpd_sim.py | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index b8adbe95..0d1ed1ff 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -15,7 +15,7 @@ from urllib.parse import parse_qs, unquote, urlsplit HOST = "0.0.0.0" PORT = 8081 PASSWORD = "1234" - +VERBOSE = False PORTS = 6 NSFP = 1 N_COUNTERS = 52 @@ -69,6 +69,9 @@ last_called = int(time.time()) sessions = set() uploaded_config = None +def vprint(string): + if VERBOSE: + print(string) class Handler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): @@ -646,30 +649,42 @@ class Handler(SimpleHTTPRequestHandler): "state": state } -# Parse the provided .c file to attempt to make the simulator act more like that configuration -def loadMachineConfig(c_file): +# Parse the provided .c or .h file to attempt to make the simulator act more like that configuration +def loadMachineConfig(filename): global machine_config, PORTS, NSFP, phys_to_log_port, tx_g, rx_g, tx_b, rx_b + c_file=filename[:-1] +'c' c_content = "" with open(c_file, 'r') as file: c_content = file.read() + h_file=filename[:-1] + 'h' + h_content="" + with open(h_file, 'r') as file: + h_content = file.read() + + combined_file_content = h_content+"\n"+c_content + regexForCArrayLiteral=r"\{\s*\d\s*(?:,\s*\d\s*)*\}" patterns = [ r"\.(isRTL8373)\s*=\s*(\d+)\s*", r"\.(n_sfp)\s*=\s*(\d+)\s*", - r"\.(MACHINE_CHIPSET)\s+(\S+)\s*", + r"\s(MACHINE_CHIPSET)\s+(\S+)\s*", r"\.(log_to_phys_port)\s*=\s*("+regexForCArrayLiteral+r")\s*", r"\.(phys_to_log_port)\s*=\s*("+regexForCArrayLiteral+r")\s*", r"\.(is_sfp)\s*=\s*("+regexForCArrayLiteral+r")\s*", ] - machine_config = { "file": c_file } + machine_config = { "file": c_file, "header": h_file } + + vprint("Parsing File content:\n--------\n") + vprint(combined_file_content) + vprint("-------") for pattern in patterns: - matches = re.search(pattern, c_content) + matches = re.search(pattern, combined_file_content) if not matches: - return + continue bits = matches.groups() value = str(bits[1]) if value[0] == "{": @@ -697,15 +712,25 @@ def loadMachineConfig(c_file): tx_b = [0] * PORTS rx_b = [0] * PORTS + vprint("------------") + vprint(machine_config) + vprint("------------") + if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("--machine", default=None, help="Path to machine.c file to parse") + parser.add_argument("--machine", default=None, help="Path to machine.c or .h file to parse") parser.add_argument("--port", default=8081, type=int, help="Port to bind to") parser.add_argument("--bindaddr", default="127.0.0.1", help="Address to bind") + parser.add_argument("--verbose", action="store_true", help="Enable verbose output") + args = parser.parse_args() + if(args.verbose): + VERBOSE = True + if(args.machine is not None): + vprint("Loading machine configuration:"+args.machine) loadMachineConfig(args.machine) HOST = args.bindaddr From 9dbf0f40f240f80b50a92a5ce55b65077ff626a4 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 24 Oct 2026 21:15:25 +0100 Subject: [PATCH 16/21] Harmonising on the machine_config structure so we can handle both with and without configuration file consistently --- tools/httpd_sim.py | 39 +++++++++++++++------------------------ 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 0d1ed1ff..10c0af50 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -17,20 +17,16 @@ PORT = 8081 PASSWORD = "1234" VERBOSE = False PORTS = 6 -NSFP = 1 N_COUNTERS = 52 VLAN_MAX_ID = 4094 L2_MAX_TRANSFER = 5 ROOT = Path.cwd().resolve() -phys_to_log_port = [] - -if PORTS == 9: - phys_to_log_port = [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ] -else: - phys_to_log_port = [ 4, 5, 6, 7, 3, 8 ] - +machine_config = { + "phys_to_log_port": [ 4, 5, 6, 7, 3, 8 ], + "is_sfp": [0, 0, 0, 0, 0, 0, 1] +} l2_entries = [ { "mac": "00:01:2f:00:00:01", "vlan":"001", "type":'l', "port":7, "idx":0x10 }, { "mac": "00:06:78:00:00:02", "vlan":"001", "type":'l', "port":7, "idx":0x20 }, @@ -225,7 +221,7 @@ class Handler(SimpleHTTPRequestHandler): if path == "/cmd": command = self._read_raw() state["cmd_history"].append(command) - print(f"Received command: '{command}'") + vprint(f"Received command: '{command}'") response = self.parse_cmd(command.strip()); if response is not None: @@ -354,16 +350,16 @@ class Handler(SimpleHTTPRequestHandler): for i in pieces[startIndex:]: if i[-1] == "t": - state["vlans"][vid]["tagged"].append(phys_to_log_port[int(i[:-1])-1]) + state["vlans"][vid]["tagged"].append(machine_config["phys_to_log_port"][int(i[:-1])-1]) else: - state["vlans"][vid]["untagged"].append(phys_to_log_port[int(i)-1]) + state["vlans"][vid]["untagged"].append(machine_config["phys_to_log_port"][int(i)-1]) def _parse_pvid(self, pieces): global state port = int(pieces[1]) vid = int(pieces[2]) if(vid in state["vlans"]): - state["vlans"][vid]["pvid"].append(phys_to_log_port[port-1]) + state["vlans"][vid]["pvid"].append(machine_config["phys_to_log_port"][port-1]) else : state["vlans"][vid] = self.newVlan("") @@ -416,7 +412,7 @@ class Handler(SimpleHTTPRequestHandler): } def _api_status(self): - global last_called + global last_called, machine_config now = max(int(time.time()), last_called + 1) elapsed = now - last_called ports = [] @@ -429,11 +425,10 @@ class Handler(SimpleHTTPRequestHandler): rx_g[n] += rate * elapsed tx_b[n] += rate * elapsed // 10_000_000 rx_b[n] += rate * elapsed // 10_000_000 - item = { "portNum": i, - "logPort": phys_to_log_port[n], - "isSFP": int(i > PORTS - NSFP), + "logPort": machine_config["phys_to_log_port"][n], + "isSFP": machine_config["is_sfp"][i], "enabled": int(i % 4 != 0), "link": (5 if i == 1 else 2) if i % 2 else 0, "txG": f"0x{tx_g[n]:016x}", @@ -442,7 +437,7 @@ class Handler(SimpleHTTPRequestHandler): "rxB": f"0x{rx_b[n]:016x}", } - if i >= PORTS - NSFP: + if machine_config["is_sfp"][i]: item.update({ "sfp_vendor": "OEM", "sfp_model": "10G-SFP+", @@ -468,10 +463,11 @@ class Handler(SimpleHTTPRequestHandler): return ports def _api_eee(self): + global state, machine_config return [ { "portNum": i, - "isSFP": int(i >= 5), + "isSFP": machine_config["is_sfp"][i], "eee": "00000010", "eee_lp": "00000100", "active": int(i % 2 == 1), @@ -651,7 +647,7 @@ class Handler(SimpleHTTPRequestHandler): # Parse the provided .c or .h file to attempt to make the simulator act more like that configuration def loadMachineConfig(filename): - global machine_config, PORTS, NSFP, phys_to_log_port, tx_g, rx_g, tx_b, rx_b + global machine_config, PORTS, phys_to_log_port, tx_g, rx_g, tx_b, rx_b c_file=filename[:-1] +'c' c_content = "" with open(c_file, 'r') as file: @@ -668,7 +664,6 @@ def loadMachineConfig(filename): patterns = [ r"\.(isRTL8373)\s*=\s*(\d+)\s*", - r"\.(n_sfp)\s*=\s*(\d+)\s*", r"\s(MACHINE_CHIPSET)\s+(\S+)\s*", r"\.(log_to_phys_port)\s*=\s*("+regexForCArrayLiteral+r")\s*", r"\.(phys_to_log_port)\s*=\s*("+regexForCArrayLiteral+r")\s*", @@ -702,10 +697,6 @@ def loadMachineConfig(filename): if "max_port" in machine_config and "min_port" in machine_config: PORTS = int(machine_config["max_port"]) - int(machine_config["min_port"]) - if "n_sftp" in machine_config: - NSFP = machine_config["n_sfp"] - if "phys_to_log_port" in machine_config: - phys_to_log_port = machine_config["phys_to_log_port"] tx_g = [0] * PORTS rx_g = [0] * PORTS From 2168ad555e5fe30c85b9af880b424067de82e3a7 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 24 Oct 2026 21:21:11 +0100 Subject: [PATCH 17/21] We now show the same number of ports on all pages --- tools/httpd_sim.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 10c0af50..9105da8b 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -399,15 +399,8 @@ class Handler(SimpleHTTPRequestHandler): "rootPort":0, "tc":"0000", "ports":[ - {"p":1,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8001","dc":"00000000"}, - {"p":2,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8002","dc":"00000000"}, - {"p":3,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8003","dc":"00000000"}, - {"p":4,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8004","dc":"00000000"}, - {"p":5,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8005","dc":"00000000"}, - {"p":6,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8006","dc":"00000000"}, - {"p":7,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8007","dc":"00000000"}, - {"p":8,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8008","dc":"00000000"}, - {"p":9,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8009","dc":"00000000"} + {"p":i,"lag":0,"mbr":0,"st":3,"role":2,"f":5,"pc":"00000000","prio":128,"p2":0,"db":"8000060f00124737","dp":"8009","dc":"00000000"} + for i in range(1, PORTS + 1) ] } From 511aebf085c76429cb87e3850299b07868e9289a Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 24 Oct 2026 21:27:11 +0100 Subject: [PATCH 18/21] If the handler doesn't return anything we need to handle that --- tools/httpd_sim.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 9105da8b..b589c0c8 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -304,8 +304,9 @@ class Handler(SimpleHTTPRequestHandler): pieces = cmd.split(" ") handler = self.get_parsers().get(pieces[0]) result = handler(pieces) - response.extend(result) - response.extend(b"\n") + if(result is not None): + response.extend(result) + response.extend(b"\n") return response def get_parsers(self): From 3694363001a746eee6eef81b2d0429552caf835f Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sun, 25 Oct 2026 15:41:12 +0000 Subject: [PATCH 19/21] Adding missing name --- tools/httpd_sim.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index b589c0c8..361fe6b9 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -422,6 +422,7 @@ class Handler(SimpleHTTPRequestHandler): item = { "portNum": i, "logPort": machine_config["phys_to_log_port"][n], + "name": "", "isSFP": machine_config["is_sfp"][i], "enabled": int(i % 4 != 0), "link": (5 if i == 1 else 2) if i % 2 else 0, From f22bf9cf82f5a19358a4c23fa28fe8345f5758e2 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Tue, 27 Oct 2026 07:45:30 +0000 Subject: [PATCH 20/21] The firmware never displays vlan 0, we shouldn't either --- tools/httpd_sim.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index 361fe6b9..f8b43b35 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -552,6 +552,8 @@ class Handler(SimpleHTTPRequestHandler): vlan_list = [] for i in state["vlans"].keys(): if i in state["vlans"] and state["vlans"][i]["active"]: + if(i == 0): + continue vlan_list.append({ "id": i, "name": state["vlans"][i]["name"], From 8d20af475edcaff55d1a700b3ec13f40ec0c92f4 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Wed, 28 Oct 2026 11:23:14 +0000 Subject: [PATCH 21/21] Adding some instructions for using the sim, also improving the --help output to be more consistent --- README.md | 32 ++++++++++++++++++++++++++++---- tools/httpd_sim.py | 8 ++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 25c0a02d..29a7061f 100644 --- a/README.md +++ b/README.md @@ -103,11 +103,11 @@ Build host tools only: docker run --rm -v $(pwd):/workspace rtlplayground-dev make -C tools ``` -Run the web-interface simulator locally: +Run the web-interface simulator through docker: ``` -docker run --rm -p 8080:8080 -v $(pwd):/workspace rtlplayground-dev \ - tools/output/httpd_sim /workspace/html +docker run --rm -p 8081:8081 -v $(pwd):/workspace rtlplayground-dev \ + tools/output/httpd_sim.py /workspace/html ``` Edit `config.txt` on your host, then re-run `make` — the @@ -362,7 +362,31 @@ session xxxx = Web session timeout in seconds (default 200) Enjoy playing! -## (11) Other documents +## (13) HTTP Simulator for front-end development + +The simulator is available in tools as `./httpd_sim.py` +By default it will serve the content in the current working directory so the easiest way to run this is to: +``` +cd ./html +../tools/httpd_sim.py +``` +And the simulator will be available on http://127.0.0.1:8081 + +There are commandline options available: +``` +usage: httpd_sim.py [-h] [--machine MACHINE] [--port PORT] [--bindaddr BINDADDR] [--verbose] + +options: + -h, --help show this help message and exit + --machine MACHINE path to machine.c or .h file to parse (optional) + --port PORT port to bind to (default 8081) + --bindaddr BINDADDR address to bind (default 127.0.0.1) + --verbose enable verbose output +``` + +The machine parsing is somewhat experimental. + +## (12) Other documents The following documents give further documentation on specific features of the RTL837x SoCs: - [Adding a new device](doc/adding-a-device.md) diff --git a/tools/httpd_sim.py b/tools/httpd_sim.py index f8b43b35..dc6816d1 100755 --- a/tools/httpd_sim.py +++ b/tools/httpd_sim.py @@ -707,10 +707,10 @@ def loadMachineConfig(filename): if __name__ == "__main__": parser = argparse.ArgumentParser() - parser.add_argument("--machine", default=None, help="Path to machine.c or .h file to parse") - parser.add_argument("--port", default=8081, type=int, help="Port to bind to") - parser.add_argument("--bindaddr", default="127.0.0.1", help="Address to bind") - parser.add_argument("--verbose", action="store_true", help="Enable verbose output") + parser.add_argument("--machine", default=None, help="path to machine.c or .h file to parse (optional)") + parser.add_argument("--port", default=8081, type=int, help="port to bind to (default 8081)") + parser.add_argument("--bindaddr", default="127.0.0.1", help="address to bind (default 127.0.0.1)") + parser.add_argument("--verbose", action="store_true", help="enable verbose output") args = parser.parse_args()