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:
@@ -0,0 +1,5 @@
|
||||
"""NAPALM driver for HPE OfficeConnect 1820 / 1920S web-managed switches."""
|
||||
|
||||
from napalm_hpe_officeconnect.officeconnect import OfficeConnectDriver
|
||||
|
||||
__all__ = ["OfficeConnectDriver"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""HTTP session handling for HPE OfficeConnect switches.
|
||||
|
||||
The switch authenticates with a form POST that answers JSON and hands out a
|
||||
``SID`` cookie::
|
||||
|
||||
GET /htdocs/login/login.lsp → sets the short-lived RID cookie
|
||||
POST /htdocs/login/login.lua → {"redirect": "...", "error": ""}
|
||||
GET /htdocs/pages/main/logout.lsp
|
||||
|
||||
An empty ``error`` means success. Firmware generations differ in what else
|
||||
they put in that object — older builds omit ``redirect``, newer ones omit
|
||||
``username`` — so ``error`` is the only field worth branching on.
|
||||
|
||||
Session hygiene matters here: the switch keeps a small table of concurrent
|
||||
sessions and only reclaims them on idle timeout. A driver that logs in on
|
||||
every poll and never logs out will eventually lock the administrator out of
|
||||
the web UI, so :meth:`logout` is best-effort and always safe to call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from napalm.base.exceptions import (
|
||||
CommandErrorException,
|
||||
ConnectionClosedException,
|
||||
ConnectionException,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("napalm_hpe_officeconnect")
|
||||
|
||||
LOGIN_PAGE = "/htdocs/login/login.lsp"
|
||||
LOGIN_ENDPOINT = "/htdocs/login/login.lua"
|
||||
LOGOUT_ENDPOINT = "/htdocs/pages/main/logout.lsp"
|
||||
|
||||
|
||||
class OfficeConnectClient:
|
||||
"""Authenticated HTTP session against an OfficeConnect switch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
username: str,
|
||||
password: str,
|
||||
scheme: str = "http",
|
||||
port: int | None = None,
|
||||
timeout: int = 30,
|
||||
verify: bool = False,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.password = password
|
||||
# HTTP by default: the switch only serves HTTPS once an operator has
|
||||
# uploaded a certificate, which is not the common case.
|
||||
self.scheme = scheme
|
||||
self.port = port
|
||||
self.timeout = timeout
|
||||
self.verify = verify
|
||||
self._session = requests.Session()
|
||||
self._authenticated = False
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
if self.port:
|
||||
return f"{self.scheme}://{self.host}:{self.port}"
|
||||
return f"{self.scheme}://{self.host}"
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self._authenticated
|
||||
|
||||
def login(self) -> None:
|
||||
"""Establish a session. Raises ConnectionException on any failure."""
|
||||
try:
|
||||
# The login page issues the RID cookie the POST is validated against.
|
||||
self._session.get(self.base_url + LOGIN_PAGE, timeout=self.timeout, verify=self.verify)
|
||||
response = self._session.post(
|
||||
self.base_url + LOGIN_ENDPOINT,
|
||||
data={"username": self.username, "password": self.password},
|
||||
timeout=self.timeout,
|
||||
verify=self.verify,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ConnectionException(f"Cannot reach {self.host}: {exc}") from exc
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise ConnectionException(
|
||||
f"Login to {self.host} returned no JSON "
|
||||
f"(HTTP {response.status_code}) — is this an OfficeConnect switch?"
|
||||
) from exc
|
||||
|
||||
error = (payload.get("error") or "").strip()
|
||||
if error:
|
||||
raise ConnectionException(f"Login to {self.host} failed: {error}")
|
||||
|
||||
self._authenticated = True
|
||||
|
||||
def fetch(self, path: str) -> str:
|
||||
"""Return the body of an authenticated page."""
|
||||
if not self._authenticated:
|
||||
raise ConnectionException(f"Not logged in to {self.host} — call login() first")
|
||||
|
||||
try:
|
||||
response = self._session.get(
|
||||
self.base_url + path,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify,
|
||||
# Do not follow the redirect: a 3xx here *is* the error signal.
|
||||
allow_redirects=False,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ConnectionClosedException(f"Request to {self.host}{path} failed: {exc}") from exc
|
||||
|
||||
if 300 <= response.status_code < 400:
|
||||
# The switch bounces expired sessions to the login page instead of
|
||||
# answering 401.
|
||||
self._authenticated = False
|
||||
raise ConnectionClosedException(f"Session to {self.host} expired while fetching {path}")
|
||||
if response.status_code != 200:
|
||||
raise CommandErrorException(f"{self.host}{path} returned HTTP {response.status_code}")
|
||||
|
||||
return response.text
|
||||
|
||||
def logout(self) -> None:
|
||||
"""Release the session. Best-effort: never raises.
|
||||
|
||||
Called from ``close()``, including on the failure path, where raising
|
||||
would mask the exception that actually caused the disconnect.
|
||||
"""
|
||||
if not self._authenticated:
|
||||
return
|
||||
try:
|
||||
self._session.get(
|
||||
self.base_url + LOGOUT_ENDPOINT,
|
||||
timeout=self.timeout,
|
||||
verify=self.verify,
|
||||
allow_redirects=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — deliberate: see docstring
|
||||
logger.debug("Logout from %s failed, session will idle out: %s", self.host, exc)
|
||||
finally:
|
||||
self._authenticated = False
|
||||
@@ -0,0 +1,241 @@
|
||||
"""NAPALM driver for HPE OfficeConnect 1820 / 1920S web-managed switches.
|
||||
|
||||
These are Broadcom FASTPATH-derived "smart managed" switches with **no CLI
|
||||
at all** — no SSH, no Telnet, and no ArubaOS-Switch REST API. The only
|
||||
management surface is the web UI, so this driver drives that UI directly.
|
||||
That is why it shares no code with ``napalm-hpe-aruba-procurve``: a
|
||||
ProCurve/ArubaOS-Switch speaks ``show``/``configure`` over SSH, which does
|
||||
not exist here.
|
||||
|
||||
The driver is deliberately **read-only**. These switches expose a single
|
||||
administrator account with no privilege levels, and unless an operator has
|
||||
uploaded a certificate the session runs over plain HTTP — so the credential
|
||||
used for polling is necessarily the admin credential. Not implementing any
|
||||
write path keeps this driver from being the thing that changes a switch
|
||||
over an unauthenticated channel.
|
||||
|
||||
Tested against: HPE OfficeConnect 1820 8G PoE+ (65W), J9982A, firmware PT.02.19.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from napalm_device_types import FingerprintRule, SwitchDriver
|
||||
|
||||
from napalm_hpe_officeconnect import parsers
|
||||
from napalm_hpe_officeconnect.client import OfficeConnectClient
|
||||
|
||||
logger = logging.getLogger("napalm_hpe_officeconnect")
|
||||
|
||||
_SPEED_RE = re.compile(r"(\d+)\s*Mbps", re.IGNORECASE)
|
||||
|
||||
# Participation values that make an interface a member of a VLAN.
|
||||
_MEMBER_STATES = ("untagged", "tagged")
|
||||
|
||||
|
||||
class OfficeConnectDriver(SwitchDriver):
|
||||
"""NAPALM driver for HPE OfficeConnect web-managed switches."""
|
||||
|
||||
VENDOR = "HPE"
|
||||
DRIVER_NAME = "hpe_officeconnect"
|
||||
|
||||
# The ProCurve driver claims the bare HPE enterprise arc (1.3.6.1.4.1.11).
|
||||
# OfficeConnect models sit under .2.3.7.11 — claiming the longer arc keeps
|
||||
# the two drivers from competing for the same device.
|
||||
SNMP_OBJECT_ID_PREFIX = "1.3.6.1.4.1.11.2.3.7.11"
|
||||
|
||||
# Verified on a J9982A. Deliberately narrow — a wrong OUI produces false
|
||||
# positives, while a missing one only costs a few points of confidence.
|
||||
OUI_PREFIXES: ClassVar[list[str]] = ["70:10:6F"]
|
||||
|
||||
HTTP_FINGERPRINT: ClassVar[list[FingerprintRule]] = [
|
||||
# The login page title reads e.g.
|
||||
# "HPE OfficeConnect Switch 1820 8G PoE+ (65W) J9982A".
|
||||
FingerprintRule("officeconnect", weight=9.0, mandatory=True),
|
||||
FingerprintRule("1820", weight=6.0),
|
||||
FingerprintRule("1920s", weight=6.0),
|
||||
FingerprintRule("hewlett packard enterprise", weight=3.0),
|
||||
]
|
||||
|
||||
# sysDescr repeats the model string, e.g. "HPE OfficeConnect Switch 1820
|
||||
# 8G PoE+ (65W) J9982A, PT.02.19, Linux 3.6.5-…, U-Boot 2012.10-…".
|
||||
# Not mandatory: HTTP already carries the hard requirement, and a device
|
||||
# reachable only over SNMP should still be able to score.
|
||||
SNMP_FINGERPRINT: ClassVar[list[FingerprintRule]] = [
|
||||
FingerprintRule("officeconnect", weight=9.0),
|
||||
FingerprintRule("1820", weight=6.0),
|
||||
FingerprintRule("1920s", weight=6.0),
|
||||
]
|
||||
|
||||
# --- Web UI endpoints -------------------------------------------------
|
||||
DASHBOARD = "/htdocs/pages/base/dashboard.lsp"
|
||||
PORT_SUMMARY = "/htdocs/pages/base/port_summary.lsp"
|
||||
PORT_STATS = "/htdocs/pages/base/port_summary_stats.lsp"
|
||||
MAC_TABLE = "/htdocs/pages/base/mac_address_table.lsp"
|
||||
VLAN_STATUS = "/htdocs/pages/switching/vlan_status.lsp"
|
||||
VLAN_PER_PORT = "/htdocs/pages/switching/vlan_per_port.lsp?vlan={vlan}"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hostname: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: int = 60,
|
||||
optional_args: dict | None = None,
|
||||
) -> None:
|
||||
self.hostname = hostname
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
|
||||
optional_args = optional_args or {}
|
||||
self._client = OfficeConnectClient(
|
||||
hostname,
|
||||
username,
|
||||
password,
|
||||
# HTTP by default: HTTPS only works once a certificate has been
|
||||
# uploaded to the switch, which is not the common deployment.
|
||||
scheme=optional_args.get("scheme", "http"),
|
||||
port=optional_args.get("port"),
|
||||
timeout=timeout,
|
||||
verify=optional_args.get("ssl_verify", False),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def open(self) -> None:
|
||||
self._client.login()
|
||||
|
||||
def close(self) -> None:
|
||||
# Always release the session: the switch keeps a small session table
|
||||
# and only reclaims entries on idle timeout.
|
||||
self._client.logout()
|
||||
|
||||
def is_alive(self) -> dict[str, bool]:
|
||||
return {"is_alive": bool(self._client.is_authenticated)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rows(self, path: str) -> list[list[str]]:
|
||||
"""Fetch a page and return its table rows as plain text cells."""
|
||||
page = self._client.fetch(path)
|
||||
return [
|
||||
[parsers.strip_markup(cell) for cell in row] for row in parsers.extract_data_set(page)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _speed_mbit(value: str) -> float:
|
||||
"""``"100 Mbps Full Duplex"`` → ``100.0``; empty (link down) → ``0.0``."""
|
||||
match = _SPEED_RE.search(value or "")
|
||||
return float(match.group(1)) if match else 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Getters
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_facts(self) -> dict[str, Any]:
|
||||
facts = parsers.parse_facts(self._client.fetch(self.DASHBOARD))
|
||||
hostname = facts["hostname"]
|
||||
return {
|
||||
"uptime": facts["uptime"],
|
||||
"vendor": self.VENDOR,
|
||||
"os_version": facts["os_version"],
|
||||
"serial_number": facts["serial_number"],
|
||||
"model": facts["model"],
|
||||
"hostname": hostname,
|
||||
"fqdn": hostname,
|
||||
"interface_list": [row[1] for row in self._rows(self.PORT_SUMMARY)],
|
||||
}
|
||||
|
||||
def get_interfaces(self) -> dict[str, dict[str, Any]]:
|
||||
"""Physical ports and trunk interfaces.
|
||||
|
||||
Columns: [checkbox, Interface, Port Description, Admin Mode,
|
||||
Physical Type, Port Status, Physical Mode, Link Speed, MTU]
|
||||
"""
|
||||
interfaces: dict[str, dict[str, Any]] = {}
|
||||
for row in self._rows(self.PORT_SUMMARY):
|
||||
interfaces[row[1]] = {
|
||||
"is_up": row[5] == "Link Up",
|
||||
"is_enabled": row[3] == "Enabled",
|
||||
"description": row[2],
|
||||
# The web UI reports neither flap time nor a per-port MAC.
|
||||
"last_flapped": -1.0,
|
||||
"speed": self._speed_mbit(row[7]),
|
||||
"mtu": int(row[8]) if row[8].isdigit() else 0,
|
||||
"mac_address": "",
|
||||
}
|
||||
return interfaces
|
||||
|
||||
def get_interfaces_counters(self) -> dict[str, dict[str, int]]:
|
||||
"""Not available on this platform.
|
||||
|
||||
``port_summary_stats.lsp`` declares the expected columns but ships an
|
||||
empty dataset on PT.02.19, and carries no AJAX endpoint that would
|
||||
fill it. Returning zeros would be indistinguishable from a switch
|
||||
that has genuinely passed no traffic, so this raises instead.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"OfficeConnect firmware PT.02.19 does not expose interface counters"
|
||||
)
|
||||
|
||||
def _vlan_participation(self) -> dict[str, dict[str, Any]]:
|
||||
"""VLAN table joined with per-VLAN interface participation.
|
||||
|
||||
The participation page renders one VLAN at a time, so this costs one
|
||||
request per VLAN on top of the VLAN list itself.
|
||||
"""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for row in self._rows(self.VLAN_STATUS):
|
||||
vlan_id, name = row[1], row[2]
|
||||
tagged: list[str] = []
|
||||
untagged: list[str] = []
|
||||
for member in self._rows(self.VLAN_PER_PORT.format(vlan=vlan_id)):
|
||||
interface, state = member[1], member[2].lower()
|
||||
if state == "tagged":
|
||||
tagged.append(interface)
|
||||
elif state == "untagged":
|
||||
untagged.append(interface)
|
||||
result[vlan_id] = {"name": name, "tagged": tagged, "untagged": untagged}
|
||||
return result
|
||||
|
||||
def get_vlans(self) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
vlan_id: {
|
||||
"name": data["name"],
|
||||
"interfaces": sorted(
|
||||
data["untagged"] + data["tagged"],
|
||||
key=lambda i: (i.startswith("TRK"), i),
|
||||
),
|
||||
}
|
||||
for vlan_id, data in self._vlan_participation().items()
|
||||
}
|
||||
|
||||
def get_vlans_detail(self) -> dict[str, dict[str, Any]]:
|
||||
"""Like :meth:`get_vlans` but keeping tagged and untagged apart."""
|
||||
return self._vlan_participation()
|
||||
|
||||
def get_mac_address_table(self) -> list[dict[str, Any]]:
|
||||
"""Columns: [VLAN ID, MAC Address, Interface, Interface Index, Status]."""
|
||||
table = []
|
||||
for row in self._rows(self.MAC_TABLE):
|
||||
status = row[4].lower()
|
||||
table.append(
|
||||
{
|
||||
"mac": row[1],
|
||||
"interface": row[2],
|
||||
"vlan": int(row[0]) if row[0].isdigit() else 0,
|
||||
"static": status in ("static", "management"),
|
||||
"active": True,
|
||||
"moves": -1,
|
||||
"last_move": -1.0,
|
||||
}
|
||||
)
|
||||
return table
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user