openswarm-slave 0.1.0__py3-none-any.whl

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,12 @@
1
+ """
2
+ OpenSwarm Linux Slave SDK (`openswarm-slave`)
3
+ =============================================
4
+ Turns any Linux system (Arduino UNO Q, Portenta X8, Raspberry Pi, Nvidia Jetson, or Linux Laptop)
5
+ into a Zero-Control-Logic OpenSwarm Slave node speaking the exact same UDP protocol (4210 / 4211)
6
+ as the microcontroller slaves.
7
+ """
8
+
9
+ from .node import SwarmNode
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = ["SwarmNode"]
openswarm_slave/cli.py ADDED
@@ -0,0 +1,27 @@
1
+ """
2
+ OpenSwarm Linux Slave CLI (`openswarm-slave`)
3
+ Turns any Linux SBC (Arduino UNO Q, Portenta X8, Raspberry Pi, Jetson) or laptop
4
+ into an active OpenSwarm Slave node right from the terminal.
5
+ """
6
+
7
+ import argparse
8
+ from .node import SwarmNode
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description="OpenSwarm Linux Slave Agent")
13
+ parser.add_argument("--id", default="linux_node", help="Slave Node ID (default: linux_node)")
14
+ parser.add_argument("--port", type=int, default=4210, help="UDP Port (default: 4210)")
15
+ parser.add_argument("--led-pin", type=int, default=2, help="Register a GPIO pin as 'led' (default: 2)")
16
+ args = parser.parse_args()
17
+
18
+ node = SwarmNode(args.id, udp_port=args.port)
19
+ node.register_digital_output("led", args.led_pin)
20
+ node.register_cpu_temp("cpu_temp")
21
+ node.register_system_load("sys_load")
22
+ node.register_wifi_rssi("rssi")
23
+ node.start(block=True)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
@@ -0,0 +1,381 @@
1
+ """
2
+ Linux Hardware Abstraction Layer (`LinuxHAL`)
3
+ Supports:
4
+ - Arduino UNO Q (Linux + MCU bridge / sysfs / libgpiod)
5
+ - Arduino Portenta X8 (NXP i.MX 8M Mini Linux sysfs / libgpiod / I2C / SPI / UART)
6
+ - Raspberry Pi (RPi.GPIO / libgpiod / sysfs / I2C / SPI / UART)
7
+ - Nvidia Jetson (Jetson.GPIO / sysfs / I2C / SPI / UART)
8
+ - Generic Linux Laptop / Desktop (sysfs thermal/network + virtual GPIO simulation for testing)
9
+ """
10
+
11
+ import os
12
+ import platform
13
+ import socket
14
+ import struct
15
+ import time
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+
19
+ class LinuxHAL:
20
+ def __init__(self, verbose_sim: bool = True):
21
+ self.verbose_sim = verbose_sim
22
+ self.board_name = self._detect_board()
23
+ self._gpio_backend = "sim"
24
+ self._rpi_gpio = None
25
+ self._pwm_instances: Dict[int, Any] = {}
26
+ self._sim_pins: Dict[int, int] = {}
27
+ self._i2c_buses: Dict[int, Any] = {}
28
+ self._spi_devs: Dict[Tuple[int, int], Any] = {}
29
+ self._uart_ports: Dict[str, Any] = {}
30
+
31
+ self._init_gpio_backend()
32
+
33
+ # -------------------------------------------------------------------------
34
+ # Board & Network Detection
35
+ # -------------------------------------------------------------------------
36
+ @staticmethod
37
+ def _detect_board() -> str:
38
+ model_paths = ["/proc/device-tree/model", "/sys/firmware/devicetree/base/model"]
39
+ for p in model_paths:
40
+ if os.path.exists(p):
41
+ try:
42
+ with open(p, "rb") as f:
43
+ model = f.read().decode("utf-8", errors="ignore").strip("\x00\r\n ").upper()
44
+ if "UNO Q" in model or "ARDUINO UNO" in model:
45
+ return "ARDUINO_UNO_Q"
46
+ if "PORTENTA" in model or "X8" in model:
47
+ return "PORTENTA_X8"
48
+ if "RASPBERRY PI" in model:
49
+ return "RASPBERRY_PI"
50
+ if "JETSON" in model or "NVIDIA" in model:
51
+ return "NVIDIA_JETSON"
52
+ if model:
53
+ return model.replace(" ", "_")[:24]
54
+ except Exception:
55
+ pass
56
+
57
+ uname = platform.uname()
58
+ if uname.system == "Linux":
59
+ return f"LINUX_{uname.machine.upper()}"
60
+ return f"{uname.system.upper()}_{uname.machine.upper()}"
61
+
62
+ @staticmethod
63
+ def local_ip() -> str:
64
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
65
+ try:
66
+ s.connect(("8.8.8.8", 80))
67
+ ip = s.getsockname()[0]
68
+ except Exception:
69
+ ip = "127.0.0.1"
70
+ finally:
71
+ s.close()
72
+ return ip
73
+
74
+ @classmethod
75
+ def broadcast_ip(cls) -> str:
76
+ ip = cls.local_ip()
77
+ parts = ip.split(".")
78
+ if len(parts) == 4 and parts[0] != "127":
79
+ return f"{parts[0]}.{parts[1]}.{parts[2]}.255"
80
+ return "255.255.255.255"
81
+
82
+ # -------------------------------------------------------------------------
83
+ # GPIO / PWM Backend Initialization (RPi.GPIO -> sysfs -> Virtual Sim)
84
+ # -------------------------------------------------------------------------
85
+ def _init_gpio_backend(self) -> None:
86
+ try:
87
+ import RPi.GPIO as GPIO # type: ignore
88
+
89
+ GPIO.setwarnings(False)
90
+ GPIO.setmode(GPIO.BCM)
91
+ self._rpi_gpio = GPIO
92
+ self._gpio_backend = "rpi_gpio"
93
+ return
94
+ except Exception:
95
+ pass
96
+
97
+ if os.path.exists("/sys/class/gpio"):
98
+ self._gpio_backend = "sysfs"
99
+ else:
100
+ self._gpio_backend = "sim"
101
+
102
+ def setup_pin(self, pin: int, mode: str = "OUT", pullup: bool = False) -> None:
103
+ if pin < 0 or pin == 255:
104
+ return
105
+ if self._gpio_backend == "rpi_gpio" and self._rpi_gpio is not None:
106
+ if mode == "OUT":
107
+ self._rpi_gpio.setup(pin, self._rpi_gpio.OUT)
108
+ self._rpi_gpio.output(pin, self._rpi_gpio.LOW)
109
+ else:
110
+ pud = self._rpi_gpio.PUD_UP if pullup else self._rpi_gpio.PUD_OFF
111
+ self._rpi_gpio.setup(pin, self._rpi_gpio.IN, pull_up_down=pud)
112
+ elif self._gpio_backend == "sysfs":
113
+ gpio_dir = f"/sys/class/gpio/gpio{pin}"
114
+ if not os.path.exists(gpio_dir):
115
+ try:
116
+ with open("/sys/class/gpio/export", "w") as f:
117
+ f.write(str(pin))
118
+ except Exception:
119
+ pass
120
+ try:
121
+ with open(f"{gpio_dir}/direction", "w") as f:
122
+ f.write("out" if mode == "OUT" else "in")
123
+ except Exception:
124
+ pass
125
+ else:
126
+ self._sim_pins[pin] = 0
127
+
128
+ def digital_write(self, pin: int, value: int) -> None:
129
+ if pin < 0 or pin == 255:
130
+ return
131
+ v = 1 if value > 0 else 0
132
+ if self._gpio_backend == "rpi_gpio" and self._rpi_gpio is not None:
133
+ self._rpi_gpio.output(pin, self._rpi_gpio.HIGH if v else self._rpi_gpio.LOW)
134
+ elif self._gpio_backend == "sysfs":
135
+ try:
136
+ with open(f"/sys/class/gpio/gpio{pin}/value", "w") as f:
137
+ f.write("1" if v else "0")
138
+ except Exception:
139
+ pass
140
+ else:
141
+ prev = self._sim_pins.get(pin)
142
+ self._sim_pins[pin] = v
143
+ if self.verbose_sim and prev != v:
144
+ print(f" [LinuxHAL] GPIO Pin {pin} -> {'HIGH (1)' if v else 'LOW (0)'}")
145
+
146
+ def digital_read(self, pin: int, pullup: bool = False) -> float:
147
+ if pin < 0 or pin == 255:
148
+ return 0.0
149
+ if self._gpio_backend == "rpi_gpio" and self._rpi_gpio is not None:
150
+ raw = int(self._rpi_gpio.input(pin))
151
+ return float(1 - raw if pullup else raw)
152
+ elif self._gpio_backend == "sysfs":
153
+ try:
154
+ with open(f"/sys/class/gpio/gpio{pin}/value", "r") as f:
155
+ raw = int(f.read().strip())
156
+ return float(1 - raw if pullup else raw)
157
+ except Exception:
158
+ return 0.0
159
+ return float(self._sim_pins.get(pin, 0))
160
+
161
+ def pwm_write(self, pin: int, duty_255: int) -> None:
162
+ if pin < 0 or pin == 255:
163
+ return
164
+ d = max(0, min(255, int(duty_255)))
165
+ if self._gpio_backend == "rpi_gpio" and self._rpi_gpio is not None:
166
+ if pin not in self._pwm_instances:
167
+ self._rpi_gpio.setup(pin, self._rpi_gpio.OUT)
168
+ pwm = self._rpi_gpio.PWM(pin, 1000)
169
+ pwm.start(0)
170
+ self._pwm_instances[pin] = pwm
171
+ self._pwm_instances[pin].ChangeDutyCycle((d / 255.0) * 100.0)
172
+ else:
173
+ prev = self._sim_pins.get(pin)
174
+ self._sim_pins[pin] = d
175
+ if self.verbose_sim and prev != d:
176
+ print(f" [LinuxHAL] PWM Pin {pin} -> duty {d}/255")
177
+
178
+ # -------------------------------------------------------------------------
179
+ # H-Bridge Motor Helpers
180
+ # -------------------------------------------------------------------------
181
+ def drive_hbridge_3pin(self, ena: int, in1: int, in2: int, speed: int) -> None:
182
+ s = max(-255, min(255, int(speed)))
183
+ if s > 0:
184
+ self.digital_write(in1, 1)
185
+ self.digital_write(in2, 0)
186
+ self.pwm_write(ena, s)
187
+ elif s < 0:
188
+ self.digital_write(in1, 0)
189
+ self.digital_write(in2, 1)
190
+ self.pwm_write(ena, -s)
191
+ else:
192
+ self.digital_write(in1, 0)
193
+ self.digital_write(in2, 0)
194
+ self.pwm_write(ena, 0)
195
+
196
+ def drive_hbridge_2pin(self, in1: int, in2: int, speed: int) -> None:
197
+ s = max(-255, min(255, int(speed)))
198
+ if s > 0:
199
+ self.pwm_write(in1, s)
200
+ self.pwm_write(in2, 0)
201
+ elif s < 0:
202
+ self.pwm_write(in1, 0)
203
+ self.pwm_write(in2, -s)
204
+ else:
205
+ self.pwm_write(in1, 0)
206
+ self.pwm_write(in2, 0)
207
+
208
+ # -------------------------------------------------------------------------
209
+ # I2C / SPI / UART Bus Helpers
210
+ # -------------------------------------------------------------------------
211
+ def _get_i2c(self, bus_num: int = 1):
212
+ if bus_num not in self._i2c_buses:
213
+ try:
214
+ import smbus2 # type: ignore
215
+
216
+ self._i2c_buses[bus_num] = smbus2.SMBus(bus_num)
217
+ except Exception:
218
+ self._i2c_buses[bus_num] = None
219
+ return self._i2c_buses[bus_num]
220
+
221
+ def i2c_write_reg(self, bus_num: int, i2c_addr: int, reg: int, val: int) -> None:
222
+ bus = self._get_i2c(bus_num)
223
+ if bus is not None:
224
+ try:
225
+ bus.write_byte_data(i2c_addr, reg & 0xFF, val & 0xFF)
226
+ return
227
+ except Exception:
228
+ pass
229
+ if self.verbose_sim:
230
+ print(f" [LinuxHAL] I2C(bus={bus_num}, addr=0x{i2c_addr:02X}) write reg 0x{reg:02X} = 0x{val:02X}")
231
+
232
+ def i2c_read_bytes(self, bus_num: int, i2c_addr: int, reg: int, length: int) -> List[int]:
233
+ bus = self._get_i2c(bus_num)
234
+ if bus is not None:
235
+ try:
236
+ return list(bus.read_i2c_block_data(i2c_addr, reg & 0xFF, length))
237
+ except Exception:
238
+ pass
239
+ return [0] * length
240
+
241
+ def _get_uart(self, port_path: str, baud: int):
242
+ if port_path not in self._uart_ports:
243
+ try:
244
+ import serial # type: ignore
245
+
246
+ self._uart_ports[port_path] = serial.Serial(port_path, baudrate=baud, timeout=0.01)
247
+ except Exception:
248
+ self._uart_ports[port_path] = None
249
+ return self._uart_ports[port_path]
250
+
251
+ def uart_write(self, port_path: str, baud: int, data_str: str, fallback_byte: int = 0) -> None:
252
+ ser = self._get_uart(port_path, baud)
253
+ if ser is not None:
254
+ try:
255
+ if data_str:
256
+ ser.write((data_str + "\n").encode("utf-8"))
257
+ else:
258
+ ser.write(bytes([fallback_byte & 0xFF]))
259
+ return
260
+ except Exception:
261
+ pass
262
+ if self.verbose_sim:
263
+ print(f" [LinuxHAL] UART({port_path} @ {baud}) TX -> '{data_str or fallback_byte}'")
264
+
265
+ def uart_read_float(self, port_path: str, baud: int, last_val: float = 0.0) -> float:
266
+ ser = self._get_uart(port_path, baud)
267
+ if ser is not None and ser.in_waiting > 0:
268
+ try:
269
+ line = ser.readline().decode("utf-8", errors="ignore").strip()
270
+ if line:
271
+ return float(line)
272
+ except Exception:
273
+ pass
274
+ return last_val
275
+
276
+ # -------------------------------------------------------------------------
277
+ # Built-in Linux System & Sensor Readers
278
+ # -------------------------------------------------------------------------
279
+ @staticmethod
280
+ def read_cpu_temp() -> float:
281
+ paths = [
282
+ "/sys/class/thermal/thermal_zone0/temp",
283
+ "/sys/devices/virtual/thermal/thermal_zone0/temp",
284
+ ]
285
+ for p in paths:
286
+ if os.path.exists(p):
287
+ try:
288
+ with open(p, "r") as f:
289
+ return float(f.read().strip()) / 1000.0
290
+ except Exception:
291
+ pass
292
+ return 42.0
293
+
294
+ @staticmethod
295
+ def read_system_load() -> List[float]:
296
+ load_1m = 0.0
297
+ ram_pct = 0.0
298
+ if os.path.exists("/proc/loadavg"):
299
+ try:
300
+ with open("/proc/loadavg", "r") as f:
301
+ load_1m = float(f.read().split()[0])
302
+ except Exception:
303
+ pass
304
+ if os.path.exists("/proc/meminfo"):
305
+ try:
306
+ mem: Dict[str, int] = {}
307
+ with open("/proc/meminfo", "r") as f:
308
+ for line in f:
309
+ parts = line.split(":")
310
+ if len(parts) == 2:
311
+ mem[parts[0].strip()] = int(parts[1].strip().split()[0])
312
+ total = mem.get("MemTotal", 0)
313
+ avail = mem.get("MemAvailable", mem.get("MemFree", 0))
314
+ if total > 0:
315
+ ram_pct = ((total - avail) / float(total)) * 100.0
316
+ except Exception:
317
+ pass
318
+ return [round(load_1m, 2), round(ram_pct, 1)]
319
+
320
+ @staticmethod
321
+ def read_wifi_rssi() -> float:
322
+ if os.path.exists("/proc/net/wireless"):
323
+ try:
324
+ with open("/proc/net/wireless", "r") as f:
325
+ lines = f.readlines()
326
+ if len(lines) >= 3:
327
+ parts = lines[2].split()
328
+ if len(parts) >= 4:
329
+ val = float(parts[3].rstrip("."))
330
+ return val if val < 0 else val - 100.0
331
+ except Exception:
332
+ pass
333
+ return -50.0
334
+
335
+ def read_ultrasonic(self, trig: int, echo: int) -> float:
336
+ if self._gpio_backend == "sim":
337
+ return 25.0
338
+ self.digital_write(trig, 0)
339
+ time.sleep(0.000002)
340
+ self.digital_write(trig, 1)
341
+ time.sleep(0.000010)
342
+ self.digital_write(trig, 0)
343
+
344
+ timeout = time.perf_counter() + 0.015
345
+ while self.digital_read(echo) == 0:
346
+ if time.perf_counter() > timeout:
347
+ return -1.0
348
+ start = time.perf_counter()
349
+ while self.digital_read(echo) == 1:
350
+ if time.perf_counter() > timeout:
351
+ return -1.0
352
+ duration_us = (time.perf_counter() - start) * 1_000_000.0
353
+ return round((duration_us * 0.0343) * 0.5, 2)
354
+
355
+ def read_mpu6050(self, bus_num: int, i2c_addr: int) -> List[float]:
356
+ raw = self.i2c_read_bytes(bus_num, i2c_addr, 0x3B, 14)
357
+ if any(raw):
358
+ ax, ay, az, _, gx, gy, gz = struct.unpack(">hhhhhhh", bytes(raw))
359
+ return [
360
+ round(ax / 16384.0, 2),
361
+ round(ay / 16384.0, 2),
362
+ round(az / 16384.0, 2),
363
+ round(gx / 131.0, 2),
364
+ round(gy / 131.0, 2),
365
+ round(gz / 131.0, 2),
366
+ ]
367
+ return [0.0, 0.0, 1.0, 0.0, 0.0, 0.0]
368
+
369
+ def read_lsm6dsox(self, bus_num: int, i2c_addr: int) -> List[float]:
370
+ raw = self.i2c_read_bytes(bus_num, i2c_addr, 0x22, 12)
371
+ if any(raw):
372
+ gx, gy, gz, ax, ay, az = struct.unpack("<hhhhhh", bytes(raw))
373
+ return [
374
+ round(ax * 0.000061, 2),
375
+ round(ay * 0.000061, 2),
376
+ round(az * 0.000061, 2),
377
+ round(gx * 0.00875, 2),
378
+ round(gy * 0.00875, 2),
379
+ round(gz * 0.00875, 2),
380
+ ]
381
+ return [0.0, 0.0, 1.0, 0.0, 0.0, 0.0]
@@ -0,0 +1,411 @@
1
+ """
2
+ SwarmNode (Linux Slave Agent)
3
+ =============================
4
+ Identical Hardware Declaration API & UDP Wire Protocol (4210 / 4211) to the C++ Arduino library.
5
+ Supports both Pythonic `snake_case` and Arduino `camelCase` registration methods!
6
+ """
7
+
8
+ import json
9
+ import socket
10
+ import threading
11
+ import time
12
+ from typing import Any, Callable, Dict, List, Optional, Union
13
+
14
+ from .hal_linux import LinuxHAL
15
+
16
+ SWARM_DEFAULT_PORT = 4210
17
+ SWARM_DISCOVERY_PORT = 4211
18
+ SWARM_WATCHDOG_SEC = 0.50 # 500ms hardware safety watchdog
19
+ SWARM_TELEMETRY_SEC = 0.05 # 20 Hz sensor telemetry stream
20
+ SWARM_HELLO_SEC = 1.00 # 1 Hz discovery broadcast
21
+
22
+
23
+ class SwarmNode:
24
+ def __init__(self, node_id: str, udp_port: int = SWARM_DEFAULT_PORT, verbose_sim: bool = True):
25
+ self.node_id = node_id
26
+ self.udp_port = udp_port
27
+ self.hal = LinuxHAL(verbose_sim=verbose_sim)
28
+
29
+ self._actuators: Dict[str, Dict[str, Any]] = {}
30
+ self._sensors: Dict[str, Dict[str, Any]] = {}
31
+
32
+ self._master_addr: Optional[ tuple ] = None
33
+ self._last_master_pkt = 0.0
34
+ self._watchdog_tripped = False
35
+ self._start_time = time.time()
36
+ self._running = False
37
+ self._sock: Optional[socket.socket] = None
38
+
39
+ # -------------------------------------------------------------------------
40
+ # 1. Standard Actuator Registration API (Motors, Servos, Steppers, GPIO)
41
+ # -------------------------------------------------------------------------
42
+ def register_l298n(self, name: str, ena: int, in1: int, in2: int, enb: int, in3: int, in4: int) -> bool:
43
+ for p in (ena, in1, in2, enb, in3, in4):
44
+ self.hal.setup_pin(p, "OUT")
45
+ self._actuators[name] = {"type": "L298N", "pins": [ena, in1, in2, enb, in3, in4]}
46
+ return True
47
+
48
+ def register_drv8833(self, name: str, in1: int, in2: int, in3: int, in4: int) -> bool:
49
+ for p in (in1, in2, in3, in4):
50
+ self.hal.setup_pin(p, "OUT")
51
+ self._actuators[name] = {"type": "DRV8833", "pins": [in1, in2, in3, in4]}
52
+ return True
53
+
54
+ def register_motor_2pin(self, name: str, pin_a: int, pin_b: int) -> bool:
55
+ self.hal.setup_pin(pin_a, "OUT")
56
+ self.hal.setup_pin(pin_b, "OUT")
57
+ self._actuators[name] = {"type": "MOTOR_2PIN", "pins": [pin_a, pin_b]}
58
+ return True
59
+
60
+ def register_servo(self, name: str, pin: int) -> bool:
61
+ self.hal.setup_pin(pin, "OUT")
62
+ self._actuators[name] = {"type": "SERVO", "pins": [pin]}
63
+ return True
64
+
65
+ def register_stepper(self, name: str, step_pin: int, dir_pin: int, en_pin: int = 255) -> bool:
66
+ self.hal.setup_pin(step_pin, "OUT")
67
+ self.hal.setup_pin(dir_pin, "OUT")
68
+ if en_pin != 255:
69
+ self.hal.setup_pin(en_pin, "OUT")
70
+ self._actuators[name] = {"type": "STEPPER", "pins": [step_pin, dir_pin, en_pin]}
71
+ return True
72
+
73
+ def register_digital_output(self, name: str, pin: int) -> bool:
74
+ self.hal.setup_pin(pin, "OUT")
75
+ self._actuators[name] = {"type": "DIGITAL_OUT", "pins": [pin]}
76
+ return True
77
+
78
+ def register_pwm_output(self, name: str, pin: int) -> bool:
79
+ self.hal.setup_pin(pin, "OUT")
80
+ self._actuators[name] = {"type": "PWM_OUT", "pins": [pin]}
81
+ return True
82
+
83
+ def register_pin(self, name: str, pin: int, mode: str = "OUT", pullup: bool = False) -> bool:
84
+ if mode.upper().startswith("IN"):
85
+ return self.register_digital_input(name, pin, pullup=pullup)
86
+ return self.register_digital_output(name, pin)
87
+
88
+ # -------------------------------------------------------------------------
89
+ # 2. Standard Sensor Registration API
90
+ # -------------------------------------------------------------------------
91
+ def register_ultrasonic(self, name: str, trig_pin: int, echo_pin: int) -> bool:
92
+ self.hal.setup_pin(trig_pin, "OUT")
93
+ self.hal.setup_pin(echo_pin, "IN")
94
+ self._sensors[name] = {"type": "ULTRASONIC", "pins": [trig_pin, echo_pin]}
95
+ return True
96
+
97
+ def register_bumper(self, name: str, pin: int, pullup: bool = True) -> bool:
98
+ return self.register_digital_input(name, pin, pullup=pullup)
99
+
100
+ def register_ir_sensor(self, name: str, pin: int, pullup: bool = False) -> bool:
101
+ return self.register_digital_input(name, pin, pullup=pullup)
102
+
103
+ def register_digital_input(self, name: str, pin: int, pullup: bool = False) -> bool:
104
+ self.hal.setup_pin(pin, "IN", pullup=pullup)
105
+ self._sensors[name] = {"type": "DIGITAL_IN", "pins": [pin], "pullup": pullup}
106
+ return True
107
+
108
+ def register_mpu6050(self, name: str, i2c_addr: int = 0x68, bus_num: int = 1) -> bool:
109
+ self.hal.i2c_write_reg(bus_num, i2c_addr, 0x6B, 0x00)
110
+ self._sensors[name] = {"type": "MPU6050", "bus": bus_num, "addr": i2c_addr}
111
+ return True
112
+
113
+ def register_lsm6dsox(self, name: str, i2c_addr: int = 0x6A, bus_num: int = 1) -> bool:
114
+ self.hal.i2c_write_reg(bus_num, i2c_addr, 0x10, 0x40)
115
+ self.hal.i2c_write_reg(bus_num, i2c_addr, 0x11, 0x40)
116
+ self._sensors[name] = {"type": "LSM6DSOX", "bus": bus_num, "addr": i2c_addr}
117
+ return True
118
+
119
+ def register_cpu_temp(self, name: str = "cpu_temp") -> bool:
120
+ self._sensors[name] = {"type": "CPU_TEMP"}
121
+ return True
122
+
123
+ def register_system_load(self, name: str = "sys_load") -> bool:
124
+ self._sensors[name] = {"type": "SYS_LOAD"}
125
+ return True
126
+
127
+ def register_wifi_rssi(self, name: str = "rssi") -> bool:
128
+ self._sensors[name] = {"type": "WIFI_RSSI"}
129
+ return True
130
+
131
+ # -------------------------------------------------------------------------
132
+ # 3. Universal Bus Protocols & Custom Callbacks (I2C, SPI, UART, Custom)
133
+ # -------------------------------------------------------------------------
134
+ def register_generic_i2c(self, name: str, i2c_addr: int, reg_addr: int, num_bytes: int = 2, bus_num: int = 1) -> bool:
135
+ self._sensors[name] = {
136
+ "type": "GENERIC_I2C",
137
+ "bus": bus_num,
138
+ "addr": i2c_addr,
139
+ "reg": reg_addr,
140
+ "len": num_bytes,
141
+ }
142
+ return True
143
+
144
+ def register_i2c_actuator(self, name: str, i2c_addr: int, bus_num: int = 1) -> bool:
145
+ self._actuators[name] = {"type": "I2C_BUS", "bus": bus_num, "addr": i2c_addr}
146
+ return True
147
+
148
+ def register_uart_sensor(self, name: str, port: str = "/dev/ttyUSB0", baud: int = 9600) -> bool:
149
+ self._sensors[name] = {"type": "UART_BUS", "port": port, "baud": baud, "last": 0.0}
150
+ return True
151
+
152
+ def register_uart_actuator(self, name: str, port: str = "/dev/ttyUSB0", baud: int = 9600) -> bool:
153
+ self._actuators[name] = {"type": "UART_BUS", "port": port, "baud": baud}
154
+ return True
155
+
156
+ def register_custom_sensor(self, name: str, read_callback: Callable[[], Union[float, List[float]]]) -> bool:
157
+ self._sensors[name] = {"type": "CUSTOM", "fn": read_callback}
158
+ return True
159
+
160
+ def register_custom_actuator(self, name: str, write_callback: Callable[[int, int, str], None]) -> bool:
161
+ self._actuators[name] = {"type": "CUSTOM", "fn": write_callback}
162
+ return True
163
+
164
+ # -------------------------------------------------------------------------
165
+ # Arduino C++ camelCase Aliases (So sketches translate 1:1!)
166
+ # -------------------------------------------------------------------------
167
+ registerL298N = register_l298n
168
+ registerDRV8833 = register_drv8833
169
+ registerMotor2Pin = register_motor_2pin
170
+ registerServo = register_servo
171
+ registerStepper = register_stepper
172
+ registerDigitalOutput = register_digital_output
173
+ registerPWMOutput = register_pwm_output
174
+ registerPin = register_pin
175
+ registerUltrasonic = register_ultrasonic
176
+ registerBumper = register_bumper
177
+ registerIRSensor = register_ir_sensor
178
+ registerDigitalInput = register_digital_input
179
+ registerMPU6050 = register_mpu6050
180
+ registerLSM6DSOX = register_lsm6dsox
181
+ registerWiFiRSSI = register_wifi_rssi
182
+ registerGenericI2C = register_generic_i2c
183
+ registerI2CActuator = register_i2c_actuator
184
+ registerUARTSensor = register_uart_sensor
185
+ registerUARTActuator = register_uart_actuator
186
+ registerCustomSensor = register_custom_sensor
187
+ registerCustomActuator = register_custom_actuator
188
+
189
+ # -------------------------------------------------------------------------
190
+ # 4. Start Background Daemon
191
+ # -------------------------------------------------------------------------
192
+ def start(self, block: bool = True) -> None:
193
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
194
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
195
+ if hasattr(socket, "SO_BROADCAST"):
196
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
197
+ self._sock.bind(("0.0.0.0", self.udp_port))
198
+ self._sock.settimeout(0.01)
199
+
200
+ self._running = True
201
+ self._last_master_pkt = time.time()
202
+
203
+ ip = self.hal.local_ip()
204
+ print("==========================================================")
205
+ print(f" OpenSwarm Linux Slave Node : '{self.node_id}'")
206
+ print(f" Detected Platform : {self.hal.board_name} (GPIO: {self.hal._gpio_backend})")
207
+ print(f" Local IP & UDP Port : {ip}:{self.udp_port}")
208
+ print(f" Registered Actuators : {list(self._actuators.keys())}")
209
+ print(f" Registered Sensors : {list(self._sensors.keys())}")
210
+ print(" Advertising to Linux Master on UDP Port 4211...")
211
+ print("==========================================================")
212
+
213
+ daemon = threading.Thread(target=self._background_loop, daemon=True, name="SwarmLinuxSlave")
214
+ daemon.start()
215
+
216
+ if block:
217
+ try:
218
+ while self._running:
219
+ time.sleep(0.5)
220
+ except KeyboardInterrupt:
221
+ print("\n[SwarmNode] Stopping motors and shutting down Linux Slave...")
222
+ self.stop()
223
+
224
+ def stop(self) -> None:
225
+ self._running = False
226
+ self._emergency_stop_all()
227
+ if self._sock:
228
+ try:
229
+ self._sock.close()
230
+ except Exception:
231
+ pass
232
+
233
+ # -------------------------------------------------------------------------
234
+ # Core Runtime Loop (Discovery, Telemetry, Command Execution, Watchdog)
235
+ # -------------------------------------------------------------------------
236
+ def _background_loop(self) -> None:
237
+ last_telemetry = 0.0
238
+ last_hello = 0.0
239
+
240
+ while self._running:
241
+ now = time.time()
242
+
243
+ # 1. Handle Incoming Master Commands
244
+ self._handle_incoming()
245
+
246
+ # 2. 500ms Hardware Safety Watchdog
247
+ if self._master_addr is not None and (now - self._last_master_pkt > SWARM_WATCHDOG_SEC):
248
+ if not self._watchdog_tripped:
249
+ self._emergency_stop_all()
250
+ self._watchdog_tripped = True
251
+ print("[SwarmNode] WATCHDOG: Master timeout -> Motors stopped.")
252
+
253
+ # 3. 20 Hz Sensor Telemetry Stream
254
+ if self._master_addr is not None and (now - last_telemetry >= SWARM_TELEMETRY_SEC):
255
+ last_telemetry = now
256
+ self._send_telemetry()
257
+
258
+ # 4. 1 Hz Auto-Discovery Broadcast
259
+ if now - last_hello >= SWARM_HELLO_SEC:
260
+ last_hello = now
261
+ self._send_hello()
262
+
263
+ def _handle_incoming(self) -> None:
264
+ if not self._sock:
265
+ return
266
+ try:
267
+ data, addr = self._sock.recvfrom(1024)
268
+ except socket.timeout:
269
+ return
270
+ except OSError:
271
+ return
272
+
273
+ try:
274
+ msg = json.loads(data.decode("utf-8", errors="ignore"))
275
+ except Exception:
276
+ return
277
+
278
+ cmd = msg.get("cmd")
279
+ if not cmd:
280
+ return
281
+
282
+ self._master_addr = addr
283
+ self._last_master_pkt = time.time()
284
+ self._watchdog_tripped = False
285
+
286
+ if cmd == "PING":
287
+ self._send_telemetry()
288
+ elif cmd == "STOP":
289
+ self._emergency_stop_all()
290
+ elif cmd == "PIN":
291
+ pin = int(msg.get("pin", -1))
292
+ val = int(msg.get("value", 0))
293
+ pwm = int(msg.get("pwm", 0))
294
+ if pin >= 0:
295
+ if pwm > 0:
296
+ self.hal.pwm_write(pin, val)
297
+ else:
298
+ self.hal.setup_pin(pin, "OUT")
299
+ self.hal.digital_write(pin, val)
300
+ elif cmd == "ACT":
301
+ name = msg.get("name", "")
302
+ act = self._actuators.get(name)
303
+ if not act:
304
+ return
305
+
306
+ v1 = int(
307
+ msg.get(
308
+ "value",
309
+ msg.get(
310
+ "state",
311
+ msg.get("left", msg.get("angle", msg.get("steps", msg.get("reg", msg.get("v1", 0))))),
312
+ ),
313
+ )
314
+ )
315
+ v2 = int(msg.get("right", msg.get("val", msg.get("v2", 0))))
316
+ data_str = str(msg.get("data", ""))
317
+ self._execute_actuator(act, v1, v2, data_str)
318
+
319
+ def _execute_actuator(self, act: Dict[str, Any], v1: int, v2: int, data_str: str) -> None:
320
+ t = act["type"]
321
+ pins = act.get("pins", [])
322
+ if t == "L298N":
323
+ self.hal.drive_hbridge_3pin(pins[0], pins[1], pins[2], v1)
324
+ self.hal.drive_hbridge_3pin(pins[3], pins[4], pins[5], v2)
325
+ elif t == "DRV8833":
326
+ self.hal.drive_hbridge_2pin(pins[0], pins[1], v1)
327
+ self.hal.drive_hbridge_2pin(pins[2], pins[3], v2)
328
+ elif t == "MOTOR_2PIN":
329
+ self.hal.drive_hbridge_2pin(pins[0], pins[1], v1)
330
+ elif t == "DIGITAL_OUT":
331
+ self.hal.digital_write(pins[0], 1 if v1 > 0 else 0)
332
+ elif t == "PWM_OUT" or t == "SERVO":
333
+ self.hal.pwm_write(pins[0], v1)
334
+ elif t == "I2C_BUS":
335
+ self.hal.i2c_write_reg(act["bus"], act["addr"], v1, v2)
336
+ elif t == "UART_BUS":
337
+ self.hal.uart_write(act["port"], act["baud"], data_str, v1)
338
+ elif t == "CUSTOM":
339
+ act["fn"](v1, v2, data_str)
340
+
341
+ def _read_sensor(self, sens: Dict[str, Any]) -> Union[float, List[float]]:
342
+ t = sens["type"]
343
+ if t == "CPU_TEMP":
344
+ return self.hal.read_cpu_temp()
345
+ if t == "SYS_LOAD":
346
+ return self.hal.read_system_load()
347
+ if t == "WIFI_RSSI":
348
+ return self.hal.read_wifi_rssi()
349
+ if t == "DIGITAL_IN":
350
+ return self.hal.digital_read(sens["pins"][0], sens.get("pullup", False))
351
+ if t == "ULTRASONIC":
352
+ return self.hal.read_ultrasonic(sens["pins"][0], sens["pins"][1])
353
+ if t == "MPU6050":
354
+ return self.hal.read_mpu6050(sens["bus"], sens["addr"])
355
+ if t == "LSM6DSOX":
356
+ return self.hal.read_lsm6dsox(sens["bus"], sens["addr"])
357
+ if t == "GENERIC_I2C":
358
+ raw = self.hal.i2c_read_bytes(sens["bus"], sens["addr"], sens["reg"], sens["len"])
359
+ val = 0
360
+ for b in raw:
361
+ val = (val << 8) | b
362
+ return float(val)
363
+ if t == "UART_BUS":
364
+ sens["last"] = self.hal.uart_read_float(sens["port"], sens["baud"], sens.get("last", 0.0))
365
+ return sens["last"]
366
+ if t == "CUSTOM":
367
+ return sens["fn"]()
368
+ return 0.0
369
+
370
+ def _send_hello(self) -> None:
371
+ if not self._sock:
372
+ return
373
+ payload = {
374
+ "type": "HELLO",
375
+ "node": self.node_id,
376
+ "board": self.hal.board_name,
377
+ "ip": self.hal.local_ip(),
378
+ "port": self.udp_port,
379
+ "actuators": list(self._actuators.keys()),
380
+ "sensors": list(self._sensors.keys()),
381
+ }
382
+ raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
383
+ try:
384
+ self._sock.sendto(raw, (self.hal.broadcast_ip(), SWARM_DISCOVERY_PORT))
385
+ except Exception:
386
+ pass
387
+
388
+ def _send_telemetry(self) -> None:
389
+ if not self._sock or self._master_addr is None:
390
+ return
391
+ uptime_ms = int((time.time() - self._start_time) * 1000)
392
+ sensor_readings: Dict[str, Any] = {}
393
+ for name, sens in self._sensors.items():
394
+ sensor_readings[name] = self._read_sensor(sens)
395
+
396
+ payload = {
397
+ "type": "STATE",
398
+ "node": self.node_id,
399
+ "uptime_ms": uptime_ms,
400
+ "sensors": sensor_readings,
401
+ }
402
+ raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
403
+ try:
404
+ self._sock.sendto(raw, self._master_addr)
405
+ except Exception:
406
+ pass
407
+
408
+ def _emergency_stop_all(self) -> None:
409
+ for act in self._actuators.values():
410
+ if act["type"] in ("L298N", "DRV8833", "MOTOR_2PIN", "STEPPER"):
411
+ self._execute_actuator(act, 0, 0, "")
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: openswarm-slave
3
+ Version: 0.1.0
4
+ Summary: Zero-Control-Logic Linux Slave Agent for the OpenSwarm Robotics Framework (Arduino UNO Q, Portenta X8, Raspberry Pi, Jetson, Linux PC)
5
+ Author-email: Mohammed Raihan Soniwala <raihann.eu@gmail.com>
6
+ License: MIT
7
+ Keywords: robotics,swarm-robotics,raspberry-pi,portenta-x8,arduino-uno-q,jetson,linux,hal
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: hardware
12
+ Requires-Dist: pyserial>=3.5; extra == "hardware"
13
+ Requires-Dist: smbus2>=0.4.2; extra == "hardware"
14
+ Requires-Dist: spidev>=3.6; platform_system == "Linux" and extra == "hardware"
15
+ Requires-Dist: gpiod>=2.0; platform_system == "Linux" and extra == "hardware"
16
+ Dynamic: license-file
17
+
18
+ # OpenSwarm Linux Slave Agent (`openswarm-slave`)
19
+
20
+ Turns any Linux SBC (**Arduino UNO Q**, **Arduino Portenta X8**, **Raspberry Pi**, **Nvidia Jetson**) or **Linux Laptop** into a **Zero-Control-Logic OpenSwarm Slave Node**.
21
+
22
+ Speaks the **exact same UDP protocol (`4210` / `4211`)** as the `SwarmNode` C++ microcontroller library—so the Central Linux Master (`openswarm-master`) controls ESP32s, RP2040s, Portenta X8s, Arduino UNO Qs, and Raspberry Pis through one unified API!
23
+
24
+ ---
25
+
26
+ ## 1. Installation (On Arduino UNO Q, Portenta X8, Raspberry Pi, or Linux Laptop)
27
+
28
+ ```bash
29
+ # Option A: Install directly from official PyPI:
30
+ pip install openswarm-slave
31
+
32
+ # Option B: Clone and install locally for development:
33
+ git clone https://github.com/raihhann/OpenSwarm.git
34
+ cd OpenSwarm/slaves/linux
35
+ pip install -e .
36
+ ```
37
+
38
+ ---
39
+
40
+ ## 2. Instant Terminal Launch (`openswarm-slave` CLI)
41
+
42
+ You can turn any Linux machine into an active Slave node with a single command:
43
+
44
+ ```bash
45
+ openswarm-slave --id portenta_x8 --led-pin 2
46
+ ```
47
+
48
+ Then from your Master terminal (or another terminal on the same laptop!), test it immediately using `openswarm-master`:
49
+
50
+ ```bash
51
+ openswarm scan
52
+ openswarm blink portenta_x8 --ip 127.0.0.1
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 3. Declarative Python Slave Script (`linux_slave_node.py`)
58
+
59
+ Just like `void setup()` on the microcontroller, you **only declare attached pins and sensors** (supports both `snake_case` and Arduino `camelCase` methods):
60
+
61
+ ```python
62
+ from openswarm_slave import SwarmNode
63
+
64
+ node = SwarmNode("uno_q_node")
65
+
66
+ # Declare attached hardware ONLY — Zero Control Logic!
67
+ node.register_digital_output("led", 2)
68
+ node.register_l298n("drive", 14, 27, 26, 32, 25, 33)
69
+ node.register_ultrasonic("front_sonar", 5, 18)
70
+ node.register_cpu_temp("cpu_temp")
71
+ node.register_system_load("sys_load")
72
+ node.register_wifi_rssi("rssi")
73
+
74
+ # Start background daemon (500ms safety watchdog + 20Hz telemetry)
75
+ node.start()
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 4. Supported Linux Hardware Backends
81
+ * **GPIO & PWM:** Automatically uses `RPi.GPIO` (on Raspberry Pi), Linux kernel `/sys/class/gpio` (on **Portenta X8**, **Arduino UNO Q**, **Jetson**), or **Virtual GPIO Simulation Mode** when testing on a standard Linux/Windows laptop.
82
+ * **Buses (`I2C`, `SPI`, `UART`):** Supports `/dev/i2c-*` (`register_mpu6050`, `register_lsm6dsox`, `register_generic_i2c`, `register_i2c_actuator`) and `/dev/tty*` (`register_uart_sensor`, `register_uart_actuator`).
83
+ * **Linux System Telemetry:** `register_cpu_temp()` (`°C`), `register_system_load()` (`[load_1m, ram_%]`), and `register_wifi_rssi()` (`dBm`).
@@ -0,0 +1,10 @@
1
+ openswarm_slave/__init__.py,sha256=HxGdiqQrG_Q4C5lRKthViW1KmPjwzVoMfRQHSJ29j-k,403
2
+ openswarm_slave/cli.py,sha256=-e0xDmLJPU598i7sYn-JcMcn_MmZrCKp9N9MGYa5N9c,960
3
+ openswarm_slave/hal_linux.py,sha256=nf5r717RPHWDUXkKHSYRMwalg8hPoPlLxTNHPlPS5T0,14764
4
+ openswarm_slave/node.py,sha256=ep8tKBDIUtkXh9ESU3sKzxksPRuh2NVmkzgc1--rdaw,16904
5
+ openswarm_slave-0.1.0.dist-info/licenses/LICENSE,sha256=AZ9akQ6SlsVf50LtUBr5TWjE-DqqK0JcgRZfdHmhscs,1081
6
+ openswarm_slave-0.1.0.dist-info/METADATA,sha256=HCbk9IxirHaEEq8a1BE9PtPz2iEcF1O6SuEZa1VpC-E,3379
7
+ openswarm_slave-0.1.0.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
8
+ openswarm_slave-0.1.0.dist-info/entry_points.txt,sha256=KJsb_NJ4RRv1xcYa5V8KlJRZKlGosi4lxkWyDrvTsNE,61
9
+ openswarm_slave-0.1.0.dist-info/top_level.txt,sha256=BO9SyiyjXpynj5n5kSC9OACVBhVc-2EOgKXHsR4vEqQ,16
10
+ openswarm_slave-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.3.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ openswarm-slave = openswarm_slave.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohammed Raihan Soniwala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ openswarm_slave