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.
Files changed (30) hide show
  1. {scadable_cli-0.2.0 → scadable_cli-0.3.0}/PKG-INFO +1 -1
  2. {scadable_cli-0.2.0 → scadable_cli-0.3.0}/pyproject.toml +1 -1
  3. scadable_cli-0.3.0/scadable/__init__.py +30 -0
  4. scadable_cli-0.3.0/scadable/actions.py +49 -0
  5. scadable_cli-0.3.0/scadable/cli.py +409 -0
  6. scadable_cli-0.3.0/scadable/connections.py +98 -0
  7. scadable_cli-0.3.0/scadable/constants.py +31 -0
  8. scadable_cli-0.3.0/scadable/controller.py +66 -0
  9. scadable_cli-0.3.0/scadable/device.py +50 -0
  10. scadable_cli-0.3.0/scadable/fields.py +129 -0
  11. scadable_cli-0.3.0/scadable/outbound.py +86 -0
  12. scadable_cli-0.3.0/scadable/schedule.py +11 -0
  13. scadable_cli-0.3.0/scadable/storage.py +82 -0
  14. scadable_cli-0.3.0/scadable/system.py +21 -0
  15. {scadable_cli-0.2.0 → scadable_cli-0.3.0}/scadable_cli.egg-info/PKG-INFO +1 -1
  16. scadable_cli-0.3.0/scadable_cli.egg-info/SOURCES.txt +19 -0
  17. scadable_cli-0.2.0/scadable/__init__.py +0 -0
  18. scadable_cli-0.2.0/scadable/edge/__init__.py +0 -17
  19. scadable_cli-0.2.0/scadable/edge/cli.py +0 -262
  20. scadable_cli-0.2.0/scadable/edge/constants.py +0 -13
  21. scadable_cli-0.2.0/scadable/edge/device.py +0 -63
  22. scadable_cli-0.2.0/scadable/edge/protocols/__init__.py +0 -0
  23. scadable_cli-0.2.0/scadable/edge/protocols/base.py +0 -10
  24. scadable_cli-0.2.0/scadable/edge/protocols/modbus.py +0 -36
  25. scadable_cli-0.2.0/scadable_cli.egg-info/SOURCES.txt +0 -15
  26. {scadable_cli-0.2.0 → scadable_cli-0.3.0}/README.md +0 -0
  27. {scadable_cli-0.2.0 → scadable_cli-0.3.0}/scadable_cli.egg-info/dependency_links.txt +0 -0
  28. {scadable_cli-0.2.0 → scadable_cli-0.3.0}/scadable_cli.egg-info/entry_points.txt +0 -0
  29. {scadable_cli-0.2.0 → scadable_cli-0.3.0}/scadable_cli.egg-info/top_level.txt +0 -0
  30. {scadable_cli-0.2.0 → scadable_cli-0.3.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scadable-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Edge SDK for the Scadable IoT platform
5
5
  License: MIT
6
6
  Keywords: iot,scada,edge,modbus,industrial
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "scadable-cli"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Edge SDK for the Scadable IoT platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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"