scadable-cli 0.1.1__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.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: scadable-cli
3
+ Version: 0.1.1
4
+ Summary: Edge SDK for the Scadable IoT platform
5
+ License: MIT
6
+ Keywords: iot,scada,edge,modbus,industrial
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Scadable Edge SDK
11
+
12
+ Python SDK for defining devices on the Scadable IoT platform.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install scadable-cli
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ Create a `config.py` for your device:
23
+
24
+ ```python
25
+ from scadable.edge import Device, ModbusConnection
26
+ from scadable.edge.constants import MODBUS_TCP, FIVE_SEC
27
+ from dataclasses import dataclass
28
+
29
+ @dataclass
30
+ class Connection(ModbusConnection):
31
+ host: str = "${DEVICE_HOST}"
32
+ port: int = 502
33
+ slave_id: int = 1
34
+
35
+ class MyPLC(Device):
36
+ id = "device-001"
37
+ protocol = MODBUS_TCP
38
+ connection = Connection
39
+ frequency = FIVE_SEC
40
+ ```
41
+
42
+ ## Project Structure
43
+
44
+ ```
45
+ gateways/
46
+ gateway-001/
47
+ device-001/
48
+ config.py
49
+ device-002/
50
+ config.py
51
+ ```
@@ -0,0 +1,42 @@
1
+ # Scadable Edge SDK
2
+
3
+ Python SDK for defining devices on the Scadable IoT platform.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install scadable-cli
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Create a `config.py` for your device:
14
+
15
+ ```python
16
+ from scadable.edge import Device, ModbusConnection
17
+ from scadable.edge.constants import MODBUS_TCP, FIVE_SEC
18
+ from dataclasses import dataclass
19
+
20
+ @dataclass
21
+ class Connection(ModbusConnection):
22
+ host: str = "${DEVICE_HOST}"
23
+ port: int = 502
24
+ slave_id: int = 1
25
+
26
+ class MyPLC(Device):
27
+ id = "device-001"
28
+ protocol = MODBUS_TCP
29
+ connection = Connection
30
+ frequency = FIVE_SEC
31
+ ```
32
+
33
+ ## Project Structure
34
+
35
+ ```
36
+ gateways/
37
+ gateway-001/
38
+ device-001/
39
+ config.py
40
+ device-002/
41
+ config.py
42
+ ```
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scadable-cli"
7
+ version = "0.1.1"
8
+ description = "Edge SDK for the Scadable IoT platform"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ keywords = ["iot", "scada", "edge", "modbus", "industrial"]
13
+ dependencies = []
14
+
15
+ [project.scripts]
16
+ scadable = "scadable.edge.cli:main"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["."]
20
+ include = ["scadable*"]
File without changes
@@ -0,0 +1,17 @@
1
+ from .device import Device, PAYLOAD_SCHEMA
2
+ from .protocols.modbus import ModbusConnection, ModbusProtocol
3
+ from .protocols.base import Protocol
4
+ from .constants import (
5
+ MODBUS_TCP, MODBUS_RTU, OPCUA, MQTT,
6
+ ONE_SEC, FIVE_SEC, TEN_SEC, THIRTY_SEC, ONE_MIN, FIVE_MIN,
7
+ )
8
+
9
+ __all__ = [
10
+ "Device",
11
+ "PAYLOAD_SCHEMA",
12
+ "ModbusConnection",
13
+ "ModbusProtocol",
14
+ "Protocol",
15
+ "MODBUS_TCP", "MODBUS_RTU", "OPCUA", "MQTT",
16
+ "ONE_SEC", "FIVE_SEC", "TEN_SEC", "THIRTY_SEC", "ONE_MIN", "FIVE_MIN",
17
+ ]
@@ -0,0 +1,274 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import argparse
5
+
6
+ CONFIG_FILE = "scadable.json"
7
+
8
+ # ─── Templates ────────────────────────────────────────────────────────────────
9
+
10
+ MODBUS_TCP_TEMPLATE = '''\
11
+ """
12
+ Generated by Scadable CLI.
13
+
14
+ Only modify the variable values below (host, port, slave_id, frequency, etc).
15
+ Do not rename classes or change the structure — the runtime depends on it.
16
+ """
17
+ from scadable.edge import Device, ModbusConnection
18
+ from scadable.edge.constants import MODBUS_TCP, FIVE_SEC
19
+ from dataclasses import dataclass
20
+
21
+
22
+ @dataclass
23
+ class Connection(ModbusConnection):
24
+ """
25
+ Modbus TCP connection settings.
26
+
27
+ Attributes:
28
+ host: IP address or hostname of the Modbus device.
29
+ Use "${{DEVICE_HOST}}" for environment variable injection.
30
+ port: TCP port number (default: 502 for Modbus).
31
+ slave_id: Modbus slave/unit ID (1-247).
32
+ """
33
+ host: str = "${{DEVICE_HOST}}"
34
+ port: int = 502
35
+ slave_id: int = 1
36
+
37
+
38
+ class {class_name}(Device):
39
+ """
40
+ Device configuration.
41
+
42
+ Attributes:
43
+ id: Unique identifier for this device.
44
+ protocol: Communication protocol (MODBUS_TCP).
45
+ connection: Connection settings class.
46
+ frequency: Polling interval in seconds (how often to read data).
47
+ filter: List of register names to pull (empty = pull all).
48
+ """
49
+ id = "{device_id}"
50
+ protocol = MODBUS_TCP
51
+ connection = Connection
52
+ frequency = FIVE_SEC
53
+ filter = [] # empty = pull all registers; e.g. ["reg_100", "reg_102"] to filter
54
+ '''
55
+
56
+ MODBUS_RTU_TEMPLATE = '''\
57
+ """
58
+ Generated by Scadable CLI.
59
+
60
+ Only modify the variable values below (serial_port, baudrate, slave_id, etc).
61
+ Do not rename classes or change the structure — the runtime depends on it.
62
+ """
63
+ from scadable.edge import Device, ModbusConnection
64
+ from scadable.edge.constants import MODBUS_RTU, FIVE_SEC
65
+ from dataclasses import dataclass
66
+
67
+
68
+ @dataclass
69
+ class Connection(ModbusConnection):
70
+ """
71
+ Modbus RTU (serial) connection settings.
72
+
73
+ Attributes:
74
+ serial_port: Serial port path (e.g., "/dev/ttyUSB0" on Linux,
75
+ "COM3" on Windows).
76
+ slave_id: Modbus slave/unit ID (1-247).
77
+ baudrate: Serial baud rate (common: 9600, 19200, 38400, 115200).
78
+ parity: Parity bit — "N" (none), "E" (even), or "O" (odd).
79
+ stopbits: Number of stop bits (1 or 2).
80
+ bytesize: Data bits per byte (typically 8).
81
+ """
82
+ serial_port: str = "/dev/ttyUSB0"
83
+ slave_id: int = 1
84
+ baudrate: int = 9600
85
+ parity: str = "N"
86
+ stopbits: int = 1
87
+ bytesize: int = 8
88
+
89
+
90
+ class {class_name}(Device):
91
+ """
92
+ Device configuration.
93
+
94
+ Attributes:
95
+ id: Unique identifier for this device.
96
+ protocol: Communication protocol (MODBUS_RTU).
97
+ connection: Connection settings class.
98
+ frequency: Polling interval in seconds (how often to read data).
99
+ filter: List of register names to pull (empty = pull all).
100
+ """
101
+ id = "{device_id}"
102
+ protocol = MODBUS_RTU
103
+ connection = Connection
104
+ frequency = FIVE_SEC
105
+ filter = [] # empty = pull all registers; e.g. ["reg_100", "reg_102"] to filter
106
+ '''
107
+
108
+ TEMPLATES = {
109
+ "modbus-tcp": MODBUS_TCP_TEMPLATE,
110
+ "modbus-rtu": MODBUS_RTU_TEMPLATE,
111
+ }
112
+
113
+ SCADABLE_ASCII = """
114
+ ____ ____ _ ____ _ ____ _ _____
115
+ / ___| / ___| / \\ | _ \\ / \\ | __ ) | | | ____|
116
+ \\___ \\ | | / _ \\ | | | | / _ \\ | _ \\ | | | _|
117
+ ___) | | |___ / ___ \\ | |_| | / ___ \\ | |_) | | |___ | |___
118
+ |____/ \\____| /_/ \\_\\ |____/ /_/ \\_\\ |____/ |_____| |_____|
119
+
120
+ Edge SDK v0.1.0
121
+ """
122
+
123
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
124
+
125
+ def to_class_name(name: str) -> str:
126
+ return "".join(p.capitalize() for p in name.replace("_", "-").split("-"))
127
+
128
+ def get_gateways() -> list:
129
+ """Get list of gateway names by scanning gateways/ subfolders."""
130
+ if not os.path.exists("gateways"):
131
+ return []
132
+ gateways = []
133
+ for name in os.listdir("gateways"):
134
+ path = os.path.join("gateways", name)
135
+ if os.path.isdir(path):
136
+ gateways.append(name)
137
+ return sorted(gateways)
138
+
139
+ def next_gateway_name() -> str:
140
+ """Auto-increment gateway name: gateway-001, gateway-002 etc."""
141
+ existing = get_gateways()
142
+ i = 1
143
+ while True:
144
+ name = f"gateway-{i:03d}"
145
+ if name not in existing:
146
+ return name
147
+ i += 1
148
+
149
+ def require_project():
150
+ """Exit if not in a Scadable project (no scadable.json)."""
151
+ if not os.path.exists(CONFIG_FILE):
152
+ print(f"No {CONFIG_FILE} found. Run 'scadable init' first.")
153
+ sys.exit(1)
154
+
155
+ def resolve_gateway(gateway_arg: str = None) -> str:
156
+ """
157
+ Resolve which gateway to use.
158
+ - If --gateway is specified, use it.
159
+ - If only one gateway exists, use it automatically.
160
+ - If multiple exist, prompt the user to pick.
161
+ """
162
+ gateways = get_gateways()
163
+ if not gateways:
164
+ print("No gateways found. Run 'scadable add gateway' first.")
165
+ sys.exit(1)
166
+ if gateway_arg:
167
+ if gateway_arg not in gateways:
168
+ print(f"Gateway '{gateway_arg}' not found. Available: {', '.join(gateways)}")
169
+ sys.exit(1)
170
+ return gateway_arg
171
+ if len(gateways) == 1:
172
+ return gateways[0]
173
+ # Multiple gateways — prompt
174
+ print("Multiple gateways found:")
175
+ for i, gw in enumerate(gateways, 1):
176
+ print(f" {i}. {gw}")
177
+ choice = input("Select gateway (number): ").strip()
178
+ try:
179
+ return gateways[int(choice) - 1]
180
+ except (ValueError, IndexError):
181
+ print("Invalid choice.")
182
+ sys.exit(1)
183
+
184
+ # ─── Commands ─────────────────────────────────────────────────────────────────
185
+
186
+ def cmd_init(project_name: str = None):
187
+ if os.path.exists(CONFIG_FILE):
188
+ print(f"{CONFIG_FILE} already exists.")
189
+ sys.exit(1)
190
+ name = project_name or os.path.basename(os.getcwd())
191
+ config = {"project": name, "version": "0.1.0"}
192
+ os.makedirs("gateways", exist_ok=True)
193
+ with open(CONFIG_FILE, "w") as f:
194
+ json.dump(config, f, indent=2)
195
+ print(SCADABLE_ASCII)
196
+ print(f"✓ Initialized project '{name}'")
197
+ print(f"✓ Created gateways/")
198
+ print(f"✓ Created {CONFIG_FILE}")
199
+ print(f"\nNext: scadable add gateway")
200
+
201
+ def cmd_add_gateway(gateway_name: str = None):
202
+ require_project()
203
+ existing = get_gateways()
204
+ name = gateway_name or next_gateway_name()
205
+ if name in existing:
206
+ print(f"Gateway '{name}' already exists.")
207
+ sys.exit(1)
208
+ path = os.path.join("gateways", name)
209
+ os.makedirs(path, exist_ok=True)
210
+ print(f"✓ Created gateways/{name}/")
211
+ print(f"\nNext: scadable add device modbus-tcp <device-name> --gateway {name}")
212
+
213
+ def cmd_add_device(protocol: str, device_name: str, gateway_arg: str = None):
214
+ require_project()
215
+ gateway = resolve_gateway(gateway_arg)
216
+ template = TEMPLATES.get(protocol)
217
+ if not template:
218
+ print(f"Unknown protocol '{protocol}'. Available: {', '.join(TEMPLATES.keys())}")
219
+ sys.exit(1)
220
+ device_path = os.path.join("gateways", gateway, device_name)
221
+ if os.path.exists(device_path):
222
+ print(f"Device '{device_name}' already exists in gateway '{gateway}'.")
223
+ sys.exit(1)
224
+ os.makedirs(device_path)
225
+ config_path = os.path.join(device_path, "config.py")
226
+ content = template.format(
227
+ class_name=to_class_name(device_name),
228
+ device_id=device_name,
229
+ )
230
+ with open(config_path, "w") as f:
231
+ f.write(content)
232
+ print(f"✓ Created {config_path}")
233
+ print(f"\nNext: edit {config_path} and set your connection details.")
234
+
235
+ # ─── Main ─────────────────────────────────────────────────────────────────────
236
+
237
+ def main():
238
+ parser = argparse.ArgumentParser(prog="scadable")
239
+ sub = parser.add_subparsers(dest="command")
240
+
241
+ # scadable init [name]
242
+ p_init = sub.add_parser("init", help="Initialize a new Scadable project")
243
+ p_init.add_argument("name", nargs="?", help="Project name (default: current folder name)")
244
+
245
+ # scadable add ...
246
+ p_add = sub.add_parser("add", help="Add a gateway or device")
247
+ add_sub = p_add.add_subparsers(dest="resource")
248
+
249
+ # scadable add gateway [name]
250
+ p_gw = add_sub.add_parser("gateway", help="Add a new gateway")
251
+ p_gw.add_argument("name", nargs="?", help="Gateway name (default: gateway-001, gateway-002...)")
252
+
253
+ # scadable add device <protocol> <name> [--gateway <name>]
254
+ p_dev = add_sub.add_parser("device", help="Add a new device to a gateway")
255
+ p_dev.add_argument("protocol", choices=list(TEMPLATES.keys()))
256
+ p_dev.add_argument("name", help="Device name e.g. temp-sensor")
257
+ p_dev.add_argument("--gateway", help="Gateway name (auto-detected if only one exists)")
258
+
259
+ args = parser.parse_args()
260
+
261
+ if args.command == "init":
262
+ cmd_init(args.name)
263
+ elif args.command == "add":
264
+ if args.resource == "gateway":
265
+ cmd_add_gateway(getattr(args, "name", None))
266
+ elif args.resource == "device":
267
+ cmd_add_device(args.protocol, args.name, getattr(args, "gateway", None))
268
+ else:
269
+ p_add.print_help()
270
+ else:
271
+ parser.print_help()
272
+
273
+ if __name__ == "__main__":
274
+ main()
@@ -0,0 +1,13 @@
1
+ # Protocols
2
+ MODBUS_TCP = "modbus-tcp"
3
+ MODBUS_RTU = "modbus-rtu"
4
+ OPCUA = "opcua"
5
+ MQTT = "mqtt"
6
+
7
+ # Frequencies in seconds
8
+ ONE_SEC = 1
9
+ FIVE_SEC = 5
10
+ TEN_SEC = 10
11
+ THIRTY_SEC = 30
12
+ ONE_MIN = 60
13
+ FIVE_MIN = 300
@@ -0,0 +1,63 @@
1
+ PAYLOAD_SCHEMA = {
2
+ "device_id": str,
3
+ "protocol": str,
4
+ "timestamp": int,
5
+ "payload": dict,
6
+ }
7
+
8
+
9
+ class Device:
10
+ """
11
+ Base class for all gateway devices.
12
+ Subclass this in your config.py to define a device.
13
+
14
+ Required class variables:
15
+ id — unique identifier for this device
16
+ protocol — use a constant from scadable.edge.constants
17
+ connection — subclass of ModbusConnection (or other protocol connection)
18
+ frequency — polling interval in seconds, use constants: FIVE_SEC, TEN_SEC etc
19
+
20
+ Optional class variables:
21
+ filter — list of register names to pull (empty = pull all)
22
+
23
+ Usage:
24
+ class MyPLC(Device):
25
+ id = "device-001"
26
+ protocol = MODBUS_TCP
27
+ connection = Connection
28
+ frequency = FIVE_SEC
29
+ filter = [] # empty = pull all registers
30
+ """
31
+
32
+ id: str = None
33
+ protocol: str = None
34
+ connection = None
35
+ frequency: int = 10
36
+ filter: list = []
37
+
38
+ def __init_subclass__(cls, **kwargs):
39
+ super().__init_subclass__(**kwargs)
40
+ # Skip validation for intermediate base classes
41
+ if cls.__name__ == "Device":
42
+ return
43
+ errors = []
44
+ if not cls.id:
45
+ errors.append(f"Device subclass '{cls.__name__}' must define 'id'")
46
+ if not cls.protocol:
47
+ errors.append(f"Device subclass '{cls.__name__}' must define 'protocol'")
48
+ if cls.connection is None:
49
+ errors.append(f"Device subclass '{cls.__name__}' must define 'connection'")
50
+ if errors:
51
+ raise TypeError("\n".join(errors))
52
+
53
+ def __repr__(self):
54
+ return (
55
+ f"<Device id={self.id!r} protocol={self.protocol!r} "
56
+ f"connection={self.connection.__name__} frequency={self.frequency}s>"
57
+ )
58
+
59
+ def read(self, *args, **kwargs):
60
+ raise NotImplementedError("read() will be implemented by the WASM runtime")
61
+
62
+ def write(self, *args, **kwargs):
63
+ raise NotImplementedError("write() will be implemented by the WASM runtime")
File without changes
@@ -0,0 +1,10 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ class Protocol(ABC):
4
+ @abstractmethod
5
+ def read(self, *args, **kwargs):
6
+ pass
7
+
8
+ @abstractmethod
9
+ def write(self, *args, **kwargs):
10
+ pass
@@ -0,0 +1,36 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+ from .base import Protocol
4
+ from ..constants import MODBUS_TCP, MODBUS_RTU
5
+
6
+ @dataclass
7
+ class ModbusConnection:
8
+ """
9
+ Base Modbus connection config.
10
+ Subclass this in your config.py and override the fields you need.
11
+
12
+ Usage:
13
+ @dataclass
14
+ class Connection(ModbusConnection):
15
+ host: str = "192.168.1.100"
16
+ port: int = 502
17
+ slave_id: int = 1
18
+ """
19
+ host: str = ""
20
+ port: int = 502
21
+ slave_id: int = 1
22
+ timeout: float = 5.0
23
+ retries: int = 3
24
+ # RTU only — leave None for TCP
25
+ serial_port: Optional[str] = None
26
+ baudrate: int = 9600
27
+ parity: str = "N" # N=none, E=even, O=odd
28
+ stopbits: int = 1
29
+ bytesize: int = 8
30
+
31
+ class ModbusProtocol(Protocol):
32
+ def read(self, *args, **kwargs):
33
+ raise NotImplementedError
34
+
35
+ def write(self, *args, **kwargs):
36
+ raise NotImplementedError
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: scadable-cli
3
+ Version: 0.1.1
4
+ Summary: Edge SDK for the Scadable IoT platform
5
+ License: MIT
6
+ Keywords: iot,scada,edge,modbus,industrial
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+
10
+ # Scadable Edge SDK
11
+
12
+ Python SDK for defining devices on the Scadable IoT platform.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install scadable-cli
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ Create a `config.py` for your device:
23
+
24
+ ```python
25
+ from scadable.edge import Device, ModbusConnection
26
+ from scadable.edge.constants import MODBUS_TCP, FIVE_SEC
27
+ from dataclasses import dataclass
28
+
29
+ @dataclass
30
+ class Connection(ModbusConnection):
31
+ host: str = "${DEVICE_HOST}"
32
+ port: int = 502
33
+ slave_id: int = 1
34
+
35
+ class MyPLC(Device):
36
+ id = "device-001"
37
+ protocol = MODBUS_TCP
38
+ connection = Connection
39
+ frequency = FIVE_SEC
40
+ ```
41
+
42
+ ## Project Structure
43
+
44
+ ```
45
+ gateways/
46
+ gateway-001/
47
+ device-001/
48
+ config.py
49
+ device-002/
50
+ config.py
51
+ ```
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ scadable/__init__.py
4
+ scadable/edge/__init__.py
5
+ scadable/edge/cli.py
6
+ scadable/edge/constants.py
7
+ scadable/edge/device.py
8
+ scadable/edge/protocols/__init__.py
9
+ scadable/edge/protocols/base.py
10
+ scadable/edge/protocols/modbus.py
11
+ scadable_cli.egg-info/PKG-INFO
12
+ scadable_cli.egg-info/SOURCES.txt
13
+ scadable_cli.egg-info/dependency_links.txt
14
+ scadable_cli.egg-info/entry_points.txt
15
+ scadable_cli.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scadable = scadable.edge.cli:main
@@ -0,0 +1 @@
1
+ scadable
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+