Skip to content

Commit 5654c75

Browse files
committed
fix device_utils
1 parent 2fcb483 commit 5654c75

19 files changed

Lines changed: 209 additions & 1933 deletions

File tree

README.md

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -637,35 +637,114 @@ with mistapi.websockets.sites.DeviceStatsEvents(apisession, site_ids=["<site_id>
637637

638638
### Device Utilities Usage
639639

640-
```python
641-
from mistapi.device_utils import ap, ex
640+
All device utility functions are **non-blocking**: they trigger the REST API call, start a WebSocket stream in the background, and return a `UtilResponse` immediately. Your script can continue processing while data streams in.
641+
642+
#### Callback style
642643

643-
# Ping from an AP
644-
result = ap.ping(apisession, site_id, device_id, host="8.8.8.8")
645-
print(result.ws_data)
644+
Pass an `on_message` callback to process each result as it arrives:
646645

647-
# Retrieve ARP table from a switch
648-
result = ex.retrieveArpTable(apisession, site_id, device_id)
649-
print(result.ws_data)
646+
```python
647+
from mistapi.device_utils import ex
650648

651-
# With real-time callback
652649
def handle(msg):
653-
print("got:", msg)
650+
print("Live:", msg)
651+
652+
response = ex.retrieveArpTable(apisession, site_id, device_id, on_message=handle)
653+
# returns immediately — on_message fires for each message in the background
654+
655+
do_other_work()
656+
657+
response.wait() # block until streaming is complete
658+
print(response.ws_data) # all collected data
659+
```
660+
661+
#### Generator style
662+
663+
Iterate over processed messages as they arrive, similar to `_MistWebsocket.receive()`:
654664

655-
result = ex.cableTest(apisession, site_id, device_id, port="ge-0/0/0", on_message=handle)
665+
```python
666+
response = ex.retrieveMacTable(apisession, site_id, device_id)
667+
for msg in response.receive(): # blocking generator, yields each message
668+
print(msg)
669+
# loop ends when the WebSocket closes
670+
print(response.ws_data)
671+
```
672+
673+
#### Context manager
674+
675+
`disconnect()` is called automatically when the context exits:
676+
677+
```python
678+
with ex.cableTest(apisession, site_id, device_id, port_id="ge-0/0/0") as response:
679+
for msg in response.receive():
680+
print(msg)
681+
# WebSocket disconnected, data ready
682+
print(response.ws_data)
683+
```
684+
685+
#### Polling
686+
687+
Check `response.done` to avoid blocking:
688+
689+
```python
690+
response = ex.retrieveBgpSummary(apisession, site_id, device_id)
691+
while not response.done:
692+
do_other_work()
693+
print(response.ws_data)
694+
```
695+
696+
#### Cancel early
697+
698+
Stop a long-running stream before it completes:
699+
700+
```python
701+
response = ex.monitorTraffic(apisession, site_id, device_id, port_id="ge-0/0/0")
702+
do_some_work()
703+
response.disconnect() # stop the WebSocket
704+
print(response.ws_data) # data collected so far
705+
```
706+
707+
#### Async await
708+
709+
Works in `asyncio` contexts without blocking the event loop:
710+
711+
```python
712+
import asyncio
713+
from mistapi.device_utils import ex
714+
715+
async def main():
716+
response = ex.traceroute(apisession, site_id, device_id, host="8.8.8.8")
717+
await response # non-blocking await
718+
print(response.ws_data)
719+
720+
asyncio.run(main())
656721
```
657722

658723
### UtilResponse Object
659724

660725
All device utility functions return a `UtilResponse` object:
661726

727+
#### Attributes
728+
662729
| Attribute | Type | Description |
663730
|-----------|------|-------------|
664731
| `trigger_api_response` | `APIResponse` | The initial REST API response that triggered the device command. Contains `status_code`, `data`, and `headers` from the trigger request. |
665732
| `ws_required` | `bool` | `True` if the command required a WebSocket connection to stream results (most diagnostic commands do). `False` if the REST response alone was sufficient. |
666-
| `ws_data` | `list[str]` | Parsed result data extracted from the WebSocket stream. Each entry is a processed output line from the device (e.g., a line of ping output or an ARP table row). |
733+
| `ws_data` | `list[str]` | Parsed result data extracted from the WebSocket stream. This list is **live** — it grows as messages arrive in the background, even before `wait()` is called. |
667734
| `ws_raw_events` | `list[str]` | Raw, unprocessed WebSocket event payloads as received from the Mist API. Useful for debugging or custom parsing. |
668735

736+
#### Properties and Methods
737+
738+
| Method / Property | Returns | Description |
739+
|-------------------|---------|-------------|
740+
| `done` | `bool` | `True` if data collection is complete (or no WS was needed). |
741+
| `wait(timeout=None)` | `UtilResponse` | Block until data collection is complete. Returns `self`. |
742+
| `receive()` | `Generator` | Blocking generator that yields each processed message as it arrives. Exits when the WebSocket closes. |
743+
| `disconnect()` | `None` | Stop the WebSocket connection early. |
744+
| `await response` | `UtilResponse` | Non-blocking await for `asyncio` contexts. |
745+
746+
`UtilResponse` also supports the context manager protocol (`with` statement).
747+
669748
### Enums
670749

671750
- `ap.TracerouteProtocol``ICMP`, `UDP` (for `ap.traceroute()`)

src/mistapi/device_utils/__tools/__ws_wrapper.py

Lines changed: 104 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
2+
import queue
23
import threading
3-
from collections.abc import Callable
4+
from collections.abc import Callable, Generator
45
from enum import Enum
56

67
from mistapi import APISession
@@ -30,19 +31,102 @@ class Timer(Enum):
3031

3132
class UtilResponse:
3233
"""
33-
A simple class to encapsulate the response from utility WebSocket functions.
34-
This class can be extended in the future to include additional metadata or helper methods.
34+
Encapsulates the response from device utility functions.
35+
36+
Returned immediately by tool functions. When a WebSocket stream is
37+
involved, data is collected in the background. Use ``receive()``,
38+
``wait()``, or the ``on_message`` callback to consume results.
39+
40+
USAGE PATTERNS
41+
-----------
42+
Callback style (on_message passed at call time)::
43+
44+
response = ex.ping(session, site_id, device_id, host="8.8.8.8",
45+
on_message=lambda msg: print(msg))
46+
do_other_work()
47+
response.wait()
48+
print(response.ws_data)
49+
50+
Generator style::
51+
52+
response = ex.ping(session, site_id, device_id, host="8.8.8.8")
53+
for msg in response.receive():
54+
print(msg)
55+
56+
Context manager::
57+
58+
with ex.ping(session, site_id, device_id, host="8.8.8.8") as response:
59+
for msg in response.receive():
60+
print(msg)
61+
62+
Async await::
63+
64+
response = ex.ping(session, site_id, device_id, host="8.8.8.8")
65+
await response
66+
print(response.ws_data)
3567
"""
3668

3769
def __init__(
3870
self,
3971
api_response: _APIResponse,
4072
) -> None:
4173
self.trigger_api_response = api_response
42-
# This can be set to True if the WebSocket connection was successfully initiated
4374
self.ws_required: bool = False
4475
self.ws_data: list[str] = []
4576
self.ws_raw_events: list[str] = []
77+
self._queue: queue.Queue[str | None] = queue.Queue()
78+
self._closed = threading.Event()
79+
self._closed.set() # default: done (no WS to wait for)
80+
self._disconnect_fn: Callable[[], None] | None = None
81+
82+
@property
83+
def done(self) -> bool:
84+
"""True if data collection is complete (or no WS was needed)."""
85+
return self._closed.is_set()
86+
87+
def wait(self, timeout: float | None = None) -> "UtilResponse":
88+
"""Block until data collection is complete. Returns self."""
89+
self._closed.wait(timeout=timeout)
90+
return self
91+
92+
def receive(self) -> Generator[str, None, None]:
93+
"""
94+
Blocking generator that yields each processed message as it arrives.
95+
96+
Mirrors ``_MistWebsocket.receive()``. Exits cleanly when the
97+
WebSocket connection closes or ``disconnect()`` is called.
98+
"""
99+
while True:
100+
try:
101+
item = self._queue.get(timeout=1)
102+
except queue.Empty:
103+
if self._closed.is_set() and self._queue.empty():
104+
break
105+
continue
106+
if item is None:
107+
break
108+
yield item
109+
110+
def disconnect(self) -> None:
111+
"""Stop the WebSocket connection early."""
112+
if self._disconnect_fn:
113+
self._disconnect_fn()
114+
115+
def __enter__(self) -> "UtilResponse":
116+
return self
117+
118+
def __exit__(self, *args) -> None:
119+
self.disconnect()
120+
121+
def __await__(self):
122+
"""Allow ``result = await response`` in async contexts."""
123+
import asyncio
124+
125+
async def _await_impl():
126+
await asyncio.to_thread(self._closed.wait)
127+
return self
128+
129+
return _await_impl().__await__()
46130

47131

48132
class WebSocketWrapper:
@@ -83,7 +167,6 @@ def __init__(
83167
self.session_id: str | None = None
84168
self.capture_id: str | None = None
85169
self._on_message_cb = on_message
86-
self._closed = threading.Event()
87170

88171
LOGGER.debug(
89172
"trigger response: %s", self.util_response.trigger_api_response.data
@@ -107,7 +190,9 @@ def _on_open(self):
107190

108191
def _on_close(self, code, msg):
109192
LOGGER.info("WebSocket closed: %s - %s", code, msg)
110-
self._closed.set()
193+
self._stop_all_timers()
194+
self.util_response._queue.put(None) # sentinel for receive()
195+
self.util_response._closed.set() # signal completion
111196

112197
##########################################################################
113198
## Helper methods for managing timers
@@ -158,6 +243,7 @@ def _handle_message(self, msg):
158243
raw = self._extract_raw(msg)
159244
if raw:
160245
self.data.append(raw)
246+
self.util_response._queue.put(raw) # feed receive() generator
161247
if self._on_message_cb:
162248
self._on_message_cb(raw)
163249
self._timeout_handler(Timer.TIMEOUT, TimerAction.RESET)
@@ -234,7 +320,11 @@ def _extract_raw(self, message):
234320
## WebSocket connection management
235321
def start(self, ws) -> UtilResponse:
236322
"""
237-
Start the WS connection, block until closed, return UtilResponse.
323+
Start the WS connection in the background and return immediately.
324+
325+
The returned ``UtilResponse`` collects data as it streams in. Use
326+
``response.receive()``, ``response.wait()``, or the ``on_message``
327+
callback to consume results.
238328
239329
PARAMS
240330
-----------
@@ -246,9 +336,13 @@ def start(self, ws) -> UtilResponse:
246336
ws.on_error(lambda error: LOGGER.error("Error: %s", error))
247337
ws.on_close(self._on_close)
248338
ws.on_open(self._on_open)
249-
ws.connect(run_in_background=False) # blocks until _on_close fires
250-
self._stop_all_timers()
339+
340+
# Wire up UtilResponse before starting WS
251341
self.util_response.ws_required = True
252-
self.util_response.ws_data = self.data
342+
self.util_response.ws_data = self.data # live list reference
253343
self.util_response.ws_raw_events = self.raw_events
344+
self.util_response._closed.clear() # mark as "in progress"
345+
self.util_response._disconnect_fn = ws.disconnect
346+
347+
ws.connect(run_in_background=True) # non-blocking
254348
return self.util_response

src/mistapi/device_utils/__tools/bpdu.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from mistapi.device_utils.__tools.__ws_wrapper import UtilResponse
1717

1818

19-
async def clear_error(
19+
def clear_error(
2020
apissession: _APISession,
2121
site_id: str,
2222
device_id: str,

src/mistapi/device_utils/__tools/dns.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class Node(Enum):
7474
# LOGGER.info(trigger.data)
7575
# print(f"SSR DNS resolution command triggered for device {device_id}")
7676
# ws = DeviceCmdEvents(apissession, site_id=site_id, device_ids=[device_id])
77-
# util_response = WebSocketWrapper(
77+
# util_response = await WebSocketWrapper(
7878
# apissession, util_response, timeout=timeout, on_message=on_message
7979
# ).start(ws)
8080
# else:

src/mistapi/device_utils/__tools/dot1x.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from mistapi.device_utils.__tools.__ws_wrapper import UtilResponse
1717

1818

19-
async def clear_sessions(
19+
def clear_sessions(
2020
apissession: _APISession,
2121
site_id: str,
2222
device_id: str,

src/mistapi/device_utils/__tools/mac.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def clear_mac_table(
7474
if trigger.status_code == 200:
7575
LOGGER.info(trigger.data)
7676
print(f"Clear MAC Table command triggered for device {device_id}")
77-
# util_response = WebSocketWrapper(
77+
# util_response = await WebSocketWrapper(
7878
# apissession, util_response, timeout=timeout, on_message=on_message
7979
# ).start(ws)
8080
else:

src/mistapi/device_utils/__tools/miscellaneous.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,15 @@ def monitor_traffic(
301301
if trigger.status_code == 200:
302302
LOGGER.info(trigger.data)
303303
print(f"Monitor traffic command triggered for device {device_id}")
304-
ws = SessionWithUrl(apissession, url=trigger.data.get("url", ""))
305-
util_response = WebSocketWrapper(
306-
apissession, util_response, timeout=timeout, on_message=on_message
307-
).start(ws)
304+
if isinstance(trigger.data, dict) and "url" in trigger.data:
305+
ws = SessionWithUrl(apissession, url=trigger.data.get("url", ""))
306+
util_response = WebSocketWrapper(
307+
apissession, util_response, timeout=timeout, on_message=on_message
308+
).start(ws)
309+
else:
310+
LOGGER.error(
311+
f"Monitor traffic command did not return a valid URL: {trigger.data}"
312+
)
308313
else:
309314
LOGGER.error(
310315
f"Failed to trigger monitor traffic command: {trigger.status_code} - {trigger.data}"

src/mistapi/device_utils/__tools/policy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from mistapi.device_utils.__tools.__ws_wrapper import UtilResponse
1717

1818

19-
async def clear_hit_count(
19+
def clear_hit_count(
2020
apissession: _APISession,
2121
site_id: str,
2222
device_id: str,

src/mistapi/device_utils/ap.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,12 @@
1818
# Re-export shared classes and types
1919
from mistapi.device_utils.__tools.arp import retrieve_ap_arp_table as retrieveArpTable
2020
from mistapi.device_utils.__tools.miscellaneous import (
21-
TracerouteProtocol,
2221
ping,
2322
traceroute,
2423
)
2524

2625
__all__ = [
2726
"ping",
2827
"traceroute",
29-
"TracerouteProtocol",
3028
"retrieveArpTable",
3129
]

0 commit comments

Comments
 (0)