feat: NAPALM driver for HPE OfficeConnect 1820/1920S

These switches have no CLI at all — no SSH, no Telnet and no ArubaOS-Switch
REST API — so the ProCurve driver cannot serve them despite the shared
vendor. The only management surface is the web UI, which ships its table
data as JavaScript array literals; those parse with ast.literal_eval, so
the driver needs no HTML parser and no dependency beyond napalm/requests.

Read-only by design: the platform exposes a single administrator account
with no privilege levels, and serves HTTPS only after a certificate has
been uploaded, so the polling credential is necessarily the admin
credential over a plain channel.

Implements get_facts, get_interfaces, get_vlans, get_vlans_detail and
get_mac_address_table, plus HTTP/SNMP fingerprints for discovery.

Tested against an HPE OfficeConnect 1820 8G PoE+ (65W), J9982A, PT.02.19.
This commit is contained in:
Christian Manivong
2026-08-10 20:45:55 +07:00
commit c76a177ff2
23 changed files with 4302 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
"""Parsers for the HPE OfficeConnect web UI.
These switches have no CLI and no REST API — the only machine-readable
surface is the web UI itself. Every list page (ports, VLANs, MAC table,
trunks, counters) is a jQuery DataTable whose payload is embedded in the
page as two JavaScript declarations::
var aDataSet = [
['<input type="checkbox" ...>', '1', '', 'Enabled', ..., '1518']
,
['<input type="checkbox" ...>', '2', '', 'Enabled', ..., '1518']
];
var aColumns = [
{ "sTitle": "Interface", "sType": "intf-sort", "sWidth": "8%" },
...
];
The array literal happens to be valid Python syntax, so it parses with
``ast.literal_eval`` — no HTML parser and no extra dependency needed.
Note that this is a property of firmware PT.02.xx and later. Older 1820
firmware server-rendered the same data as ``<tr>``/``<td>`` markup, which
is why the (unmaintained) hp1820-cli scraper does not work here.
"""
from __future__ import annotations
import ast
import html as html_module
import re
_DATASET_RE = re.compile(r"var\s+aDataSet\s*=\s*\[(.*?)\n\s*\];", re.DOTALL)
_STITLE_RE = re.compile(r"\"sTitle\"\s*:\s*(['\"])(.*?)\1", re.DOTALL)
_TAG_RE = re.compile(r"<[^>]*>")
_UPTIME_RE = re.compile(
r"(\d+)\s*days?,\s*(\d+)\s*hours?,\s*(\d+)\s*mins?,\s*(\d+)\s*secs?",
re.IGNORECASE,
)
def strip_markup(value: str) -> str:
"""Reduce a DataTable cell to its plain text.
The leading column of most tables is a row-selection checkbox whose
"value" is an ``<input>`` element; it collapses to an empty string.
"""
text = _TAG_RE.sub("", value)
text = html_module.unescape(text)
return re.sub(r"\s+", " ", text).strip()
def extract_data_set(page: str) -> list[list[str]]:
"""Return the rows of the page's ``aDataSet`` as lists of raw cell strings.
Cells are returned verbatim, markup included — callers decide per column
whether to run them through :func:`strip_markup`. Returns an empty list
if the page carries no table (e.g. an error or login-redirect page).
"""
match = _DATASET_RE.search(page)
if not match:
return []
body = match.group(1)
# Fast path: the whole literal at once.
try:
rows = ast.literal_eval("[" + body + "]")
except (SyntaxError, ValueError):
# Fall back to row-at-a-time so a single malformed row — an escape
# sequence that is valid in JS but not in Python, say — costs us that
# row rather than the entire table.
rows = []
for line in body.splitlines():
line = line.strip().rstrip(",")
if not line.startswith("["):
continue
try:
rows.append(ast.literal_eval(line))
except (SyntaxError, ValueError):
continue
return [[str(cell) for cell in row] for row in rows if isinstance(row, (list, tuple))]
def extract_column_titles(page: str) -> list[str]:
"""Return the text column titles declared in ``aColumns``.
Purely informational columns whose title is markup rather than text —
the select-all checkbox — are dropped, so the result lines up with the
data columns a caller actually reads.
"""
titles = [strip_markup(m.group(2)) for m in _STITLE_RE.finditer(page)]
return [t for t in titles if t]
def parse_uptime(value: str) -> int:
"""Convert ``"0 days, 18 hours, 44 mins, 40 secs"`` to seconds.
Returns -1 when the string cannot be parsed, which is NAPALM's
convention for an unknown uptime.
"""
match = _UPTIME_RE.search(value or "")
if not match:
return -1
days, hours, mins, secs = (int(g) for g in match.groups())
return days * 86400 + hours * 3600 + mins * 60 + secs
def _text_by_id(page: str, element_id: str) -> str:
"""Text content of the ``<td id="...">`` carrying a dashboard value."""
match = re.search(rf'id="{re.escape(element_id)}"[^>]*>(.*?)</', page, re.DOTALL)
return strip_markup(match.group(1)) if match else ""
def _input_value_by_id(page: str, element_id: str) -> str:
"""Value of the ``<input id="...">`` carrying an editable dashboard field.
The attribute often wraps across lines, hence the DOTALL search.
"""
match = re.search(rf'id="{re.escape(element_id)}"[^>]*?value="([^"]*)"', page, re.DOTALL)
return html_module.unescape(match.group(1)).strip() if match else ""
def parse_facts(page: str) -> dict[str, object]:
"""Extract system facts from ``dashboard.lsp``.
The System Description is a comma-separated tuple of model, firmware,
kernel and bootloader::
HPE OfficeConnect Switch 1820 8G PoE+ (65W) J9982A, PT.02.19,
Linux 3.6.5, U-Boot 2012.10-00116 (Jul 30 2014 - 10:52:01)
"""
descr = _text_by_id(page, "sys_descr")
parts = [p.strip() for p in descr.split(",")] if descr else []
model = parts[0] if parts else ""
os_version = _text_by_id(page, "sw_version") or (parts[1] if len(parts) > 1 else "")
return {
"hostname": _input_value_by_id(page, "sys_name"),
"model": model,
"serial_number": _text_by_id(page, "serial_number"),
"os_version": os_version,
"sys_object_id": _text_by_id(page, "sys_obj_id"),
"uptime": parse_uptime(_text_by_id(page, "sys_up_time")),
"system_description": descr,
}