openswarm-master 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/__init__.py +12 -0
- openswarm/cli.py +76 -0
- openswarm/master.py +223 -0
- openswarm/node.py +181 -0
- openswarm_master-0.1.0.dist-info/METADATA +82 -0
- openswarm_master-0.1.0.dist-info/RECORD +9 -0
- openswarm_master-0.1.0.dist-info/WHEEL +5 -0
- openswarm_master-0.1.0.dist-info/entry_points.txt +2 -0
- openswarm_master-0.1.0.dist-info/top_level.txt +1 -0
openswarm/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenSwarm Master SDK
|
|
3
|
+
====================
|
|
4
|
+
Zero-Control-Logic Distributed Hardware Abstraction Layer (Remote HAL) Master.
|
|
5
|
+
All robot intelligence, sensor reading, and actuator control runs here on the Master.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .master import SwarmMaster
|
|
9
|
+
from .node import RemoteSwarmNode
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__all__ = ["SwarmMaster", "RemoteSwarmNode"]
|
openswarm/cli.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenSwarm Command-Line Interface (`openswarm`)
|
|
3
|
+
Allows scanning for Slave nodes, monitoring live telemetry, and testing LED/pins directly from terminal.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import time
|
|
8
|
+
from .master import SwarmMaster
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def cmd_scan(args: argparse.Namespace) -> None:
|
|
12
|
+
master = SwarmMaster()
|
|
13
|
+
print(f"Scanning local network for OpenSwarm Slave nodes ({args.wait:.1f}s)...")
|
|
14
|
+
nodes = master.discover_all(wait_seconds=args.wait)
|
|
15
|
+
if not nodes:
|
|
16
|
+
print("No Slave nodes discovered via UDP broadcast.")
|
|
17
|
+
print("Tip: If your Wi-Fi router blocks UDP broadcast, use `--ip <SLAVE_IP>` in commands.")
|
|
18
|
+
else:
|
|
19
|
+
print(f"\nDiscovered {len(nodes)} Slave Node(s):")
|
|
20
|
+
print("-" * 78)
|
|
21
|
+
for node in nodes.values():
|
|
22
|
+
print(f" Node ID : {node.node_id} ({node.board})")
|
|
23
|
+
print(f" Address : {node.ip}:{node.port}")
|
|
24
|
+
print(f" Actuators : {node.declared_actuators}")
|
|
25
|
+
print(f" Sensors : {node.sensors}")
|
|
26
|
+
print("-" * 78)
|
|
27
|
+
master.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cmd_blink(args: argparse.Namespace) -> None:
|
|
31
|
+
master = SwarmMaster()
|
|
32
|
+
node = master.connect(args.node, ip=args.ip, timeout=args.timeout)
|
|
33
|
+
print(f"\nConnected to '{node.node_id}' at {node.ip}:{node.port}!")
|
|
34
|
+
print(f"Blinking '{args.actuator}' (Ctrl+C to stop)...\n")
|
|
35
|
+
|
|
36
|
+
state = False
|
|
37
|
+
try:
|
|
38
|
+
while True:
|
|
39
|
+
state = not state
|
|
40
|
+
node.digital_write(args.actuator, state)
|
|
41
|
+
print(
|
|
42
|
+
f" -> LED={'ON ' if state else 'OFF'} | "
|
|
43
|
+
f"Uptime={node.uptime_ms / 1000.0:.1f}s | "
|
|
44
|
+
f"Sensors={node.sensors}"
|
|
45
|
+
)
|
|
46
|
+
time.sleep(args.interval)
|
|
47
|
+
except KeyboardInterrupt:
|
|
48
|
+
print("\nTurning LED OFF and closing Master connection...")
|
|
49
|
+
node.digital_write(args.actuator, 0)
|
|
50
|
+
master.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> None:
|
|
54
|
+
parser = argparse.ArgumentParser(description="OpenSwarm Linux Master CLI")
|
|
55
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
# openswarm scan
|
|
58
|
+
p_scan = sub.add_parser("scan", help="Auto-discover all OpenSwarm Slave nodes on Wi-Fi")
|
|
59
|
+
p_scan.add_argument("--wait", type=float, default=2.5, help="Scan duration in seconds")
|
|
60
|
+
p_scan.set_defaults(func=cmd_scan)
|
|
61
|
+
|
|
62
|
+
# openswarm blink <node_id>
|
|
63
|
+
p_blink = sub.add_parser("blink", help="Blink a registered LED/Digital output on a Slave node")
|
|
64
|
+
p_blink.add_argument("node", nargs="?", default="esp32_node", help="Slave Node ID (default: esp32_node)")
|
|
65
|
+
p_blink.add_argument("--ip", type=str, default=None, help="Optional Slave IP address (e.g. 192.168.0.118)")
|
|
66
|
+
p_blink.add_argument("--actuator", type=str, default="led", help="Actuator name (default: led)")
|
|
67
|
+
p_blink.add_argument("--interval", type=float, default=0.5, help="Blink interval in seconds")
|
|
68
|
+
p_blink.add_argument("--timeout", type=float, default=8.0, help="Discovery timeout in seconds")
|
|
69
|
+
p_blink.set_defaults(func=cmd_blink)
|
|
70
|
+
|
|
71
|
+
args = parser.parse_args()
|
|
72
|
+
args.func(args)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
openswarm/master.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SwarmMaster — Central Linux/Cross-Platform Master Engine for OpenSwarm.
|
|
3
|
+
- Listens on UDP Port 4211 for Slave auto-discovery broadcasts ("HELLO").
|
|
4
|
+
- Sends low-latency actuator commands and 5 Hz watchdog heartbeats ("PING") over UDP Port 4210.
|
|
5
|
+
- Receives 20 Hz sensor telemetry streams ("STATE") from all connected Slaves in a background thread.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import socket
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from .node import RemoteSwarmNode
|
|
15
|
+
|
|
16
|
+
SWARM_DEFAULT_SLAVE_PORT = 4210
|
|
17
|
+
SWARM_DISCOVERY_PORT = 4211
|
|
18
|
+
SWARM_HEARTBEAT_SEC = 0.20 # 200ms keepalive (well below the 500ms Slave hardware watchdog)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SwarmMaster:
|
|
22
|
+
def __init__(self, discovery_port: int = SWARM_DISCOVERY_PORT, heartbeat_sec: float = SWARM_HEARTBEAT_SEC):
|
|
23
|
+
self.discovery_port = discovery_port
|
|
24
|
+
self.heartbeat_sec = heartbeat_sec
|
|
25
|
+
self._nodes: Dict[str, RemoteSwarmNode] = {}
|
|
26
|
+
self._lock = threading.Lock()
|
|
27
|
+
self._running = True
|
|
28
|
+
|
|
29
|
+
# 1. Command & Telemetry Socket (ephemeral port; Slaves reply with STATE to this socket)
|
|
30
|
+
self._cmd_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
31
|
+
self._cmd_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
32
|
+
self._cmd_sock.bind(("0.0.0.0", 0))
|
|
33
|
+
self._cmd_sock.settimeout(0.05)
|
|
34
|
+
|
|
35
|
+
# 2. Discovery Socket (listens on UDP 4211 for Slave HELLO broadcasts)
|
|
36
|
+
self._disc_sock: Optional[socket.socket] = None
|
|
37
|
+
try:
|
|
38
|
+
self._disc_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
39
|
+
self._disc_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
40
|
+
if hasattr(socket, "SO_BROADCAST"):
|
|
41
|
+
self._disc_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
42
|
+
self._disc_sock.bind(("0.0.0.0", self.discovery_port))
|
|
43
|
+
self._disc_sock.settimeout(0.05)
|
|
44
|
+
except OSError:
|
|
45
|
+
# If another process is already bound to 4211, direct IP connect still works
|
|
46
|
+
self._disc_sock = None
|
|
47
|
+
|
|
48
|
+
# Start background network & heartbeat daemon threads
|
|
49
|
+
self._rx_thread = threading.Thread(target=self._network_loop, daemon=True, name="OpenSwarmRx")
|
|
50
|
+
self._hb_thread = threading.Thread(target=self._heartbeat_loop, daemon=True, name="OpenSwarmHeartbeat")
|
|
51
|
+
self._rx_thread.start()
|
|
52
|
+
self._hb_thread.start()
|
|
53
|
+
|
|
54
|
+
# -------------------------------------------------------------------------
|
|
55
|
+
# Public Discovery & Connection API
|
|
56
|
+
# -------------------------------------------------------------------------
|
|
57
|
+
def connect(
|
|
58
|
+
self,
|
|
59
|
+
node_id: str,
|
|
60
|
+
ip: Optional[str] = None,
|
|
61
|
+
port: int = SWARM_DEFAULT_SLAVE_PORT,
|
|
62
|
+
timeout: float = 10.0,
|
|
63
|
+
) -> RemoteSwarmNode:
|
|
64
|
+
"""
|
|
65
|
+
Connects to a Slave node by its Node ID (e.g. 'esp32_node').
|
|
66
|
+
- If `ip` is provided (e.g. ip='192.168.0.118'), connects immediately and verifies telemetry.
|
|
67
|
+
- Otherwise, waits up to `timeout` seconds for the Slave's UDP auto-discovery broadcast.
|
|
68
|
+
"""
|
|
69
|
+
if ip is not None:
|
|
70
|
+
with self._lock:
|
|
71
|
+
if node_id not in self._nodes:
|
|
72
|
+
self._nodes[node_id] = RemoteSwarmNode(
|
|
73
|
+
node_id=node_id,
|
|
74
|
+
ip=ip,
|
|
75
|
+
port=port,
|
|
76
|
+
send_fn=self._send_json,
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
self._nodes[node_id].ip = ip
|
|
80
|
+
self._nodes[node_id].port = port
|
|
81
|
+
node = self._nodes[node_id]
|
|
82
|
+
|
|
83
|
+
# Immediately send a PING so the Slave starts streaming 20Hz telemetry to us
|
|
84
|
+
node.ping()
|
|
85
|
+
time.sleep(0.1)
|
|
86
|
+
return node
|
|
87
|
+
|
|
88
|
+
print(f"[OpenSwarm Master] Waiting for Slave '{node_id}' on UDP port {self.discovery_port}...")
|
|
89
|
+
start = time.time()
|
|
90
|
+
while (time.time() - start) < timeout:
|
|
91
|
+
with self._lock:
|
|
92
|
+
if node_id in self._nodes:
|
|
93
|
+
node = self._nodes[node_id]
|
|
94
|
+
node.ping()
|
|
95
|
+
print(
|
|
96
|
+
f"[OpenSwarm Master] Discovered '{node.node_id}' ({node.board}) "
|
|
97
|
+
f"at {node.ip}:{node.port} | Actuators: {node.declared_actuators} | Sensors: {node.declared_sensors}"
|
|
98
|
+
)
|
|
99
|
+
return node
|
|
100
|
+
time.sleep(0.05)
|
|
101
|
+
|
|
102
|
+
raise TimeoutError(
|
|
103
|
+
f"Could not auto-discover Slave '{node_id}' within {timeout}s. "
|
|
104
|
+
f"Make sure your laptop and ESP32 are on the same Wi-Fi network, or pass ip='192.168.x.x' directly: "
|
|
105
|
+
f"master.connect('{node_id}', ip='192.168.0.118')"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def discover_all(self, wait_seconds: float = 2.5) -> Dict[str, RemoteSwarmNode]:
|
|
109
|
+
"""Listens for `wait_seconds` and returns all discovered Slave nodes on the network."""
|
|
110
|
+
time.sleep(wait_seconds)
|
|
111
|
+
with self._lock:
|
|
112
|
+
return dict(self._nodes)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def nodes(self) -> Dict[str, RemoteSwarmNode]:
|
|
116
|
+
with self._lock:
|
|
117
|
+
return dict(self._nodes)
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
"""Stops all connected Slave motors and closes Master sockets."""
|
|
121
|
+
self._running = False
|
|
122
|
+
with self._lock:
|
|
123
|
+
for node in self._nodes.values():
|
|
124
|
+
try:
|
|
125
|
+
node.stop()
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
try:
|
|
129
|
+
self._cmd_sock.close()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
if self._disc_sock:
|
|
133
|
+
try:
|
|
134
|
+
self._disc_sock.close()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# -------------------------------------------------------------------------
|
|
139
|
+
# Internal UDP Transport & Background Threads
|
|
140
|
+
# -------------------------------------------------------------------------
|
|
141
|
+
def _send_json(self, ip: str, port: int, payload: Dict[str, Any]) -> None:
|
|
142
|
+
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
143
|
+
try:
|
|
144
|
+
self._cmd_sock.sendto(raw, (ip, port))
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def _network_loop(self) -> None:
|
|
149
|
+
while self._running:
|
|
150
|
+
# 1. Check Discovery Socket (HELLO broadcasts on port 4211)
|
|
151
|
+
if self._disc_sock is not None:
|
|
152
|
+
try:
|
|
153
|
+
data, addr = self._disc_sock.recvfrom(2048)
|
|
154
|
+
self._handle_packet(data, addr)
|
|
155
|
+
except socket.timeout:
|
|
156
|
+
pass
|
|
157
|
+
except OSError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# 2. Check Command/Telemetry Socket (STATE telemetry packets)
|
|
161
|
+
try:
|
|
162
|
+
data, addr = self._cmd_sock.recvfrom(2048)
|
|
163
|
+
self._handle_packet(data, addr)
|
|
164
|
+
except socket.timeout:
|
|
165
|
+
pass
|
|
166
|
+
except OSError:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
def _handle_packet(self, data: bytes, addr: tuple) -> None:
|
|
170
|
+
try:
|
|
171
|
+
msg = json.loads(data.decode("utf-8", errors="ignore"))
|
|
172
|
+
except Exception:
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
pkt_type = msg.get("type")
|
|
176
|
+
node_id = msg.get("node")
|
|
177
|
+
if not node_id:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
sender_ip = msg.get("ip") or addr[0]
|
|
181
|
+
sender_port = int(msg.get("port") or SWARM_DEFAULT_SLAVE_PORT)
|
|
182
|
+
|
|
183
|
+
with self._lock:
|
|
184
|
+
if pkt_type == "HELLO":
|
|
185
|
+
board = msg.get("board", "UNKNOWN")
|
|
186
|
+
actuators = msg.get("actuators", [])
|
|
187
|
+
sensors = msg.get("sensors", [])
|
|
188
|
+
if node_id not in self._nodes:
|
|
189
|
+
node = RemoteSwarmNode(
|
|
190
|
+
node_id=node_id,
|
|
191
|
+
ip=sender_ip,
|
|
192
|
+
port=sender_port,
|
|
193
|
+
send_fn=self._send_json,
|
|
194
|
+
board=board,
|
|
195
|
+
actuators=actuators,
|
|
196
|
+
sensors=sensors,
|
|
197
|
+
)
|
|
198
|
+
self._nodes[node_id] = node
|
|
199
|
+
# Trigger initial PING so Slave starts streaming STATE
|
|
200
|
+
node.ping()
|
|
201
|
+
else:
|
|
202
|
+
self._nodes[node_id]._update_hello(sender_ip, sender_port, board, actuators, sensors)
|
|
203
|
+
|
|
204
|
+
elif pkt_type == "STATE":
|
|
205
|
+
uptime_ms = int(msg.get("uptime_ms", 0))
|
|
206
|
+
sensors_dict = msg.get("sensors", {})
|
|
207
|
+
if node_id not in self._nodes:
|
|
208
|
+
self._nodes[node_id] = RemoteSwarmNode(
|
|
209
|
+
node_id=node_id,
|
|
210
|
+
ip=addr[0],
|
|
211
|
+
port=addr[1],
|
|
212
|
+
send_fn=self._send_json,
|
|
213
|
+
sensors=list(sensors_dict.keys()),
|
|
214
|
+
)
|
|
215
|
+
self._nodes[node_id]._update_state(uptime_ms, sensors_dict)
|
|
216
|
+
|
|
217
|
+
def _heartbeat_loop(self) -> None:
|
|
218
|
+
while self._running:
|
|
219
|
+
with self._lock:
|
|
220
|
+
nodes_list = list(self._nodes.values())
|
|
221
|
+
for node in nodes_list:
|
|
222
|
+
node.ping()
|
|
223
|
+
time.sleep(self.heartbeat_sec)
|
openswarm/node.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RemoteSwarmNode — Represents a single connected Slave node (ESP32, UNO, Nano, RP2040, or Linux SBC).
|
|
3
|
+
Exposes clean Python methods to read live sensors and command any actuator, pin, or bus protocol.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RemoteSwarmNode:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
node_id: str,
|
|
14
|
+
ip: str,
|
|
15
|
+
port: int,
|
|
16
|
+
send_fn: Callable[[str, int, Dict[str, Any]], None],
|
|
17
|
+
board: str = "UNKNOWN",
|
|
18
|
+
actuators: Optional[List[str]] = None,
|
|
19
|
+
sensors: Optional[List[str]] = None,
|
|
20
|
+
):
|
|
21
|
+
self.node_id: str = node_id
|
|
22
|
+
self.ip: str = ip
|
|
23
|
+
self.port: int = port
|
|
24
|
+
self.board: str = board
|
|
25
|
+
self.declared_actuators: List[str] = actuators or []
|
|
26
|
+
self.declared_sensors: List[str] = sensors or []
|
|
27
|
+
|
|
28
|
+
self._send_fn = send_fn
|
|
29
|
+
self._sensor_values: Dict[str, Union[float, List[float]]] = {}
|
|
30
|
+
self.uptime_ms: int = 0
|
|
31
|
+
self.last_seen: float = time.time()
|
|
32
|
+
|
|
33
|
+
# -------------------------------------------------------------------------
|
|
34
|
+
# Internal state updates (called by SwarmMaster background thread)
|
|
35
|
+
# -------------------------------------------------------------------------
|
|
36
|
+
def _update_hello(self, ip: str, port: int, board: str, actuators: List[str], sensors: List[str]) -> None:
|
|
37
|
+
self.ip = ip
|
|
38
|
+
self.port = port
|
|
39
|
+
self.board = board
|
|
40
|
+
self.declared_actuators = actuators
|
|
41
|
+
self.declared_sensors = sensors
|
|
42
|
+
self.last_seen = time.time()
|
|
43
|
+
|
|
44
|
+
def _update_state(self, uptime_ms: int, sensors_dict: Dict[str, Any]) -> None:
|
|
45
|
+
self.uptime_ms = uptime_ms
|
|
46
|
+
self._sensor_values.update(sensors_dict)
|
|
47
|
+
self.last_seen = time.time()
|
|
48
|
+
|
|
49
|
+
# -------------------------------------------------------------------------
|
|
50
|
+
# Sensor Reading API
|
|
51
|
+
# -------------------------------------------------------------------------
|
|
52
|
+
@property
|
|
53
|
+
def sensors(self) -> Dict[str, Union[float, List[float]]]:
|
|
54
|
+
"""Returns a dictionary of the latest 20Hz streamed sensor values."""
|
|
55
|
+
return dict(self._sensor_values)
|
|
56
|
+
|
|
57
|
+
def read(self, sensor_name: str, default: Any = 0.0) -> Union[float, List[float]]:
|
|
58
|
+
"""Reads the latest value of a named sensor (e.g., node.read('rssi') or node.read('front_sonar'))."""
|
|
59
|
+
return self._sensor_values.get(sensor_name, default)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_online(self) -> bool:
|
|
63
|
+
"""Returns True if we heard from this Slave within the last 2.0 seconds."""
|
|
64
|
+
return (time.time() - self.last_seen) < 2.0
|
|
65
|
+
|
|
66
|
+
# -------------------------------------------------------------------------
|
|
67
|
+
# Pin & GPIO Control API
|
|
68
|
+
# -------------------------------------------------------------------------
|
|
69
|
+
def digital_write(self, name: str, state: Union[int, bool]) -> None:
|
|
70
|
+
"""
|
|
71
|
+
Writes HIGH (1/True) or LOW (0/False) to a registered digital output pin
|
|
72
|
+
(e.g., node.digital_write('led', 1)).
|
|
73
|
+
"""
|
|
74
|
+
val = 1 if state else 0
|
|
75
|
+
self._send_fn(self.ip, self.port, {"cmd": "ACT", "name": name, "value": val})
|
|
76
|
+
|
|
77
|
+
def pwm_write(self, name_or_pin: Union[str, int], duty: int) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Writes a PWM duty cycle (0..255) either to a registered PWM output name
|
|
80
|
+
or directly to a raw hardware pin number.
|
|
81
|
+
"""
|
|
82
|
+
duty_clamped = max(0, min(255, int(duty)))
|
|
83
|
+
if isinstance(name_or_pin, int):
|
|
84
|
+
self._send_fn(self.ip, self.port, {"cmd": "PIN", "pin": name_or_pin, "value": duty_clamped, "pwm": 1})
|
|
85
|
+
else:
|
|
86
|
+
self._send_fn(self.ip, self.port, {"cmd": "ACT", "name": name_or_pin, "value": duty_clamped})
|
|
87
|
+
|
|
88
|
+
def pin_write(self, pin: int, value: Union[int, bool], pwm: bool = False) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Direct raw GPIO pin control by pin number (even if not registered in setup()).
|
|
91
|
+
Example: node.pin_write(2, 1) -> sets GPIO 2 HIGH.
|
|
92
|
+
"""
|
|
93
|
+
val = int(value)
|
|
94
|
+
self._send_fn(
|
|
95
|
+
self.ip,
|
|
96
|
+
self.port,
|
|
97
|
+
{"cmd": "PIN", "pin": int(pin), "value": val, "pwm": 1 if pwm else 0},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# -------------------------------------------------------------------------
|
|
101
|
+
# Motor, Servo & Stepper Control API
|
|
102
|
+
# -------------------------------------------------------------------------
|
|
103
|
+
def drive(self, name: str, left: int, right: int = 0) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Drives a dual H-Bridge (L298N / DRV8833) or single 2-pin DC motor (-255 to +255).
|
|
106
|
+
Example: node.drive('drive', left=180, right=180)
|
|
107
|
+
"""
|
|
108
|
+
self._send_fn(
|
|
109
|
+
self.ip,
|
|
110
|
+
self.port,
|
|
111
|
+
{"cmd": "ACT", "name": name, "left": int(left), "right": int(right)},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def servo(self, name: str, angle: int) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Sets a hobby servo angle (0 to 180 degrees).
|
|
117
|
+
Example: node.servo('gripper_servo', 90)
|
|
118
|
+
"""
|
|
119
|
+
self._send_fn(
|
|
120
|
+
self.ip,
|
|
121
|
+
self.port,
|
|
122
|
+
{"cmd": "ACT", "name": name, "angle": int(angle)},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def stepper(self, name: str, steps: int, step_delay_us: int = 800) -> None:
|
|
126
|
+
"""
|
|
127
|
+
Commands a Step/Dir stepper driver (positive = forward, negative = reverse).
|
|
128
|
+
Example: node.stepper('lifter_step', steps=200, step_delay_us=800)
|
|
129
|
+
"""
|
|
130
|
+
self._send_fn(
|
|
131
|
+
self.ip,
|
|
132
|
+
self.port,
|
|
133
|
+
{"cmd": "ACT", "name": name, "steps": int(steps), "v2": int(step_delay_us)},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def stop(self) -> None:
|
|
137
|
+
"""Immediately stops all motors on this Slave node."""
|
|
138
|
+
self._send_fn(self.ip, self.port, {"cmd": "STOP"})
|
|
139
|
+
|
|
140
|
+
# -------------------------------------------------------------------------
|
|
141
|
+
# Universal Bus & Custom Actuator API (LoRa, SPI, I2C, UART, Custom)
|
|
142
|
+
# -------------------------------------------------------------------------
|
|
143
|
+
def send_bus(self, name: str, data: str = "", v1: int = 0, v2: int = 0) -> None:
|
|
144
|
+
"""
|
|
145
|
+
Transmits a string payload or numeric values over a registered LoRa, UART, or Custom actuator.
|
|
146
|
+
Example: node.send_bus('lora_radio', data='HELLO_SWARM')
|
|
147
|
+
"""
|
|
148
|
+
self._send_fn(
|
|
149
|
+
self.ip,
|
|
150
|
+
self.port,
|
|
151
|
+
{"cmd": "ACT", "name": name, "v1": int(v1), "v2": int(v2), "data": str(data)},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def write_reg(self, name: str, reg: int, val: int) -> None:
|
|
155
|
+
"""
|
|
156
|
+
Writes a register byte [reg, val] to a registered I2C or SPI actuator device.
|
|
157
|
+
Example: node.write_reg('i2c_pwm_chip', reg=0x06, val=128)
|
|
158
|
+
"""
|
|
159
|
+
self._send_fn(
|
|
160
|
+
self.ip,
|
|
161
|
+
self.port,
|
|
162
|
+
{"cmd": "ACT", "name": name, "reg": int(reg), "val": int(val)},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def set_actuator(self, name: str, v1: int = 0, v2: int = 0, data: str = "") -> None:
|
|
166
|
+
"""Generic low-level actuator command."""
|
|
167
|
+
payload: Dict[str, Any] = {"cmd": "ACT", "name": name, "v1": int(v1), "v2": int(v2)}
|
|
168
|
+
if data:
|
|
169
|
+
payload["data"] = data
|
|
170
|
+
self._send_fn(self.ip, self.port, payload)
|
|
171
|
+
|
|
172
|
+
def ping(self) -> None:
|
|
173
|
+
"""Sends a heartbeat PING to request immediate telemetry and reset the Slave watchdog."""
|
|
174
|
+
self._send_fn(self.ip, self.port, {"cmd": "PING"})
|
|
175
|
+
|
|
176
|
+
def __repr__(self) -> str:
|
|
177
|
+
return (
|
|
178
|
+
f"<RemoteSwarmNode id='{self.node_id}' board='{self.board}' "
|
|
179
|
+
f"ip='{self.ip}:{self.port}' actuators={self.declared_actuators} "
|
|
180
|
+
f"sensors={self.declared_sensors}>"
|
|
181
|
+
)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openswarm-master
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Central Linux Master SDK & Runtime for the OpenSwarm Distributed Robotics Framework
|
|
5
|
+
Author-email: Mohammed Raihan Soniwala <raihann.eu@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: robotics,swarm-robotics,esp32,arduino,hal,distributed-systems
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# OpenSwarm Master (`openswarm-master`)
|
|
12
|
+
|
|
13
|
+
The Central Linux Master SDK & CLI for the [**OpenSwarm**](https://github.com/raihhann/OpenSwarm) Distributed Hardware-Agnostic Robotics Framework.
|
|
14
|
+
|
|
15
|
+
* **Zero Slave Control Logic:** All sensor processing, state machines, kinematics, and actuator commands run here on the Master.
|
|
16
|
+
* **Zero External Dependencies:** Pure Python 3 standard library (`socket`, `threading`, `json`) for instant installation on any Linux laptop, Ubuntu PC, Raspberry Pi, or Windows machine.
|
|
17
|
+
* **Auto-Discovery + Direct IP:** Automatically discovers Slaves via UDP port `4211` (`HELLO` broadcast) or connects directly by IP (`192.168.x.x:4210`).
|
|
18
|
+
* **Built-In Keepalive:** Sends a 200 ms background heartbeat (`PING`) to keep the Slave's 500 ms hardware safety watchdog active and stream 20 Hz sensor telemetry (`STATE`).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. Installation
|
|
23
|
+
|
|
24
|
+
### Option A: Install Directly from GitHub
|
|
25
|
+
```bash
|
|
26
|
+
pip install "git+https://github.com/raihhann/OpenSwarm.git#subdirectory=master"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Option B: Clone & Install in Editable Mode (Recommended for Development)
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/raihhann/OpenSwarm.git
|
|
32
|
+
cd OpenSwarm/master
|
|
33
|
+
pip install -e .
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 2. Terminal CLI (`openswarm`)
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Scan the local Wi-Fi network for active Slave nodes:
|
|
42
|
+
openswarm scan
|
|
43
|
+
|
|
44
|
+
# Blink the built-in LED ("led" on GPIO 2) on esp32_node and print live 20 Hz RSSI:
|
|
45
|
+
openswarm blink esp32_node --ip 192.168.0.118
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 3. Python API Reference
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import time
|
|
54
|
+
from openswarm import SwarmMaster
|
|
55
|
+
|
|
56
|
+
master = SwarmMaster()
|
|
57
|
+
|
|
58
|
+
# Connect by Node ID (auto-discovers on LAN, or pass ip="192.168.0.118" directly)
|
|
59
|
+
bot = master.connect("esp32_node", ip="192.168.0.118")
|
|
60
|
+
|
|
61
|
+
# 1. Digital & PWM Pin Control
|
|
62
|
+
bot.digital_write("led", 1) # Named digital output HIGH
|
|
63
|
+
bot.digital_write("led", 0) # Named digital output LOW
|
|
64
|
+
bot.pwm_write("led", 128) # Named PWM output (0..255)
|
|
65
|
+
bot.pin_write(2, 1) # Direct GPIO pin number write
|
|
66
|
+
|
|
67
|
+
# 2. Motor, Servo & Stepper Control
|
|
68
|
+
bot.drive("drive", left=180, right=180) # Dual H-Bridge (L298N / DRV8833)
|
|
69
|
+
bot.servo("gripper", angle=90) # Hobby Servo (0..180 deg)
|
|
70
|
+
bot.stepper("lifter", steps=200) # Stepper driver
|
|
71
|
+
bot.stop() # Immediate emergency stop
|
|
72
|
+
|
|
73
|
+
# 3. Universal Bus & Custom Actuator Control (LoRa, SPI, I2C, UART)
|
|
74
|
+
bot.send_bus("lora_radio", data="PING_SWARM")
|
|
75
|
+
bot.write_reg("i2c_pwm_chip", reg=0x06, val=200)
|
|
76
|
+
|
|
77
|
+
# 4. Live 20 Hz Sensor Telemetry
|
|
78
|
+
print("RSSI:", bot.read("rssi"))
|
|
79
|
+
print("All Sensors:", bot.sensors)
|
|
80
|
+
|
|
81
|
+
master.close()
|
|
82
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
openswarm/__init__.py,sha256=Ti9psc5Gf_fcJjynmMzEygc-bSmZB6jHrEadeji01ZM,350
|
|
2
|
+
openswarm/cli.py,sha256=TY7fcBT2aKvUCwVqoOACfqRbwidMWADONFDOcG75mnY,3042
|
|
3
|
+
openswarm/master.py,sha256=5S44Bh03s7BByY2eWmDFIswHZopOcLhus1Cav47KtJs,8994
|
|
4
|
+
openswarm/node.py,sha256=hjSLwsYKvslRVXGWZf49JmkdZbjeRWgQR6R-9dsPboI,7468
|
|
5
|
+
openswarm_master-0.1.0.dist-info/METADATA,sha256=dKQmlRu_oeRnR-9UW4Jveqv3CHQ1SlbapFiW5nqEy0c,3061
|
|
6
|
+
openswarm_master-0.1.0.dist-info/WHEEL,sha256=0CuiUZ_p9E4cD6NyLD6UG80LBXYyiSYZOKDm5lp32xk,91
|
|
7
|
+
openswarm_master-0.1.0.dist-info/entry_points.txt,sha256=j_tjlaID1-3rpK5TC3PfBf8jvCE5ow9sirBoHfKM47M,49
|
|
8
|
+
openswarm_master-0.1.0.dist-info/top_level.txt,sha256=xw4v2UTMTsXuf200nzQ8OiYwa3do7gDpiIiTtW92tFE,10
|
|
9
|
+
openswarm_master-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openswarm
|