scadable-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {scadable_cli-0.2.0 → scadable_cli-0.3.0}/PKG-INFO +1 -1
- {scadable_cli-0.2.0 → scadable_cli-0.3.0}/pyproject.toml +1 -1
- scadable_cli-0.3.0/scadable/__init__.py +30 -0
- scadable_cli-0.3.0/scadable/actions.py +49 -0
- scadable_cli-0.3.0/scadable/cli.py +409 -0
- scadable_cli-0.3.0/scadable/connections.py +98 -0
- scadable_cli-0.3.0/scadable/constants.py +31 -0
- scadable_cli-0.3.0/scadable/controller.py +66 -0
- scadable_cli-0.3.0/scadable/device.py +50 -0
- scadable_cli-0.3.0/scadable/fields.py +129 -0
- scadable_cli-0.3.0/scadable/outbound.py +86 -0
- scadable_cli-0.3.0/scadable/schedule.py +11 -0
- scadable_cli-0.3.0/scadable/storage.py +82 -0
- scadable_cli-0.3.0/scadable/system.py +21 -0
- {scadable_cli-0.2.0 → scadable_cli-0.3.0}/scadable_cli.egg-info/PKG-INFO +1 -1
- scadable_cli-0.3.0/scadable_cli.egg-info/SOURCES.txt +19 -0
- scadable_cli-0.2.0/scadable/__init__.py +0 -0
- scadable_cli-0.2.0/scadable/edge/__init__.py +0 -17
- scadable_cli-0.2.0/scadable/edge/cli.py +0 -262
- scadable_cli-0.2.0/scadable/edge/constants.py +0 -13
- scadable_cli-0.2.0/scadable/edge/device.py +0 -63
- scadable_cli-0.2.0/scadable/edge/protocols/__init__.py +0 -0
- scadable_cli-0.2.0/scadable/edge/protocols/base.py +0 -10
- scadable_cli-0.2.0/scadable/edge/protocols/modbus.py +0 -36
- scadable_cli-0.2.0/scadable_cli.egg-info/SOURCES.txt +0 -15
- {scadable_cli-0.2.0 → scadable_cli-0.3.0}/README.md +0 -0
- {scadable_cli-0.2.0 → scadable_cli-0.3.0}/scadable_cli.egg-info/dependency_links.txt +0 -0
- {scadable_cli-0.2.0 → scadable_cli-0.3.0}/scadable_cli.egg-info/entry_points.txt +0 -0
- {scadable_cli-0.2.0 → scadable_cli-0.3.0}/scadable_cli.egg-info/top_level.txt +0 -0
- {scadable_cli-0.2.0 → scadable_cli-0.3.0}/setup.cfg +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scadable Edge SDK
|
|
3
|
+
|
|
4
|
+
Write simple Python classes. The SDK handles everything else.
|
|
5
|
+
|
|
6
|
+
from scadable import Device, modbus_tcp, every, Register, SECONDS
|
|
7
|
+
|
|
8
|
+
class TempSensor(Device):
|
|
9
|
+
id = "temp-sensor"
|
|
10
|
+
connection = modbus_tcp(host="192.168.1.100")
|
|
11
|
+
poll = every(5, SECONDS)
|
|
12
|
+
registers = [Register(40001, "temperature", scale=0.1)]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .device import Device
|
|
16
|
+
from .controller import Controller
|
|
17
|
+
from .storage import FileStorage, SQLiteStorage
|
|
18
|
+
from .outbound import MQTTOutbound, S3Outbound
|
|
19
|
+
from .connections import modbus_tcp, modbus_rtu, opcua, serial_uart, ble
|
|
20
|
+
from .fields import Register, Field, Node, Characteristic
|
|
21
|
+
from .schedule import every
|
|
22
|
+
from .actions import route, actuate, alert, now
|
|
23
|
+
from . import system
|
|
24
|
+
from .constants import (
|
|
25
|
+
SECONDS, MINUTES, HOURS,
|
|
26
|
+
MB_64, MB_128, MB_256, MB_512, GB_1, GB_2, GB_5,
|
|
27
|
+
INT16, UINT16, INT32, UINT32, FLOAT32, FLOAT64,
|
|
28
|
+
FLOAT, UINT8, INT8,
|
|
29
|
+
SECURITY_NONE, SECURITY_BASIC256, SECURITY_BASIC128,
|
|
30
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime actions for controllers.
|
|
3
|
+
|
|
4
|
+
These are stubs. The edge-main runtime provides the real implementations
|
|
5
|
+
when controllers execute on the gateway.
|
|
6
|
+
"""
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def route(outbound_id, data, metadata=None):
|
|
11
|
+
"""
|
|
12
|
+
Send data to an outbound destination.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
outbound_id: The id of the outbound (e.g. "sensor-data").
|
|
16
|
+
data: Dict of values, or bytes for file uploads.
|
|
17
|
+
metadata: Optional dict of metadata (for S3 uploads).
|
|
18
|
+
"""
|
|
19
|
+
raise NotImplementedError("route() is executed by the edge-main runtime")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def actuate(device, action=None, register=None, value=None):
|
|
23
|
+
"""
|
|
24
|
+
Send a command to a device.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
device: Device class or device id string.
|
|
28
|
+
action: Action name (e.g. "capture", "reset").
|
|
29
|
+
register: Modbus register address (for direct writes).
|
|
30
|
+
value: Value to write.
|
|
31
|
+
"""
|
|
32
|
+
raise NotImplementedError("actuate() is executed by the edge-main runtime")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def alert(level, message, devices=None):
|
|
36
|
+
"""
|
|
37
|
+
Send an alert.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
level: "info", "warning", or "critical".
|
|
41
|
+
message: Alert message string.
|
|
42
|
+
devices: Optional list of device ids to associate with the alert.
|
|
43
|
+
"""
|
|
44
|
+
raise NotImplementedError("alert() is executed by the edge-main runtime")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def now():
|
|
48
|
+
"""Current unix timestamp in seconds."""
|
|
49
|
+
return int(time.time())
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scadable Edge SDK CLI
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
scadable init [name]
|
|
6
|
+
scadable add device modbus-tcp|modbus-rtu|opcua|serial <name>
|
|
7
|
+
scadable add controller <name>
|
|
8
|
+
scadable add storage file|sqlite <name>
|
|
9
|
+
scadable add outbound mqtt|s3 <name>
|
|
10
|
+
scadable list [devices|controllers|storage|outbound]
|
|
11
|
+
scadable validate
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Helpers
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def to_class_name(name: str) -> str:
|
|
26
|
+
"""Convert kebab-case name to PascalCase class name.
|
|
27
|
+
|
|
28
|
+
Examples:
|
|
29
|
+
temp-sensor -> TempSensor
|
|
30
|
+
my_device -> MyDevice
|
|
31
|
+
simple -> Simple
|
|
32
|
+
"""
|
|
33
|
+
return "".join(word.capitalize() for word in re.split(r"[-_]", name))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ensure_project() -> dict:
|
|
37
|
+
"""Load scadable.json from the current directory or exit."""
|
|
38
|
+
if not os.path.exists("scadable.json"):
|
|
39
|
+
print("Error: scadable.json not found. Run 'scadable init' first.", file=sys.stderr)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
with open("scadable.json") as f:
|
|
42
|
+
return json.load(f)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write_file(directory: str, name: str, content: str) -> str:
|
|
46
|
+
"""Write a component file into *directory*/<name>.py and return the path."""
|
|
47
|
+
os.makedirs(directory, exist_ok=True)
|
|
48
|
+
path = os.path.join(directory, f"{name}.py")
|
|
49
|
+
if os.path.exists(path):
|
|
50
|
+
print(f"Error: {path} already exists.", file=sys.stderr)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
with open(path, "w") as f:
|
|
53
|
+
f.write(content)
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Templates
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
TEMPLATES = {
|
|
62
|
+
"device": {
|
|
63
|
+
"modbus-tcp": """\
|
|
64
|
+
from scadable import Device, modbus_tcp, every, Register, SECONDS
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class {class_name}(Device):
|
|
68
|
+
id = "{name}"
|
|
69
|
+
connection = modbus_tcp(host="${{DEVICE_HOST}}", port=502, slave=1)
|
|
70
|
+
poll = every(5, SECONDS)
|
|
71
|
+
registers = [
|
|
72
|
+
Register(40001, "value_1", scale=0.1),
|
|
73
|
+
Register(40002, "value_2", scale=0.01),
|
|
74
|
+
]
|
|
75
|
+
""",
|
|
76
|
+
"modbus-rtu": """\
|
|
77
|
+
from scadable import Device, modbus_rtu, every, Register, SECONDS
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class {class_name}(Device):
|
|
81
|
+
id = "{name}"
|
|
82
|
+
connection = modbus_rtu(port="/dev/ttyUSB0", baud=9600, slave=1)
|
|
83
|
+
poll = every(10, SECONDS)
|
|
84
|
+
registers = [
|
|
85
|
+
Register(30001, "value_1", scale=0.1),
|
|
86
|
+
]
|
|
87
|
+
""",
|
|
88
|
+
"opcua": """\
|
|
89
|
+
from scadable import Device, opcua, every, Node, SECONDS
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class {class_name}(Device):
|
|
93
|
+
id = "{name}"
|
|
94
|
+
connection = opcua(
|
|
95
|
+
host="${{OPCUA_HOST}}",
|
|
96
|
+
port=4840,
|
|
97
|
+
nodes=[
|
|
98
|
+
Node("value_1", namespace=2, path="Channel1/Device1/Value1"),
|
|
99
|
+
Node("value_2", namespace=2, path="Channel1/Device1/Value2"),
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
poll = every(5, SECONDS)
|
|
103
|
+
""",
|
|
104
|
+
"serial": """\
|
|
105
|
+
from scadable import Device, serial_uart, every, Field, SECONDS, UINT16, FLOAT32
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class {class_name}(Device):
|
|
109
|
+
id = "{name}"
|
|
110
|
+
connection = serial_uart(port="/dev/ttyUSB0", baud=115200)
|
|
111
|
+
poll = every(1, SECONDS)
|
|
112
|
+
fields = [
|
|
113
|
+
Field("value_1", start=0, length=4, type=FLOAT32, scale=0.01),
|
|
114
|
+
Field("value_2", start=4, length=2, type=UINT16),
|
|
115
|
+
]
|
|
116
|
+
""",
|
|
117
|
+
"ble": """\
|
|
118
|
+
from scadable import Device, ble, every, Characteristic, SECONDS
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class {class_name}(Device):
|
|
122
|
+
id = "{name}"
|
|
123
|
+
connection = ble(
|
|
124
|
+
mac="${{DEVICE_MAC}}",
|
|
125
|
+
service="0x180D", # Heart Rate Service
|
|
126
|
+
characteristics=[
|
|
127
|
+
Characteristic("heart_rate", uuid="0x2A37"),
|
|
128
|
+
Characteristic("body_sensor_location", uuid="0x2A38"),
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
poll = every(1, SECONDS)
|
|
132
|
+
""",
|
|
133
|
+
},
|
|
134
|
+
"controller": {
|
|
135
|
+
"_default": """\
|
|
136
|
+
from scadable import Controller, every, route, alert, SECONDS
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class {class_name}(Controller):
|
|
140
|
+
id = "{name}"
|
|
141
|
+
run = every(5, SECONDS)
|
|
142
|
+
uses = [] # add Device classes here
|
|
143
|
+
|
|
144
|
+
def execute(self, data):
|
|
145
|
+
# Access device data: data.DeviceName.field_name
|
|
146
|
+
# Send to outbound: route("outbound-id", {{"key": value}})
|
|
147
|
+
# Send alert: alert("warning", "message")
|
|
148
|
+
pass
|
|
149
|
+
""",
|
|
150
|
+
},
|
|
151
|
+
"storage": {
|
|
152
|
+
"file": """\
|
|
153
|
+
from scadable import FileStorage, GB_1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class {class_name}(FileStorage):
|
|
157
|
+
id = "{name}"
|
|
158
|
+
path = "/var/data/{name}"
|
|
159
|
+
max_size = GB_1
|
|
160
|
+
warn_at = 80
|
|
161
|
+
""",
|
|
162
|
+
"sqlite": """\
|
|
163
|
+
from scadable import SQLiteStorage, MB_256
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class {class_name}(SQLiteStorage):
|
|
167
|
+
id = "{name}"
|
|
168
|
+
path = "/var/data/{name}.db"
|
|
169
|
+
max_size = MB_256
|
|
170
|
+
warn_at = 80
|
|
171
|
+
""",
|
|
172
|
+
},
|
|
173
|
+
"outbound": {
|
|
174
|
+
"mqtt": """\
|
|
175
|
+
from scadable import MQTTOutbound
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class {class_name}(MQTTOutbound):
|
|
179
|
+
id = "{name}"
|
|
180
|
+
devices = [] # all devices
|
|
181
|
+
""",
|
|
182
|
+
"s3": """\
|
|
183
|
+
from scadable import S3Outbound
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class {class_name}(S3Outbound):
|
|
187
|
+
id = "{name}"
|
|
188
|
+
devices = []
|
|
189
|
+
storage = None # set to a Storage class
|
|
190
|
+
prefix = ""
|
|
191
|
+
max_age = ""
|
|
192
|
+
""",
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# Map of resource type -> (directory, base class names for validation)
|
|
197
|
+
RESOURCE_TYPES = {
|
|
198
|
+
"devices": ("devices", ["Device"]),
|
|
199
|
+
"controllers": ("controllers", ["Controller"]),
|
|
200
|
+
"storage": ("storage", ["FileStorage", "SQLiteStorage"]),
|
|
201
|
+
"outbound": ("outbound", ["MQTTOutbound", "S3Outbound"]),
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Commands
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def cmd_init(args):
|
|
210
|
+
"""Initialise a new Scadable project."""
|
|
211
|
+
name = args.name or os.path.basename(os.getcwd())
|
|
212
|
+
|
|
213
|
+
if os.path.exists("scadable.json"):
|
|
214
|
+
print("Error: scadable.json already exists in this directory.", file=sys.stderr)
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
config = {
|
|
218
|
+
"name": name,
|
|
219
|
+
"version": "1.0.0",
|
|
220
|
+
"sdk": "scadable-edge-sdk",
|
|
221
|
+
}
|
|
222
|
+
with open("scadable.json", "w") as f:
|
|
223
|
+
json.dump(config, f, indent=2)
|
|
224
|
+
f.write("\n")
|
|
225
|
+
|
|
226
|
+
for d in ("devices", "controllers", "storage", "outbound"):
|
|
227
|
+
os.makedirs(d, exist_ok=True)
|
|
228
|
+
|
|
229
|
+
print(f"Initialised project '{name}'")
|
|
230
|
+
print(" created scadable.json")
|
|
231
|
+
print(" created devices/")
|
|
232
|
+
print(" created controllers/")
|
|
233
|
+
print(" created storage/")
|
|
234
|
+
print(" created outbound/")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def cmd_add(args):
|
|
238
|
+
"""Add a component to the project."""
|
|
239
|
+
_ensure_project()
|
|
240
|
+
|
|
241
|
+
resource = args.resource
|
|
242
|
+
name = args.name
|
|
243
|
+
|
|
244
|
+
if resource == "device":
|
|
245
|
+
subtype = args.subtype
|
|
246
|
+
if subtype not in TEMPLATES["device"]:
|
|
247
|
+
print(f"Error: unknown device type '{subtype}'. "
|
|
248
|
+
f"Choose from: {', '.join(TEMPLATES['device'].keys())}", file=sys.stderr)
|
|
249
|
+
sys.exit(1)
|
|
250
|
+
template = TEMPLATES["device"][subtype]
|
|
251
|
+
directory = "devices"
|
|
252
|
+
elif resource == "controller":
|
|
253
|
+
template = TEMPLATES["controller"]["_default"]
|
|
254
|
+
directory = "controllers"
|
|
255
|
+
elif resource == "storage":
|
|
256
|
+
subtype = args.subtype
|
|
257
|
+
if subtype not in TEMPLATES["storage"]:
|
|
258
|
+
print(f"Error: unknown storage type '{subtype}'. "
|
|
259
|
+
f"Choose from: {', '.join(TEMPLATES['storage'].keys())}", file=sys.stderr)
|
|
260
|
+
sys.exit(1)
|
|
261
|
+
template = TEMPLATES["storage"][subtype]
|
|
262
|
+
directory = "storage"
|
|
263
|
+
elif resource == "outbound":
|
|
264
|
+
subtype = args.subtype
|
|
265
|
+
if subtype not in TEMPLATES["outbound"]:
|
|
266
|
+
print(f"Error: unknown outbound type '{subtype}'. "
|
|
267
|
+
f"Choose from: {', '.join(TEMPLATES['outbound'].keys())}", file=sys.stderr)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
template = TEMPLATES["outbound"][subtype]
|
|
270
|
+
directory = "outbound"
|
|
271
|
+
else:
|
|
272
|
+
print(f"Error: unknown resource type '{resource}'.", file=sys.stderr)
|
|
273
|
+
sys.exit(1)
|
|
274
|
+
|
|
275
|
+
class_name = to_class_name(name)
|
|
276
|
+
content = template.format(class_name=class_name, name=name)
|
|
277
|
+
path = _write_file(directory, name, content)
|
|
278
|
+
print(f"Created {path} ({class_name})")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cmd_list(args):
|
|
282
|
+
"""List project components."""
|
|
283
|
+
_ensure_project()
|
|
284
|
+
|
|
285
|
+
if args.type:
|
|
286
|
+
types_to_show = [args.type]
|
|
287
|
+
else:
|
|
288
|
+
types_to_show = list(RESOURCE_TYPES.keys())
|
|
289
|
+
|
|
290
|
+
for rtype in types_to_show:
|
|
291
|
+
if rtype not in RESOURCE_TYPES:
|
|
292
|
+
print(f"Error: unknown type '{rtype}'. "
|
|
293
|
+
f"Choose from: {', '.join(RESOURCE_TYPES.keys())}", file=sys.stderr)
|
|
294
|
+
sys.exit(1)
|
|
295
|
+
directory, _ = RESOURCE_TYPES[rtype]
|
|
296
|
+
files = sorted(f for f in os.listdir(directory) if f.endswith(".py")) if os.path.isdir(directory) else []
|
|
297
|
+
print(f"{rtype}/ ({len(files)})")
|
|
298
|
+
for f in files:
|
|
299
|
+
print(f" {f}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cmd_validate(args):
|
|
303
|
+
"""Validate all project components."""
|
|
304
|
+
_ensure_project()
|
|
305
|
+
|
|
306
|
+
errors = 0
|
|
307
|
+
checked = 0
|
|
308
|
+
|
|
309
|
+
for rtype, (directory, base_classes) in RESOURCE_TYPES.items():
|
|
310
|
+
if not os.path.isdir(directory):
|
|
311
|
+
continue
|
|
312
|
+
files = sorted(f for f in os.listdir(directory) if f.endswith(".py"))
|
|
313
|
+
for filename in files:
|
|
314
|
+
filepath = os.path.join(directory, filename)
|
|
315
|
+
checked += 1
|
|
316
|
+
try:
|
|
317
|
+
with open(filepath) as f:
|
|
318
|
+
source = f.read()
|
|
319
|
+
# Check that the file contains a class inheriting from one of the expected base classes
|
|
320
|
+
pattern = r"class\s+\w+\((" + "|".join(base_classes) + r")\)"
|
|
321
|
+
if re.search(pattern, source):
|
|
322
|
+
print(f" ok {filepath}")
|
|
323
|
+
else:
|
|
324
|
+
print(f" ERROR {filepath} - no subclass of {'/'.join(base_classes)} found")
|
|
325
|
+
errors += 1
|
|
326
|
+
except Exception as e:
|
|
327
|
+
print(f" ERROR {filepath} - {e}")
|
|
328
|
+
errors += 1
|
|
329
|
+
|
|
330
|
+
if checked == 0:
|
|
331
|
+
print("No component files found.")
|
|
332
|
+
else:
|
|
333
|
+
print(f"\nChecked {checked} file(s), {errors} error(s).")
|
|
334
|
+
|
|
335
|
+
if errors:
|
|
336
|
+
sys.exit(1)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Argument parser
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
344
|
+
parser = argparse.ArgumentParser(
|
|
345
|
+
prog="scadable",
|
|
346
|
+
description="Scadable Edge SDK CLI",
|
|
347
|
+
)
|
|
348
|
+
sub = parser.add_subparsers(dest="command")
|
|
349
|
+
|
|
350
|
+
# init
|
|
351
|
+
p_init = sub.add_parser("init", help="Initialise a new project")
|
|
352
|
+
p_init.add_argument("name", nargs="?", default=None, help="Project name (defaults to directory name)")
|
|
353
|
+
|
|
354
|
+
# add
|
|
355
|
+
p_add = sub.add_parser("add", help="Add a component")
|
|
356
|
+
p_add.add_argument("resource", choices=["device", "controller", "storage", "outbound"])
|
|
357
|
+
p_add.add_argument("subtype", nargs="?", default=None,
|
|
358
|
+
help="Sub-type (e.g. modbus-tcp, file, mqtt). Required for device/storage/outbound.")
|
|
359
|
+
p_add.add_argument("name", nargs="?", default=None, help="Component name")
|
|
360
|
+
|
|
361
|
+
# list
|
|
362
|
+
p_list = sub.add_parser("list", help="List components")
|
|
363
|
+
p_list.add_argument("type", nargs="?", default=None,
|
|
364
|
+
choices=list(RESOURCE_TYPES.keys()),
|
|
365
|
+
help="Filter by type")
|
|
366
|
+
|
|
367
|
+
# validate
|
|
368
|
+
sub.add_parser("validate", help="Validate all components")
|
|
369
|
+
|
|
370
|
+
return parser
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main(argv=None):
|
|
374
|
+
parser = build_parser()
|
|
375
|
+
args = parser.parse_args(argv)
|
|
376
|
+
|
|
377
|
+
if args.command is None:
|
|
378
|
+
parser.print_help()
|
|
379
|
+
sys.exit(0)
|
|
380
|
+
|
|
381
|
+
if args.command == "init":
|
|
382
|
+
cmd_init(args)
|
|
383
|
+
elif args.command == "add":
|
|
384
|
+
# For resources that need a subtype, the positional args shift:
|
|
385
|
+
# "add device modbus-tcp temp-sensor" -> resource=device, subtype=modbus-tcp, name=temp-sensor
|
|
386
|
+
# "add controller monitor" -> resource=controller, subtype=monitor, name=None
|
|
387
|
+
# For controller, subtype is actually the name (no subtype needed).
|
|
388
|
+
if args.resource == "controller":
|
|
389
|
+
# subtype holds the name, name is None
|
|
390
|
+
if args.subtype is None:
|
|
391
|
+
print("Error: name is required. Usage: scadable add controller <name>", file=sys.stderr)
|
|
392
|
+
sys.exit(1)
|
|
393
|
+
args.name = args.subtype
|
|
394
|
+
args.subtype = None
|
|
395
|
+
else:
|
|
396
|
+
# device, storage, outbound all require subtype + name
|
|
397
|
+
if args.subtype is None or args.name is None:
|
|
398
|
+
print(f"Error: subtype and name are required. "
|
|
399
|
+
f"Usage: scadable add {args.resource} <type> <name>", file=sys.stderr)
|
|
400
|
+
sys.exit(1)
|
|
401
|
+
cmd_add(args)
|
|
402
|
+
elif args.command == "list":
|
|
403
|
+
cmd_list(args)
|
|
404
|
+
elif args.command == "validate":
|
|
405
|
+
cmd_validate(args)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Connection helpers. Each returns a simple config dict.
|
|
3
|
+
No dataclasses, no boilerplate.
|
|
4
|
+
|
|
5
|
+
Docs: https://docs.scadable.com/docs/edge/protocols
|
|
6
|
+
"""
|
|
7
|
+
from .constants import SECURITY_NONE
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def modbus_tcp(host, port=502, slave=1, timeout=5.0, retries=3):
|
|
11
|
+
"""Modbus TCP connection to a PLC, power meter, VFD, etc."""
|
|
12
|
+
return {
|
|
13
|
+
"type": "modbus-tcp",
|
|
14
|
+
"host": host,
|
|
15
|
+
"port": port,
|
|
16
|
+
"slave": slave,
|
|
17
|
+
"timeout": timeout,
|
|
18
|
+
"retries": retries,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def modbus_rtu(port, baud=9600, slave=1, parity="N", stopbits=1, bytesize=8, timeout=5.0):
|
|
23
|
+
"""Modbus RTU connection over RS-485 serial."""
|
|
24
|
+
return {
|
|
25
|
+
"type": "modbus-rtu",
|
|
26
|
+
"port": port,
|
|
27
|
+
"baud": baud,
|
|
28
|
+
"slave": slave,
|
|
29
|
+
"parity": parity,
|
|
30
|
+
"stopbits": stopbits,
|
|
31
|
+
"bytesize": bytesize,
|
|
32
|
+
"timeout": timeout,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def opcua(host, port=4840, nodes=None, security=SECURITY_NONE, username="", password=""):
|
|
37
|
+
"""OPC-UA connection to Ignition, Kepware, Siemens, etc.
|
|
38
|
+
|
|
39
|
+
Nodes can be Node objects or legacy tuples:
|
|
40
|
+
nodes=[Node("temp", namespace=2, path="Tank/Temp")]
|
|
41
|
+
nodes=[("temp", "ns=2;s=Tank/Temp")] # backward compatible
|
|
42
|
+
"""
|
|
43
|
+
resolved_nodes = []
|
|
44
|
+
for n in (nodes or []):
|
|
45
|
+
if isinstance(n, tuple):
|
|
46
|
+
resolved_nodes.append(n)
|
|
47
|
+
elif hasattr(n, "node_id"):
|
|
48
|
+
resolved_nodes.append((n.name, n.node_id))
|
|
49
|
+
else:
|
|
50
|
+
resolved_nodes.append(n)
|
|
51
|
+
return {
|
|
52
|
+
"type": "opcua",
|
|
53
|
+
"host": host,
|
|
54
|
+
"port": port,
|
|
55
|
+
"nodes": resolved_nodes,
|
|
56
|
+
"security": security,
|
|
57
|
+
"username": username,
|
|
58
|
+
"password": password,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def serial_uart(port, baud=115200, parity="N", stopbits=1, bytesize=8, timeout=5.0):
|
|
63
|
+
"""Serial/UART connection to ESP32, Arduino, custom devices."""
|
|
64
|
+
return {
|
|
65
|
+
"type": "serial",
|
|
66
|
+
"port": port,
|
|
67
|
+
"baud": baud,
|
|
68
|
+
"parity": parity,
|
|
69
|
+
"stopbits": stopbits,
|
|
70
|
+
"bytesize": bytesize,
|
|
71
|
+
"timeout": timeout,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ble(mac=None, service=None, characteristics=None, scan_timeout=10.0):
|
|
76
|
+
"""BLE connection to a medical wearable, sensor, or embedded device.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
mac: Bluetooth MAC address (e.g. "AA:BB:CC:DD:EE:FF"). If None, scans by service UUID.
|
|
80
|
+
service: BLE service UUID to filter by (e.g. "0x1822" for Pulse Oximeter Service).
|
|
81
|
+
characteristics: List of Characteristic objects to read.
|
|
82
|
+
scan_timeout: Seconds to scan before giving up (default 10).
|
|
83
|
+
"""
|
|
84
|
+
resolved = []
|
|
85
|
+
for c in (characteristics or []):
|
|
86
|
+
if isinstance(c, tuple):
|
|
87
|
+
resolved.append(c) # backward compat
|
|
88
|
+
elif hasattr(c, "uuid"):
|
|
89
|
+
resolved.append((c.name, c.uuid))
|
|
90
|
+
else:
|
|
91
|
+
resolved.append(c)
|
|
92
|
+
return {
|
|
93
|
+
"type": "ble",
|
|
94
|
+
"mac": mac or "",
|
|
95
|
+
"service": service or "",
|
|
96
|
+
"characteristics": resolved,
|
|
97
|
+
"scan_timeout": scan_timeout,
|
|
98
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Time units
|
|
2
|
+
SECONDS = "seconds"
|
|
3
|
+
MINUTES = "minutes"
|
|
4
|
+
HOURS = "hours"
|
|
5
|
+
|
|
6
|
+
# Storage sizes (bytes)
|
|
7
|
+
MB_64 = 64 * 1024 * 1024
|
|
8
|
+
MB_128 = 128 * 1024 * 1024
|
|
9
|
+
MB_256 = 256 * 1024 * 1024
|
|
10
|
+
MB_512 = 512 * 1024 * 1024
|
|
11
|
+
GB_1 = 1024 * 1024 * 1024
|
|
12
|
+
GB_2 = 2 * 1024 * 1024 * 1024
|
|
13
|
+
GB_5 = 5 * 1024 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
# Register types (Modbus)
|
|
16
|
+
INT16 = "int16"
|
|
17
|
+
UINT16 = "uint16"
|
|
18
|
+
INT32 = "int32"
|
|
19
|
+
UINT32 = "uint32"
|
|
20
|
+
FLOAT32 = "float32"
|
|
21
|
+
FLOAT64 = "float64"
|
|
22
|
+
|
|
23
|
+
# Field types (Serial/UART)
|
|
24
|
+
FLOAT = "float"
|
|
25
|
+
UINT8 = "uint8"
|
|
26
|
+
INT8 = "int8"
|
|
27
|
+
|
|
28
|
+
# OPC-UA security policies
|
|
29
|
+
SECURITY_NONE = "None"
|
|
30
|
+
SECURITY_BASIC256 = "Basic256Sha256"
|
|
31
|
+
SECURITY_BASIC128 = "Basic128Rsa15"
|