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.
- openswarm_slave/__init__.py +12 -0
- openswarm_slave/cli.py +27 -0
- openswarm_slave/hal_linux.py +381 -0
- openswarm_slave/node.py +411 -0
- openswarm_slave-0.1.0.dist-info/METADATA +83 -0
- openswarm_slave-0.1.0.dist-info/RECORD +10 -0
- openswarm_slave-0.1.0.dist-info/WHEEL +5 -0
- openswarm_slave-0.1.0.dist-info/entry_points.txt +2 -0
- openswarm_slave-0.1.0.dist-info/licenses/LICENSE +21 -0
- openswarm_slave-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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]
|
openswarm_slave/node.py
ADDED
|
@@ -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,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
|