core-extra/daemon/core/plugins/sdt.py

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, Dict, List, Optional, Set, Tuple, Type
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 IOError:
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 IOError:
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 IOError:
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)