-
Notifications
You must be signed in to change notification settings - Fork 40
lf_wifi_msgs.py: Add standalone wifi messages script #381
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
saurabhkakade21
merged 1 commit into
greearb:master
from
goyalsaurabh06:LISP-195-standalone_wifi_messages
Sep 2, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,366 @@ | ||
| #!/usr/bin/env python3 | ||
| """ | ||
| NAME: lf_wifi_msgs.py | ||
|
|
||
| PURPOSE: | ||
| Fetch LANforge Wi-Fi messages from the GUI REST API (/wifi-msgs): the last or | ||
| first N messages, everything since a time-stamp, everything from the last | ||
| <duration>, everything between two stamps, or keep printing new messages as | ||
| they arrive. | ||
|
|
||
| EXAMPLE: | ||
| python3 lf_wifi_msgs.py --mgr 192.168.1.31 --last 50 | ||
| python3 lf_wifi_msgs.py --mgr 192.168.1.31 --duration 5m --output json --outfile msgs.json | ||
| python3 lf_wifi_msgs.py --mgr 192.168.1.31 --since 1699999999999 --output json | ||
| python3 lf_wifi_msgs.py --mgr 192.168.1.31 --between 1788159332090 1788161286054 | ||
| python3 lf_wifi_msgs.py --mgr 192.168.1.31 --poll --interval 2s | ||
|
|
||
| # importable | ||
| from lf_wifi_msgs import WifiMessages | ||
| wifi_msgs = WifiMessages(host="192.168.1.31") | ||
| for entry in wifi_msgs.last(50): | ||
| print(entry["time-stamp"], entry["resource"], entry["text"]) | ||
| for entry in wifi_msgs.poll(interval=2): # stop with break / wifi_msgs.stop_polling() | ||
| handle(entry) | ||
|
|
||
| NOTES: | ||
| LANforge time-stamps are epoch milliseconds. --since / --between values are | ||
| sent through unchanged; --duration is computed off the newest message. | ||
| Raw output is '<time-stamp> <resource> <text>' per line; --output json keeps the full entry dicts. | ||
|
|
||
| SCRIPT_CLASSIFICATION: Reporting, Wi-Fi Messages | ||
|
|
||
| SCRIPT_CATEGORIES: Functional | ||
|
|
||
| STATUS: Functional | ||
|
|
||
| COPYRIGHT: | ||
| Copyright (C) 2020-2026 Candela Technologies Inc | ||
| License: Free to distribute and modify. LANforge systems must be licensed. | ||
|
|
||
| INCLUDE_IN_README: False | ||
| """ | ||
| import sys | ||
| import os | ||
| import importlib | ||
| import argparse | ||
| import json | ||
| import re | ||
| import time | ||
| import threading | ||
| import logging | ||
| from typing import Callable, Iterator, List, Optional | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
| if sys.version_info[0] != 3: | ||
| logger.critical("This script requires Python 3") | ||
| exit(1) | ||
|
|
||
| sys.path.append(os.path.join(os.path.abspath(__file__ + "../../../"))) | ||
|
|
||
| lfcli_base = importlib.import_module("py-json.LANforge.lfcli_base") | ||
| LFCliBase = lfcli_base.LFCliBase | ||
| realm = importlib.import_module("py-json.realm") | ||
| Realm = realm.Realm | ||
| lf_logger_config = importlib.import_module("py-scripts.lf_logger_config") | ||
|
|
||
| RETRY_TIMEOUT = 40 # seconds to keep retrying a /wifi-msgs GET that returns nothing | ||
| RETRY_INTERVAL = 5 # seconds between those retries | ||
| POLL_INTERVAL = 5 # default seconds between polls in poll() | ||
|
|
||
|
|
||
| class WifiMessages(Realm): | ||
| """Query the LANforge '/wifi-msgs' REST endpoints.""" | ||
|
|
||
| def __init__(self, host: Optional[str] = None, port: int = 8080, debug: bool = False, | ||
| retry_timeout: int = RETRY_TIMEOUT, retry_interval: int = RETRY_INTERVAL) -> None: | ||
| """Build a /wifi-msgs client. | ||
|
|
||
| Args: | ||
| host: LANforge manager IP or hostname. | ||
| port: LANforge GUI REST port (default 8080). | ||
| debug: pass through to the REST layer for verbose logging. | ||
| retry_timeout: seconds to keep retrying a GET that returns nothing. | ||
| retry_interval: seconds between those retries. | ||
| """ | ||
| super().__init__(host, port, debug_=debug) | ||
| self.debug = debug | ||
| self.retry_timeout = retry_timeout | ||
| self.retry_interval = retry_interval | ||
| self._poll_stop = threading.Event() # set by stop_polling(), watched by poll() | ||
|
|
||
| @staticmethod | ||
| def normalize_messages(response: Optional[dict]) -> List[dict]: | ||
| """Normalize a '/wifi-msgs' response body into a flat list of entry dicts. | ||
|
|
||
| Args: | ||
| response: the decoded JSON body of a /wifi-msgs GET, or None. | ||
|
|
||
| LANforge returns wifi-messages as a bare entry, a '{<key>: {entry}}' | ||
| wrapper, or a list of either; this returns a plain list of the entry dicts | ||
| (each has 'resource', 'text', 'time-stamp'). Missing or empty gives []. | ||
| """ | ||
| messages = (response or {}).get("wifi-messages") | ||
| if messages is None: | ||
| return [] | ||
| items = messages if isinstance(messages, list) else [messages] | ||
| out = [] | ||
| for item in items: | ||
| if not isinstance(item, dict): | ||
| continue | ||
| if "text" in item or "time-stamp" in item: | ||
| out.append(item) | ||
| else: | ||
| out.extend(v for v in item.values() if isinstance(v, dict)) | ||
| return out | ||
|
|
||
| @staticmethod | ||
| def timestamp_ms(entry: dict) -> Optional[int]: | ||
| """The entry's 'time-stamp' as an int, or None if missing/non-numeric. | ||
|
|
||
| Args: | ||
| entry: one normalized wifi-msg dict. | ||
| """ | ||
| raw = entry.get("time-stamp") or entry.get("timestamp") | ||
| try: | ||
| return int(str(raw).strip()) | ||
| except (TypeError, ValueError): | ||
|
abhisheksharma-candela marked this conversation as resolved.
|
||
| logger.error("wifi-msg entry has a missing/non-numeric time-stamp: %r", raw) | ||
| return None | ||
|
|
||
| @staticmethod | ||
| def to_seconds(text: str) -> float: | ||
| """Parse a duration into float seconds. | ||
|
|
||
| Accepts a bare number (seconds) or a number with a unit suffix - ms, s, m, h or d - e.g. 30s, 5m, 2h, 500ms, or just 30. | ||
|
|
||
| Args: | ||
| text: the duration string to parse. | ||
| """ | ||
| match = re.match(r"^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\s*$", str(text), re.IGNORECASE) | ||
| if not match: | ||
| raise ValueError("invalid duration {!r}; use e.g. 30s, 5m, 2h, 500ms".format(text)) | ||
| units = {"ms": 0.001, "s": 1, "m": 60, "h": 3600, "d": 86400} | ||
| return float(match.group(1)) * units[(match.group(2) or "s").lower()] | ||
|
|
||
| def get_wifi_messages(self, uri: str) -> List[dict]: | ||
| """GET wifi messages (retrying while the response is empty), return the normalized entries. | ||
|
|
||
| Args: | ||
| uri: the /wifi-msgs REST path to GET. | ||
| """ | ||
| start = time.time() | ||
| response = self.json_get(uri, debug_=self.debug) | ||
| while response is None and (time.time() - start) < self.retry_timeout: | ||
| logger.warning("GET %s returned nothing from LANforge; retrying", uri) | ||
| time.sleep(self.retry_interval) | ||
| response = self.json_get(uri, debug_=self.debug) | ||
| if response is None: | ||
| logger.error("GET %s returned nothing after %ss", uri, self.retry_timeout) | ||
| return self.normalize_messages(response) | ||
|
|
||
| def last(self, count: int = 1) -> List[dict]: | ||
| """The most recent buffered messages. | ||
|
|
||
| Args: | ||
| count: number of messages to fetch (default 1). | ||
| """ | ||
| return self.get_wifi_messages("/wifi-msgs/last/{}".format(int(count))) | ||
|
|
||
| def first(self, count: int = 1) -> List[dict]: | ||
| """The oldest buffered messages. | ||
|
|
||
| Args: | ||
| count: number of messages to fetch (default 1). | ||
| """ | ||
| return self.get_wifi_messages("/wifi-msgs/first/{}".format(int(count))) | ||
|
|
||
| def since(self, timestamp: int) -> List[dict]: | ||
| """Every buffered message at or after a time-stamp. | ||
|
|
||
| Args: | ||
| timestamp: LANforge epoch-ms time-stamp. | ||
| """ | ||
| return self.get_wifi_messages("/wifi-msgs/since=time/{}".format(timestamp)) | ||
|
|
||
| def between(self, start: int, end: int) -> List[dict]: | ||
| """Every buffered message within a time window. | ||
|
|
||
| Args: | ||
| start: window start, LANforge epoch-ms time-stamp. | ||
| end: window end, LANforge epoch-ms time-stamp. | ||
| """ | ||
| return self.get_wifi_messages("/wifi-msgs/between=time/{}/{}".format(start, end)) | ||
|
|
||
| def duration(self, seconds: float) -> List[dict]: | ||
| """Messages from the last 'seconds': the newest message's stamp minus the window, via since=time. | ||
| Falls back to last=time with no baseline. | ||
|
|
||
| Args: | ||
| seconds: window length in seconds. | ||
| """ | ||
| window_ms = int(float(seconds) * 1000) | ||
| latest = self.last(1) | ||
| base_ms = self.timestamp_ms(latest[-1]) if latest else None | ||
| if base_ms is None: | ||
| logger.warning("No epoch-ms wifi-msg baseline available; using /wifi-msgs/last=time") | ||
| return self.get_wifi_messages("/wifi-msgs/last=time/{}".format(window_ms)) | ||
| return self.since(base_ms - window_ms) | ||
|
|
||
| def stop_polling(self) -> None: | ||
| """Ask a running 'poll()' to stop. Cleared on the next 'poll()' entry.""" | ||
| self._poll_stop.set() | ||
|
|
||
| def poll(self, interval: float = POLL_INTERVAL, since_ts: Optional[int] = None, | ||
| stop: Optional[Callable[[], bool]] = None) -> Iterator[dict]: | ||
| """Yield each new /wifi-msgs entry once, as it appears. | ||
|
|
||
| Open-ended generator; never prints. Stop it with 'break', 'stop_polling()', or a 'stop' predicate. | ||
|
|
||
| Args: | ||
| interval: seconds to wait between polls (default POLL_INTERVAL). | ||
| since_ts: epoch-ms start point; default is the newest message at entry, so only new traffic is yielded. | ||
| stop: optional predicate; polling ends when it returns True. | ||
| """ | ||
| self._poll_stop.clear() | ||
| if since_ts is None: | ||
| deadline = time.time() + self.retry_timeout | ||
| while since_ts is None: | ||
| latest = self.last(1) | ||
| since_ts = self.timestamp_ms(latest[-1]) if latest else None | ||
| if since_ts is not None or time.time() >= deadline: | ||
| break | ||
| # timestamp_ms() is None on a missing/non-numeric stamp; retry a few times for a real one before giving up. | ||
| logger.warning("wifi-msg has no usable time-stamp; retrying for a poll baseline") | ||
| if self._poll_stop.wait(self.retry_interval): | ||
| return | ||
| if since_ts is None: | ||
| # Still nothing usable, end the generator so the caller stops. | ||
| logger.error("no usable wifi-msg time-stamp for a poll baseline after %ss; giving up", self.retry_timeout) | ||
| return | ||
| since_ts = int(since_ts) | ||
| seen = {(e.get("resource"), e.get("time-stamp"), str(e.get("text"))) | ||
| for e in self.since(since_ts) if self.timestamp_ms(e) == since_ts} | ||
|
|
||
| while not self._poll_stop.is_set() and not (stop and stop()): | ||
| batch = self.since(since_ts) | ||
| newest = since_ts | ||
| for entry in batch: | ||
| marker = (entry.get("resource"), entry.get("time-stamp"), str(entry.get("text"))) | ||
| if marker not in seen: | ||
| yield entry | ||
| ts = self.timestamp_ms(entry) | ||
| if ts is not None and ts > newest: | ||
| newest = ts | ||
| # Advance the cursor; the batch was fetched from the old (<= newest) cursor. | ||
| since_ts = newest | ||
| seen = {(e.get("resource"), e.get("time-stamp"), str(e.get("text"))) | ||
| for e in batch if self.timestamp_ms(e) == newest} | ||
| if self._poll_stop.wait(interval): | ||
| return | ||
|
|
||
| @staticmethod | ||
| def render(entries: List[dict], output: str, stream, line_delimited: bool = False) -> None: | ||
| """Write entries to stream. | ||
|
|
||
| Args: | ||
| entries: normalized wifi-msg dicts to write. | ||
| output: 'raw' or 'json'. | ||
| stream: a writable text stream (file object or sys.stdout). | ||
| line_delimited: json only - emit one object per line instead of an array. | ||
|
|
||
| output='raw' -> '<time-stamp> <resource> <text>', one line of text at a time. | ||
| output='json' -> a pretty JSON array; or, with line_delimited=True (used by --poll), one compact JSON object | ||
| per line so the growing stream stays parseable line by line. | ||
|
|
||
| """ | ||
| if output == "json": | ||
| if line_delimited: | ||
| for entry in entries: | ||
| stream.write(json.dumps(entry, separators=(",", ":")) + "\n") | ||
| else: | ||
| json.dump(entries, stream, indent=2) | ||
| stream.write("\n") | ||
| return | ||
| for entry in entries: | ||
| ts = entry.get("time-stamp", "") | ||
| resource = entry.get("resource", "") | ||
| text = entry.get("text", []) | ||
| for line in (text if isinstance(text, list) else [text]): | ||
| stream.write("{} {} {}\n".format(ts, resource, line)) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| help_summary = ("Fetch LANforge Wi-Fi messages from the /wifi-msgs REST API: last/first N, since a " | ||
| "time-stamp, from the last <duration>, between two stamps, or poll for new ones. " | ||
| "Importable as WifiMessages.") | ||
|
|
||
| parser = LFCliBase.create_basic_argparse( | ||
| prog='lf_wifi_msgs.py', | ||
| formatter_class=argparse.RawTextHelpFormatter, | ||
| description=__doc__) | ||
|
|
||
| wifi_msgs_args = parser.add_argument_group('wifi-msgs arguments') | ||
| mode = wifi_msgs_args.add_mutually_exclusive_group() | ||
| mode.add_argument('--last', type=int, metavar='N', help='the most recent N messages (default: 25)') | ||
| mode.add_argument('--first', type=int, metavar='N', help='the oldest N messages still buffered') | ||
| mode.add_argument('--since', metavar='TIMESTAMP', help='every message since a LANforge epoch-ms time-stamp') | ||
| mode.add_argument('--duration', metavar='WINDOW', help='every message from the last WINDOW (e.g. 30s, 5m, 2h)') | ||
| mode.add_argument('--between', nargs=2, type=int, metavar=('START', 'END'), | ||
| help='every message between two epoch-ms stamps') | ||
| mode.add_argument('--poll', '-p', action='store_true', | ||
| help='print new messages as they arrive (Ctrl-C to stop)') | ||
| wifi_msgs_args.add_argument('--interval', default='5s', metavar='WINDOW', | ||
| help='--poll interval (e.g. 2s, 500ms; default: 5s)') | ||
| wifi_msgs_args.add_argument('--output', choices=['raw', 'json'], default='raw', | ||
| help='raw message text lines or json (default: raw)') | ||
| wifi_msgs_args.add_argument('--outfile', metavar='PATH', help='write output to PATH instead of stdout') | ||
|
|
||
| args = parser.parse_args() | ||
| if args.help_summary: | ||
| print(help_summary) | ||
| exit(0) | ||
|
|
||
| try: | ||
| interval = WifiMessages.to_seconds(args.interval) | ||
| duration = WifiMessages.to_seconds(args.duration) if args.duration is not None else None | ||
| except Exception as error: | ||
| parser.error(f"Failed to parse the CLI arguments: {str(error)}") | ||
|
|
||
| logger_config = lf_logger_config.lf_logger_config() | ||
| logger_config.set_level(level=args.log_level) | ||
| logger_config.set_json(json_file=args.lf_logger_config_json) | ||
|
|
||
| wifi_msgs = WifiMessages(host=args.mgr, port=args.mgr_port, debug=args.debug) | ||
| out_stream = open(args.outfile, 'w') if args.outfile else sys.stdout | ||
| try: | ||
| if args.poll: | ||
| print("Polling /wifi-msgs (Ctrl-C to stop)", file=sys.stderr) | ||
| try: | ||
| for entry in wifi_msgs.poll(interval=interval): | ||
| wifi_msgs.render([entry], args.output, out_stream, line_delimited=True) | ||
| out_stream.flush() | ||
| except KeyboardInterrupt: | ||
| wifi_msgs.stop_polling() | ||
| print("Polling stopped", file=sys.stderr) | ||
| else: | ||
| if args.since is not None: | ||
| entries = wifi_msgs.since(args.since) | ||
| elif duration is not None: | ||
| entries = wifi_msgs.duration(duration) | ||
| elif args.between is not None: | ||
| entries = wifi_msgs.between(*sorted(args.between)) | ||
| elif args.first is not None: | ||
| entries = wifi_msgs.first(args.first) | ||
| else: | ||
| entries = wifi_msgs.last(args.last if args.last is not None else 25) | ||
| logger.debug("Fetched %d wifi message(s)", len(entries)) | ||
| wifi_msgs.render(entries, args.output, out_stream) | ||
| finally: | ||
| if out_stream is not sys.stdout: | ||
| out_stream.close() | ||
| exit(0) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.