Skip to content

Commit 5471f5a

Browse files
committed
Add VT100 terminal emulation and enable topCommand for EX/SRX devices
- Add _VT100Screen class and ANSI escape stripping in WebSocketWrapper for rendering screen-based output (top, monitor interface) - Uncomment and refactor top_command() to use start_with_trigger pattern - Export topCommand from device_utils.ex and device_utils.srx - Handle binary WebSocket frames in _MistWebsocket._handle_message (null byte stripping, bytes→str decode, TypeError catch) - Fix first_message_timeout stop to check timer is active before stopping - Update README with new functions and print flush examples - Add unit tests for VT100 screen, ANSI stripping, and binary frames
1 parent aae4fa4 commit 5471f5a

11 files changed

Lines changed: 455 additions & 93 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,4 @@ cython_debug/
152152
#.idea/
153153

154154
test/
155+
debug.py

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -712,9 +712,9 @@ with mistapi.websockets.sites.DeviceStatsEvents(apisession, site_ids=["<site_id>
712712
| Module | Device Type | Functions |
713713
|--------|-------------|-----------|
714714
| `device_utils.ap` | Mist Access Points | `ping`, `traceroute`, `retrieveArpTable` |
715-
| `device_utils.ex` | Juniper EX Switches | `ping`, `monitorTraffic`, `retrieveArpTable`, `retrieveBgpSummary`, `retrieveDhcpLeases`, `releaseDhcpLeases`, `retrieveMacTable`, `clearMacTable`, `clearLearnedMac`, `clearBpduError`, `clearDot1xSessions`, `clearHitCount`, `bouncePort`, `cableTest` |
716-
| `device_utils.srx` | Juniper SRX Firewalls | `ping`, `monitorTraffic`, `retrieveArpTable`, `retrieveBgpSummary`, `retrieveDhcpLeases`, `releaseDhcpLeases`, `showDatabase`, `showNeighbors`, `showInterfaces`, `bouncePort`, `retrieveRoutes` |
717-
| `device_utils.ssr` | Juniper SSR Routers | `ping`, `retrieveArpTable`, `retrieveBgpSummary`, `retrieveDhcpLeases`, `releaseDhcpLeases`, `showDatabase`, `showNeighbors`, `showInterfaces`, `bouncePort`, `retrieveRoutes`, `showServicePath` |
715+
| `device_utils.ex` | Juniper EX Switches | `ping`, `monitorTraffic`, `topCommand`, `retrieveArpTable`, `retrieveBgpSummary`, `retrieveDhcpLeases`, `releaseDhcpLeases`, `retrieveMacTable`, `clearMacTable`, `clearLearnedMac`, `clearBpduError`, `clearDot1xSessions`, `clearHitCount`, `bouncePort`, `cableTest` |
716+
| `device_utils.srx` | Juniper SRX Firewalls | `ping`, `monitorTraffic`, `topCommand`, `retrieveArpTable`, `retrieveBgpSummary`, `retrieveDhcpLeases`, `releaseDhcpLeases`, `retrieveOspfDatabase`, `retrieveOspfNeighbors`, `retrieveOspfInterfaces`, `retrieveOspfSummary`, `retrieveSessions`, `clearSessions`, `bouncePort`, `retrieveRoutes` |
717+
| `device_utils.ssr` | Juniper SSR Routers | `ping`, `retrieveArpTable`, `retrieveBgpSummary`, `retrieveDhcpLeases`, `releaseDhcpLeases`, `retrieveOspfDatabase`, `retrieveOspfNeighbors`, `retrieveOspfInterfaces`, `retrieveOspfSummary`, `retrieveSessions`, `clearSessions`, `bouncePort`, `retrieveRoutes`, `showServicePath` |
718718

719719
### Device Utilities Usage
720720

@@ -746,7 +746,7 @@ Iterate over processed messages as they arrive, similar to `_MistWebsocket.recei
746746
```python
747747
response = ex.retrieveMacTable(apisession, site_id, device_id)
748748
for msg in response.receive(): # blocking generator, yields each message
749-
print(msg)
749+
print(msg, end="", flush=True)
750750
# loop ends when the WebSocket closes
751751
print(response.ws_data)
752752
```
@@ -758,7 +758,7 @@ print(response.ws_data)
758758
```python
759759
with ex.cableTest(apisession, site_id, device_id, port_id="ge-0/0/0") as response:
760760
for msg in response.receive():
761-
print(msg)
761+
print(msg, end="", flush=True)
762762
# WebSocket disconnected, data ready
763763
print(response.ws_data)
764764
```
@@ -794,7 +794,7 @@ import asyncio
794794
from mistapi.device_utils import ex
795795

796796
async def main():
797-
response = ex.traceroute(apisession, site_id, device_id, host="8.8.8.8")
797+
response = ex.retrieveArpTable(apisession, site_id, device_id)
798798
await response # non-blocking await
799799
print(response.ws_data)
800800

src/mistapi/__api_session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -662,7 +662,7 @@ def _check_api_tokens(self, apitokens) -> list[str]:
662662
LOGGER.error(
663663
"apisession:_check_api_tokens:"
664664
"%s API Token %s has different privileges "
665-
"than the %s API Token %s and will not be used",
665+
"than the %s API Token %s and will not be used",
666666
token_type,
667667
masked,
668668
primary_token_type,

src/mistapi/device_utils/__tools/__ws_wrapper.py

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import queue
3+
import re
34
import threading
45
from collections.abc import Callable, Generator
56
from enum import Enum
@@ -8,6 +9,150 @@
89
from mistapi.__api_response import APIResponse as _APIResponse
910
from mistapi.__logger import logger as LOGGER
1011

12+
# Matches ANSI CSI sequences, OSC sequences, and character set designations
13+
_ANSI_ESCAPE_RE = re.compile(
14+
r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][A-B0-2]"
15+
)
16+
17+
# Detects VT100 cursor positioning / clear-screen (triggers screen-buffer mode)
18+
_SCREEN_MODE_RE = re.compile(r"\x1b\[[\d;]*H|\x1b\[2J")
19+
20+
21+
class _VT100Screen:
22+
"""Minimal VT100 terminal emulator for rendering screen-based output.
23+
24+
Handles the subset of VT100 sequences used by Junos ``top`` and
25+
``monitor interface`` commands: cursor positioning, screen/line
26+
clearing, and cursor movement. SGR (colors), scroll regions, and
27+
mode changes are silently ignored.
28+
"""
29+
30+
def __init__(self, rows: int = 80, cols: int = 200) -> None:
31+
self.rows = rows
32+
self.cols = cols
33+
self.cursor_row = 0
34+
self.cursor_col = 0
35+
self.grid: list[list[str]] = [[" "] * cols for _ in range(rows)]
36+
37+
def feed(self, text: str) -> None:
38+
"""Process *text* (may contain VT100 sequences) into the screen buffer."""
39+
i = 0
40+
n = len(text)
41+
while i < n:
42+
ch = text[i]
43+
44+
if ch == "\x1b" and i + 1 < n:
45+
nxt = text[i + 1]
46+
if nxt == "[":
47+
# CSI sequence: \x1b[ <params> <cmd>
48+
j = i + 2
49+
params = ""
50+
while j < n and text[j] in "0123456789;":
51+
params += text[j]
52+
j += 1
53+
if j < n:
54+
self._handle_csi(params, text[j])
55+
i = j + 1
56+
else:
57+
i = j
58+
continue
59+
if nxt in "()":
60+
# Character-set designation – skip 3 bytes
61+
i += 3 if i + 2 < n else n
62+
continue
63+
if nxt == "]":
64+
# OSC sequence – skip until BEL
65+
j = i + 2
66+
while j < n and text[j] != "\x07":
67+
j += 1
68+
i = j + 1
69+
continue
70+
# Unknown escape – skip \x1b and the next char
71+
i += 2
72+
continue
73+
74+
if ch == "\r":
75+
self.cursor_col = 0
76+
i += 1
77+
continue
78+
79+
if ch == "\n":
80+
self.cursor_row += 1
81+
self.cursor_col = 0
82+
if self.cursor_row >= self.rows:
83+
self.grid.pop(0)
84+
self.grid.append([" "] * self.cols)
85+
self.cursor_row = self.rows - 1
86+
i += 1
87+
continue
88+
89+
if ch == "\x00":
90+
i += 1
91+
continue
92+
93+
# Printable character
94+
if 0 <= self.cursor_row < self.rows and 0 <= self.cursor_col < self.cols:
95+
self.grid[self.cursor_row][self.cursor_col] = ch
96+
self.cursor_col += 1
97+
i += 1
98+
99+
# ------------------------------------------------------------------
100+
def _handle_csi(self, params: str, cmd: str) -> None:
101+
nums = []
102+
for p in params.split(";") if params else []:
103+
try:
104+
nums.append(int(p))
105+
except ValueError:
106+
nums.append(0)
107+
108+
if cmd in ("H", "f"): # Cursor position
109+
row = (nums[0] - 1) if nums else 0
110+
col = (nums[1] - 1) if len(nums) > 1 else 0
111+
self.cursor_row = max(0, min(row, self.rows - 1))
112+
self.cursor_col = max(0, min(col, self.cols - 1))
113+
elif cmd == "A": # Cursor up
114+
self.cursor_row = max(0, self.cursor_row - (nums[0] if nums else 1))
115+
elif cmd == "B": # Cursor down
116+
self.cursor_row = min(
117+
self.rows - 1, self.cursor_row + (nums[0] if nums else 1)
118+
)
119+
elif cmd == "C": # Cursor forward
120+
self.cursor_col = min(
121+
self.cols - 1, self.cursor_col + (nums[0] if nums else 1)
122+
)
123+
elif cmd == "D": # Cursor back
124+
self.cursor_col = max(0, self.cursor_col - (nums[0] if nums else 1))
125+
elif cmd == "J": # Erase in display
126+
n = nums[0] if nums else 0
127+
if n == 2:
128+
self.grid = [[" "] * self.cols for _ in range(self.rows)]
129+
self.cursor_row = 0
130+
self.cursor_col = 0
131+
elif n == 0:
132+
for c in range(self.cursor_col, self.cols):
133+
self.grid[self.cursor_row][c] = " "
134+
for r in range(self.cursor_row + 1, self.rows):
135+
self.grid[r] = [" "] * self.cols
136+
elif cmd == "K": # Erase in line
137+
n = nums[0] if nums else 0
138+
if n == 0:
139+
for c in range(self.cursor_col, self.cols):
140+
self.grid[self.cursor_row][c] = " "
141+
elif n == 1:
142+
for c in range(self.cursor_col + 1):
143+
self.grid[self.cursor_row][c] = " "
144+
elif n == 2:
145+
self.grid[self.cursor_row] = [" "] * self.cols
146+
# SGR (m), scroll region (r), mode set/reset (l, h) – ignore
147+
148+
# ------------------------------------------------------------------
149+
def render(self) -> str:
150+
"""Return screen content as text with trailing whitespace trimmed."""
151+
lines = ["".join(row).rstrip() for row in self.grid]
152+
while lines and not lines[-1]:
153+
lines.pop()
154+
return "\n".join(lines)
155+
11156

12157
class TimerAction(Enum):
13158
"""
@@ -176,6 +321,8 @@ def __init__(
176321
self.session_id: str | None = None
177322
self.capture_id: str | None = None
178323
self._on_message_cb = on_message
324+
self._screen: _VT100Screen | None = None
325+
self._screen_mode: bool = False
179326
self._extract_trigger_ids()
180327

181328
def _extract_trigger_ids(self):
@@ -252,7 +399,8 @@ def _handle_message(self, msg):
252399
self._timeout_handler(Timer.FIRST_MESSAGE_TIMEOUT, TimerAction.START)
253400
elif self._extract_session_id(msg):
254401
# Stop the first message timeout timer on receiving the first message
255-
self._timeout_handler(Timer.FIRST_MESSAGE_TIMEOUT, TimerAction.STOP)
402+
if self.timers[Timer.FIRST_MESSAGE_TIMEOUT.value]["thread"]:
403+
self._timeout_handler(Timer.FIRST_MESSAGE_TIMEOUT, TimerAction.STOP)
256404
LOGGER.debug("data: %s", msg)
257405
raw = self._extract_raw(msg)
258406
if raw:
@@ -323,8 +471,19 @@ def _extract_raw(self, message, root: bool = True):
323471
return self._extract_raw(event["data"], root=False)
324472
if "raw" in event:
325473
self.received_messages += 1
326-
LOGGER.debug("Extracted raw message: %s", event["raw"])
327-
return event["raw"]
474+
raw_value = event["raw"]
475+
if isinstance(raw_value, str):
476+
# Detect screen-mode (cursor positioning / clear-screen)
477+
if not self._screen_mode and _SCREEN_MODE_RE.search(raw_value):
478+
self._screen_mode = True
479+
self._screen = _VT100Screen()
480+
if self._screen_mode and self._screen is not None:
481+
self._screen.feed(raw_value)
482+
raw_value = self._screen.render()
483+
else:
484+
raw_value = _ANSI_ESCAPE_RE.sub("", raw_value)
485+
LOGGER.debug("Extracted raw message: %s", raw_value)
486+
return raw_value
328487
if "pcap_dict" in event:
329488
self.received_messages += 1
330489
LOGGER.debug("Extracted pcap data: %s", event["pcap_dict"])

src/mistapi/device_utils/__tools/dns.py

Lines changed: 25 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,25 @@
1-
"""
2-
--------------------------------------------------------------------------------
3-
------------------------- Mist API Python CLI Session --------------------------
1+
# """
2+
# --------------------------------------------------------------------------------
3+
# ------------------------- Mist API Python CLI Session --------------------------
44

5-
Written by: Thomas Munzer (tmunzer@juniper.net)
6-
Github : https://github.com/tmunzer/mistapi_python
5+
# Written by: Thomas Munzer (tmunzer@juniper.net)
6+
# Github : https://github.com/tmunzer/mistapi_python
77

8-
This package is licensed under the MIT License.
8+
# This package is licensed under the MIT License.
99

10-
--------------------------------------------------------------------------------
11-
"""
10+
# --------------------------------------------------------------------------------
11+
# """
1212

13-
from enum import Enum
13+
# from collections.abc import Callable
1414

15+
# from mistapi import APISession as _APISession
16+
# from mistapi.api.v1.sites import devices
17+
# from mistapi.device_utils.__tools.__common import Node
18+
# from mistapi.device_utils.__tools.__ws_wrapper import UtilResponse, WebSocketWrapper
19+
# from mistapi.websockets.sites import DeviceCmdEvents
1520

16-
class Node(Enum):
17-
"""Node Enum for specifying node information in DNS commands."""
1821

19-
NODE0 = "node0"
20-
NODE1 = "node1"
21-
22-
23-
## NO DATA
22+
# ## NO DATA
2423
# def test_resolution(
2524
# apisession: _APISession,
2625
# site_id: str,
@@ -63,22 +62,14 @@ class Node(Enum):
6362
# body["node"] = node.value
6463
# if hostname:
6564
# body["hostname"] = hostname
66-
# trigger = devices.testSiteSsrDnsResolution(
67-
# apisession,
68-
# site_id=site_id,
69-
# device_id=device_id,
70-
# body=body,
65+
# util_response = UtilResponse()
66+
# return WebSocketWrapper(
67+
# apisession, util_response, timeout=timeout, on_message=on_message
68+
# ).start_with_trigger(
69+
# trigger_fn=lambda: devices.testSiteSsrDnsResolution(
70+
# apisession, site_id=site_id, device_id=device_id, body=body
71+
# ),
72+
# ws_factory_fn=lambda _trigger: DeviceCmdEvents(
73+
# apisession, site_id=site_id, device_ids=[device_id]
74+
# ),
7175
# )
72-
# util_response = UtilResponse(trigger)
73-
# if trigger.status_code == 200:
74-
# LOGGER.info(trigger.data)
75-
# print(f"SSR DNS resolution command triggered for device {device_id}")
76-
# ws = DeviceCmdEvents(apisession, site_id=site_id, device_ids=[device_id])
77-
# util_response = await WebSocketWrapper(
78-
# apisession, util_response, timeout=timeout, on_message=on_message
79-
# ).start(ws)
80-
# else:
81-
# LOGGER.error(
82-
# f"Failed to trigger SSR DNS resolution command: {trigger.status_code} - {trigger.data}"
83-
# ) # Give the SSR DNS resolution command a moment to take effect
84-
# return util_response

0 commit comments

Comments
 (0)