Skip to content

Commit b3732fa

Browse files
committed
[ot-sim] Add new ot-sim user app
See README for details.
1 parent 90f8ef1 commit b3732fa

14 files changed

+1220
-2
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ Apps written to work with the latest version of
1111

1212
Below are relevant notes for each phenix app available in this repo.
1313

14+
### ot-sim
15+
16+
The `ot-sim` app aids in the generation of configuration files for
17+
[OT-sim](https://ot-sim.patsec.dev). The configuration options it provides can
18+
be found [here](src/python/phenix_apps/apps/otsim/README.md).
19+
1420
### protonuke
1521

1622
The `protonuke` app simply injects the `/etc/default/protonuke` file into

src/python/phenix_apps/apps/__init__.py

+47-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import copy, json, os, sys
1+
import copy, os, sys
22

33
from box import Box
44

@@ -97,6 +97,39 @@ def extract_node(self, hostname):
9797
if node.general.hostname == hostname:
9898
return node
9999

100+
def extract_annotated_topology_nodes(self, annotations):
101+
nodes = self.experiment.spec.topology.nodes
102+
hosts = []
103+
104+
if isinstance(annotations, str):
105+
annotations = [annotations]
106+
107+
for node in nodes:
108+
node_annotations = node.get('annotations', {})
109+
110+
# Could be a null entry in the JSON schema.
111+
if not node_annotations:
112+
continue
113+
114+
for annotation in node_annotations.keys():
115+
if annotation in annotations:
116+
hosts.append(node)
117+
break
118+
119+
return hosts
120+
121+
def extract_app_node(self, hostname):
122+
app = self.extract_app()
123+
124+
for host in app.get("hosts", []):
125+
if host.hostname == hostname:
126+
node = copy.deepcopy(host)
127+
node.update({'topology': self.extract_node(hostname)})
128+
129+
return node
130+
131+
return None
132+
100133
def extract_nodes_topology_type(self, types):
101134
hosts = []
102135

@@ -168,6 +201,9 @@ def extract_nodes_label(self, labels):
168201

169202
return hosts
170203

204+
def extract_labeled_nodes(self, labels):
205+
return self.extract_nodes_label(labels)
206+
171207
def extract_experiment_name(self):
172208
return self.experiment.spec.experimentName
173209

@@ -182,7 +218,16 @@ def extract_asset_dir(self):
182218
def extract_metadata(self):
183219
app = self.extract_app()
184220

185-
return app.get('metadata', None)
221+
return app.get('metadata', {})
222+
223+
def extract_node_metadata(self, hostname):
224+
app = self.extract_app()
225+
226+
for host in app.get("hosts", []):
227+
if host.hostname == hostname:
228+
return host.metadata
229+
230+
return {}
186231

187232
def add_node(self, node):
188233
self.experiment.spec.topology.nodes.append(node)
+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# OT-sim App
2+
3+
This app, named `ot-sim` aids in the generation of config files for
4+
[OT-sim](https://ot-sim.patsec.dev). It currently supports generating
5+
configuration files for three different types of devices: a field device
6+
(`fd-server`), a front-end processor (`fep`), and a client (`fd-client`). It
7+
also supports generating a start script for a HELICS broker if needed.
8+
9+
Below is an example of all the options available in the app, with documentation
10+
for each.
11+
12+
```
13+
spec:
14+
scenario:
15+
apps:
16+
- name: ot-sim
17+
metadata:
18+
infrastructure: power-distribution # this is the default
19+
helics:
20+
# The broker setting has the following options:
21+
# * address
22+
# * hostname
23+
# * base-fed-count
24+
# If both `hostname` and `address` are provided, `hostname`
25+
# takes precedence and the address for the broker will be pulled
26+
# from the topology. If `hostname` is provided it should include
27+
# the name of an interface in the topology to pull an IP from.
28+
# Otherwise, the first interface from the topology will be used.
29+
# If both `hostname` and `base-fed-count` are provided, an
30+
# inject will be generated and added to the topology for the
31+
# host to start the broker with the given number of federates
32+
# plus however many devices with I/O modules are configured. The
33+
# optional `log-level` and `log-file` options for the broker are
34+
# only used if a broker inject is created.
35+
broker:
36+
hostname: helics-broker|IF0
37+
base-fed-count: 2
38+
log-level: SUMMARY # this is the default
39+
log-file: /var/log/helics-broker.log # this is the default
40+
federate: OpenDSS # default federate to subscribe to; this is the default
41+
message-bus:
42+
pull-endpoint: tcp://127.0.0.1:1234 # this is the default
43+
pub-endpoint: tcp://127.0.0.1:5678 # this is the default
44+
cpu-module:
45+
api-endpoint: 0.0.0.0:9101 # this is the default; can be set to null to disable globally
46+
# can also be set per device via host metadata
47+
infrastructures:
48+
power-distribution:
49+
node:
50+
voltage:
51+
type: analog-read
52+
modbus: # Device type variables support per-protocol configurations. Right now,
53+
scaling: 2 # the Modbus protocol only looks for a single config: `scaling`. If not
54+
# provided, the scaling defaults to 0. The DNP3 protocol looks for four
55+
# configs: `svar`, `evar`, `class`, and `sbo`.
56+
bus:
57+
voltage:
58+
type: analog-read
59+
modbus:
60+
scaling: 2
61+
breaker:
62+
voltage: analog-read # will assume Modbus scaling of 0 if not specified
63+
current: analog-read
64+
freq: analog-read
65+
power: analog-read
66+
status: binary-read
67+
control: binary-read-write
68+
capacitor:
69+
voltage: analog-read
70+
current: analog-read
71+
freq: analog-read
72+
power: analog-read
73+
setpt: analog-read-write
74+
on_off_status: binary-read
75+
regulator:
76+
voltage: analog-read
77+
current: analog-read
78+
freq: analog-read
79+
power: analog-read
80+
setpt: analog-read-write
81+
on_off_status: binary-read
82+
load:
83+
voltage: analog-read
84+
current: analog-read
85+
active_power: analog-read
86+
reactive_power: analog-read
87+
line:
88+
from_voltage: analog-read
89+
from_current: analog-read
90+
from_active_power: analog-read
91+
from_reactive_power: analog-read
92+
to_voltage: analog-read
93+
to_current: analog-read
94+
to_active_power: analog-read
95+
to_reactive_power: analog-read
96+
transformer:
97+
from_voltage: analog-read
98+
from_current: analog-read
99+
from_active_power: analog-read
100+
from_reactive_power: analog-read
101+
to_voltage: analog-read
102+
to_current: analog-read
103+
to_active_power: analog-read
104+
to_reactive_power: analog-read
105+
hosts:
106+
- hostname: outstation
107+
metadata:
108+
type: fd-server
109+
infrastructure: power-distribution # will default to infrastructure in app metadata if not provided
110+
111+
# The `message-bus`, `helics`, and `cpu-module` keys available
112+
# in the app metadata can be overridden on a per host basis here.
113+
# The only difference is for the `helics.federate` setting,
114+
# which when defined in the host metadata specifies the name to
115+
# use for the federate the device will be providing.
116+
117+
helics:
118+
# Defaults to helics.broker in app metadata if not provided.
119+
# If provided for a host, and both `hostname` and
120+
# `base-fed-count` are provided, a separate inject is tracked
121+
# and generated for the given host in the topology file. Similar
122+
# to the app metadata, `log-level` and `log-file` can also be
123+
# specified.
124+
broker:
125+
address: helics-broker.other.network.test
126+
federate:
127+
name: outstation-fed # name to use for this federate; defaults to hostname if not provided
128+
log-level: SUMMARY # log level to pass to I/O module's init string; this is the default
129+
130+
# The `modbus` and `dnp3` sections can take two forms. The first
131+
# is an array of device name/type configs, in which case the app
132+
# will assume the host running the device should listen on the
133+
# first interface defined in the topology file for the protocol.
134+
# The second is a map containing the `interface` key and the
135+
# `devices` key. In this form, the `devices` value will be an
136+
# array of name/type configs like before and the `interface` value
137+
# will specify the interface the device should listen on for the
138+
# protocol. The interface can be specified as an actual IP:port
139+
# to listen on or it can be an interface name:port to listen on,
140+
# in which case the IP is pulled from the topology file. For
141+
# either, if no port is specified, the default port for the
142+
# protocol will be used.
143+
144+
modbus:
145+
# Will default to listening on port 502 of the IP configured for
146+
# the first interface defined in the topology file.
147+
- name: OpenDSS/bus-01 # name of topic key from another federate to subscribe to
148+
# defaults to helics.federate in app metadata if 'OpenDSS/' (federate name) is left off
149+
type: bus # type of device for topic key -- this determines values to publish and subscribe to based on infrastructure
150+
dnp3:
151+
interface: IF1:20000
152+
devices:
153+
- name: line-01-03
154+
type: branch
155+
```

src/python/phenix_apps/apps/otsim/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import xml.dom.minidom as minidom
2+
import xml.etree.ElementTree as ET
3+
4+
5+
class Config:
6+
def __init__(self, md):
7+
if 'message-bus' in md:
8+
self.default_pull = md['message-bus'].get('pull-endpoint', 'tcp://127.0.0.1:1234')
9+
self.default_pub = md['message-bus'].get('pub-endpoint', 'tcp://127.0.0.1:5678')
10+
else:
11+
self.default_pull = 'tcp://127.0.0.1:1234'
12+
self.default_pub = 'tcp://127.0.0.1:5678'
13+
14+
if 'cpu-module' in md:
15+
self.default_api = md['cpu-module'].get('api-endpoint', '0.0.0.0:9101')
16+
else:
17+
self.default_api = '0.0.0.0:9101'
18+
19+
20+
def init_xml_root(self, md):
21+
self.root = ET.Element('ot-sim')
22+
23+
msgbus = ET.SubElement(self.root, 'message-bus')
24+
pull = ET.SubElement(msgbus, 'pull-endpoint')
25+
pub = ET.SubElement(msgbus, 'pub-endpoint')
26+
27+
if 'message-bus' in md:
28+
pull.text = md['message-bus'].get('pull-endpoint', self.default_pull)
29+
pub.text = md['message-bus'].get('pub-endpoint', self.default_pub)
30+
else:
31+
pull.text = self.default_pull
32+
pub.text = self.default_pub
33+
34+
self.cpu = ET.SubElement(self.root, 'cpu')
35+
36+
if 'cpu-module' in md:
37+
api = ET.SubElement(self.cpu, 'api-endpoint')
38+
api.text = md['cpu-module'].get('api-endpoint', '0.0.0.0:9101')
39+
elif self.default_api:
40+
api = ET.SubElement(self.cpu, 'api-endpoint')
41+
api.text = self.default_api
42+
43+
backplane = ET.SubElement(self.cpu, 'module', {'name': 'backplane'})
44+
backplane.text = 'ot-sim-message-bus {{config_file}}'
45+
46+
47+
def append_to_root(self, child):
48+
self.root.append(child)
49+
50+
51+
def append_to_cpu(self, child):
52+
self.cpu.append(child)
53+
54+
55+
def to_file(self, path):
56+
with open(path, 'w') as f:
57+
f.write(minidom.parseString(ET.tostring(self.root)).toprettyxml())

0 commit comments

Comments
 (0)