460 lines
16 KiB
Python
460 lines
16 KiB
Python
"""
|
|
sdt.py: Scripted Display Tool (SDT3D) helper
|
|
"""
|
|
|
|
import logging
|
|
import socket
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Optional
|
|
from urllib.parse import urlparse
|
|
|
|
from core.constants import CORE_CONF_DIR
|
|
from core.emane.nodes import EmaneNet
|
|
from core.emulator.data import LinkData, NodeData
|
|
from core.emulator.enumerations import EventTypes, MessageFlags
|
|
from core.errors import CoreError
|
|
from core.nodes.base import CoreNode, NodeBase
|
|
from core.nodes.network import HubNode, SwitchNode, TunnelNode, WlanNode
|
|
from core.nodes.physical import Rj45Node
|
|
from core.nodes.wireless import WirelessNode
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
if TYPE_CHECKING:
|
|
from core.emulator.session import Session
|
|
|
|
LOCAL_ICONS_PATH: Path = Path(__file__).parent.parent / "gui" / "data" / "icons"
|
|
CORE_LAYER: str = "CORE"
|
|
NODE_LAYER: str = "CORE::Nodes"
|
|
LINK_LAYER: str = "CORE::Links"
|
|
WIRED_LINK_LAYER: str = f"{LINK_LAYER}::wired"
|
|
CORE_LAYERS: list[str] = [CORE_LAYER, LINK_LAYER, NODE_LAYER, WIRED_LINK_LAYER]
|
|
DEFAULT_LINK_COLOR: str = "red"
|
|
NODE_TYPES: dict[type[NodeBase], str] = {
|
|
HubNode: "hub",
|
|
SwitchNode: "lanswitch",
|
|
TunnelNode: "tunnel",
|
|
WlanNode: "wlan",
|
|
EmaneNet: "emane",
|
|
WirelessNode: "wireless",
|
|
Rj45Node: "rj45",
|
|
}
|
|
|
|
|
|
def is_wireless(node: NodeBase) -> bool:
|
|
return isinstance(node, (WlanNode, EmaneNet, WirelessNode))
|
|
|
|
|
|
def get_link_id(node1_id: int, node2_id: int, network_id: int) -> str:
|
|
link_id = f"{node1_id}-{node2_id}"
|
|
if network_id is not None:
|
|
link_id = f"{link_id}-{network_id}"
|
|
return link_id
|
|
|
|
|
|
class Sdt:
|
|
"""
|
|
Helper class for exporting session objects to NRL"s SDT3D.
|
|
The connect() method initializes the display, and can be invoked
|
|
when a node position or link has changed.
|
|
"""
|
|
|
|
DEFAULT_SDT_URL: str = "tcp://127.0.0.1:50000/"
|
|
# default altitude (in meters) for flyto view
|
|
DEFAULT_ALT: int = 2500
|
|
# TODO: read in user"s nodes.conf here; below are default node types from the GUI
|
|
DEFAULT_SPRITES: dict[str, str] = [
|
|
("router", "router.png"),
|
|
("host", "host.png"),
|
|
("PC", "pc.png"),
|
|
("mdr", "mdr.png"),
|
|
("prouter", "prouter.png"),
|
|
("hub", "hub.png"),
|
|
("lanswitch", "lanswitch.png"),
|
|
("wlan", "wlan.png"),
|
|
("emane", "emane.png"),
|
|
("wireless", "wireless.png"),
|
|
("rj45", "rj45.png"),
|
|
("tunnel", "tunnel.png"),
|
|
]
|
|
|
|
def __init__(self, session: "Session") -> None:
|
|
"""
|
|
Creates a Sdt instance.
|
|
|
|
:param session: session this manager is tied to
|
|
"""
|
|
self.session: "Session" = session
|
|
self.sock: Optional[socket.socket] = None
|
|
self.connected: bool = False
|
|
self.url: str = self.DEFAULT_SDT_URL
|
|
self.address: Optional[tuple[Optional[str], Optional[int]]] = None
|
|
self.protocol: Optional[str] = None
|
|
self.network_layers: set[str] = set()
|
|
self.session.node_handlers.append(self.handle_node_update)
|
|
self.session.link_handlers.append(self.handle_link_update)
|
|
|
|
def is_enabled(self) -> bool:
|
|
"""
|
|
Check for "enablesdt" session option. Return False by default if
|
|
the option is missing.
|
|
|
|
:return: True if enabled, False otherwise
|
|
"""
|
|
return self.session.options.get_int("enablesdt") == 1
|
|
|
|
def seturl(self) -> None:
|
|
"""
|
|
Read "sdturl" from session options, or use the default value.
|
|
Set self.url, self.address, self.protocol
|
|
|
|
:return: nothing
|
|
"""
|
|
url = self.session.options.get("stdurl", self.DEFAULT_SDT_URL)
|
|
self.url = urlparse(url)
|
|
self.address = (self.url.hostname, self.url.port)
|
|
self.protocol = self.url.scheme
|
|
|
|
def connect(self) -> bool:
|
|
"""
|
|
Connect to the SDT address/port if enabled.
|
|
|
|
:return: True if connected, False otherwise
|
|
"""
|
|
if not self.is_enabled():
|
|
return False
|
|
if self.connected:
|
|
return True
|
|
if self.session.state == EventTypes.SHUTDOWN_STATE:
|
|
return False
|
|
|
|
self.seturl()
|
|
logger.info("connecting to SDT at %s://%s", self.protocol, self.address)
|
|
if self.sock is None:
|
|
try:
|
|
if self.protocol.lower() == "udp":
|
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.sock.connect(self.address)
|
|
else:
|
|
# Default to tcp
|
|
self.sock = socket.create_connection(self.address, 5)
|
|
except OSError:
|
|
logger.exception("SDT socket connect error")
|
|
return False
|
|
|
|
if not self.initialize():
|
|
return False
|
|
|
|
self.connected = True
|
|
# refresh all objects in SDT3D when connecting after session start
|
|
if not self.sendobjs():
|
|
return False
|
|
return True
|
|
|
|
def initialize(self) -> bool:
|
|
"""
|
|
Load icon sprites, and fly to the reference point location on
|
|
the virtual globe.
|
|
|
|
:return: initialize command status
|
|
"""
|
|
if not self.cmd(f'path "{LOCAL_ICONS_PATH.absolute()}"'):
|
|
return False
|
|
# send node type to icon mappings
|
|
for node_type, icon in self.DEFAULT_SPRITES:
|
|
if not self.cmd(f"sprite {node_type} image {icon}"):
|
|
return False
|
|
lat, long = self.session.location.refgeo[:2]
|
|
return self.cmd(f"flyto {long:.6f},{lat:.6f},{self.DEFAULT_ALT}")
|
|
|
|
def disconnect(self) -> None:
|
|
"""
|
|
Disconnect from SDT.
|
|
|
|
:return: nothing
|
|
"""
|
|
if self.sock:
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
logger.error("error closing socket")
|
|
finally:
|
|
self.sock = None
|
|
self.connected = False
|
|
|
|
def shutdown(self) -> None:
|
|
"""
|
|
Invoked from Session.shutdown() and Session.checkshutdown().
|
|
|
|
:return: nothing
|
|
"""
|
|
self.cmd("clear all")
|
|
for layer in self.network_layers:
|
|
self.cmd(f"delete layer,{layer}")
|
|
for layer in CORE_LAYERS[::-1]:
|
|
self.cmd(f"delete layer,{layer}")
|
|
self.disconnect()
|
|
self.network_layers.clear()
|
|
|
|
def cmd(self, cmdstr: str) -> bool:
|
|
"""
|
|
Send an SDT command over a UDP socket. socket.sendall() is used
|
|
as opposed to socket.sendto() because an exception is raised when
|
|
there is no socket listener.
|
|
|
|
:param cmdstr: command to send
|
|
:return: True if command was successful, False otherwise
|
|
"""
|
|
if self.sock is None:
|
|
return False
|
|
try:
|
|
cmd = f"{cmdstr}\n".encode()
|
|
logger.debug("sdt cmd: %s", cmd)
|
|
self.sock.sendall(cmd)
|
|
return True
|
|
except OSError:
|
|
logger.exception("SDT connection error")
|
|
self.sock = None
|
|
self.connected = False
|
|
return False
|
|
|
|
def sendobjs(self) -> None:
|
|
"""
|
|
Session has already started, and the SDT3D GUI later connects.
|
|
Send all node and link objects for display. Otherwise, nodes and
|
|
links will only be drawn when they have been updated (e.g. moved).
|
|
|
|
:return: nothing
|
|
"""
|
|
for layer in CORE_LAYERS:
|
|
self.cmd(f"layer {layer}")
|
|
with self.session.nodes_lock:
|
|
nets = []
|
|
for node in self.session.nodes.values():
|
|
if isinstance(node, (EmaneNet, WlanNode)):
|
|
nets.append(node)
|
|
if not isinstance(node, NodeBase):
|
|
continue
|
|
self.add_node(node)
|
|
for link in self.session.link_manager.links():
|
|
if is_wireless(link.node1) or is_wireless(link.node2):
|
|
continue
|
|
link_data = link.get_data(MessageFlags.ADD)
|
|
self.handle_link_update(link_data)
|
|
for net in nets:
|
|
for link_data in net.links(MessageFlags.ADD):
|
|
self.handle_link_update(link_data)
|
|
|
|
def get_node_position(self, node: NodeBase) -> Optional[str]:
|
|
"""
|
|
Convenience to generate an SDT position string, given a node.
|
|
|
|
:param node:
|
|
:return:
|
|
"""
|
|
x, y, z = node.position.get()
|
|
if x is None or y is None:
|
|
return None
|
|
lat, lon, alt = self.session.location.getgeo(x, y, z)
|
|
return f"pos {lon:.6f},{lat:.6f},{alt:.6f}"
|
|
|
|
def add_node(self, node: NodeBase) -> None:
|
|
"""
|
|
Handle adding a node in SDT.
|
|
|
|
:param node: node to add
|
|
:return: nothing
|
|
"""
|
|
logger.debug("sdt add node: %s - %s", node.id, node.name)
|
|
if not self.connect():
|
|
return
|
|
pos = self.get_node_position(node)
|
|
if not pos:
|
|
return
|
|
if isinstance(node, CoreNode):
|
|
node_type = node.model
|
|
else:
|
|
node_type = NODE_TYPES.get(type(node), "PC")
|
|
icon = node.icon
|
|
if icon:
|
|
node_type = node.name
|
|
icon = icon.replace("$CORE_DATA_DIR", str(LOCAL_ICONS_PATH.absolute()))
|
|
icon = icon.replace("$CORE_CONF_DIR", str(CORE_CONF_DIR))
|
|
self.cmd(f"sprite {node_type} image {icon}")
|
|
self.cmd(
|
|
f'node {node.id} nodeLayer "{NODE_LAYER}" '
|
|
f'type {node_type} label on,"{node.name}" {pos}'
|
|
)
|
|
|
|
def edit_node(self, node: NodeBase, lon: float, lat: float, alt: float) -> None:
|
|
"""
|
|
Handle updating a node in SDT.
|
|
|
|
:param node: node to update
|
|
:param lon: node longitude
|
|
:param lat: node latitude
|
|
:param alt: node altitude
|
|
:return: nothing
|
|
"""
|
|
logger.debug("sdt update node: %s - %s", node.id, node.name)
|
|
if not self.connect():
|
|
return
|
|
|
|
if all([lat is not None, lon is not None, alt is not None]):
|
|
pos = f"pos {lon:.6f},{lat:.6f},{alt:.6f}"
|
|
self.cmd(f"node {node.id} {pos}")
|
|
else:
|
|
pos = self.get_node_position(node)
|
|
if not pos:
|
|
return
|
|
self.cmd(f"node {node.id} {pos}")
|
|
|
|
def delete_node(self, node_id: int) -> None:
|
|
"""
|
|
Handle deleting a node in SDT.
|
|
|
|
:param node_id: node id to delete
|
|
:return: nothing
|
|
"""
|
|
logger.debug("sdt delete node: %s", node_id)
|
|
if not self.connect():
|
|
return
|
|
self.cmd(f"delete node,{node_id}")
|
|
|
|
def handle_node_update(self, node_data: NodeData) -> None:
|
|
"""
|
|
Handler for node updates, specifically for updating their location.
|
|
|
|
:param node_data: node data being updated
|
|
:return: nothing
|
|
"""
|
|
if not self.connect():
|
|
return
|
|
node = node_data.node
|
|
logger.debug("sdt handle node update: %s - %s", node.id, node.name)
|
|
if node_data.message_type == MessageFlags.DELETE:
|
|
self.cmd(f"delete node,{node.id}")
|
|
else:
|
|
x, y, _ = node.position.get()
|
|
lon, lat, alt = node.position.get_geo()
|
|
if all([lat is not None, lon is not None, alt is not None]):
|
|
pos = f"pos {lon:.6f},{lat:.6f},{alt:.6f}"
|
|
self.cmd(f"node {node.id} {pos}")
|
|
elif node_data.message_type == MessageFlags.NONE:
|
|
lat, lon, alt = self.session.location.getgeo(x, y, 0)
|
|
pos = f"pos {lon:.6f},{lat:.6f},{alt:.6f}"
|
|
self.cmd(f"node {node.id} {pos}")
|
|
|
|
def wireless_net_check(self, node_id: int) -> bool:
|
|
"""
|
|
Determines if a node is either a wireless node type.
|
|
|
|
:param node_id: node id to check
|
|
:return: True is a wireless node type, False otherwise
|
|
"""
|
|
result = False
|
|
try:
|
|
node = self.session.get_node(node_id, NodeBase)
|
|
result = isinstance(node, (WlanNode, EmaneNet, WirelessNode))
|
|
except CoreError:
|
|
pass
|
|
return result
|
|
|
|
def add_link(
|
|
self, node1_id: int, node2_id: int, network_id: int = None, label: str = None
|
|
) -> None:
|
|
"""
|
|
Handle adding a link in SDT.
|
|
|
|
:param node1_id: node one id
|
|
:param node2_id: node two id
|
|
:param network_id: network link is associated with, None otherwise
|
|
:param label: label for link
|
|
:return: nothing
|
|
"""
|
|
logger.debug("sdt add link: %s, %s, %s", node1_id, node2_id, network_id)
|
|
if not self.connect():
|
|
return
|
|
if self.wireless_net_check(node1_id) or self.wireless_net_check(node2_id):
|
|
return
|
|
color = DEFAULT_LINK_COLOR
|
|
if network_id:
|
|
color = self.session.get_link_color(network_id)
|
|
line = f"{color},2"
|
|
link_id = get_link_id(node1_id, node2_id, network_id)
|
|
if network_id:
|
|
layer = self.get_network_layer(network_id)
|
|
else:
|
|
layer = WIRED_LINK_LAYER
|
|
link_label = ""
|
|
if label:
|
|
link_label = f'linklabel on,"{label}"'
|
|
self.cmd(
|
|
f"link {node1_id},{node2_id},{link_id} linkLayer {layer} line {line} "
|
|
f"{link_label}"
|
|
)
|
|
|
|
def get_network_layer(self, network_id: int) -> str:
|
|
node = self.session.nodes.get(network_id)
|
|
if node:
|
|
layer = f"{LINK_LAYER}::{node.name}"
|
|
self.network_layers.add(layer)
|
|
else:
|
|
layer = WIRED_LINK_LAYER
|
|
return layer
|
|
|
|
def delete_link(self, node1_id: int, node2_id: int, network_id: int = None) -> None:
|
|
"""
|
|
Handle deleting a link in SDT.
|
|
|
|
:param node1_id: node one id
|
|
:param node2_id: node two id
|
|
:param network_id: network link is associated with, None otherwise
|
|
:return: nothing
|
|
"""
|
|
logger.debug("sdt delete link: %s, %s, %s", node1_id, node2_id, network_id)
|
|
if not self.connect():
|
|
return
|
|
if self.wireless_net_check(node1_id) or self.wireless_net_check(node2_id):
|
|
return
|
|
link_id = get_link_id(node1_id, node2_id, network_id)
|
|
self.cmd(f"delete link,{node1_id},{node2_id},{link_id}")
|
|
|
|
def edit_link(
|
|
self, node1_id: int, node2_id: int, network_id: int, label: str
|
|
) -> None:
|
|
"""
|
|
Handle editing a link in SDT.
|
|
|
|
:param node1_id: node one id
|
|
:param node2_id: node two id
|
|
:param network_id: network link is associated with, None otherwise
|
|
:param label: label to update
|
|
:return: nothing
|
|
"""
|
|
logger.debug("sdt edit link: %s, %s, %s", node1_id, node2_id, network_id)
|
|
if not self.connect():
|
|
return
|
|
if self.wireless_net_check(node1_id) or self.wireless_net_check(node2_id):
|
|
return
|
|
link_id = get_link_id(node1_id, node2_id, network_id)
|
|
link_label = f'linklabel on,"{label}"'
|
|
self.cmd(f"link {node1_id},{node2_id},{link_id} {link_label}")
|
|
|
|
def handle_link_update(self, link_data: LinkData) -> None:
|
|
"""
|
|
Handle link broadcast messages and push changes to SDT.
|
|
|
|
:param link_data: link data to handle
|
|
:return: nothing
|
|
"""
|
|
node1_id = link_data.node1_id
|
|
node2_id = link_data.node2_id
|
|
network_id = link_data.network_id
|
|
label = link_data.label
|
|
if link_data.message_type == MessageFlags.ADD:
|
|
self.add_link(node1_id, node2_id, network_id, label)
|
|
elif link_data.message_type == MessageFlags.DELETE:
|
|
self.delete_link(node1_id, node2_id, network_id)
|
|
elif link_data.message_type == MessageFlags.NONE and label:
|
|
self.edit_link(node1_id, node2_id, network_id, label)
|