nwaves-leader 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: nwaves-leader
3
+ Version: 0.1.1
4
+ Summary: NWaves leader bridge: drive the NWaves SO-101 simulation with the leader arm plugged into your computer
5
+ Author: NWaves
6
+ Author-email: NWaves <support@nwaves.ai>
7
+ License: Proprietary
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: pyserial>=3.5
11
+ Dynamic: author
12
+ Dynamic: requires-python
13
+
14
+ # NWaves leader bridge
15
+
16
+ Drives the NWaves SO-101 simulation with the leader arm plugged into your own computer,
17
+ for browsers that cannot open USB devices themselves (Firefox, Safari). Chrome and Edge
18
+ connect over USB directly from the session page and do not need it.
19
+
20
+ Install and run (the session page shows the same one-liners):
21
+
22
+ macOS / Linux: curl -fsSL https://<academy host>/nwaves-leader.sh | sh
23
+ Windows: irm https://<academy host>/nwaves-leader.ps1 | iex
24
+ any: python3 -m pip install nwaves-leader then nwaves-leader
25
+
26
+ Then press "Connect through the NWaves leader bridge" in the session page.
27
+
28
+ The bridge listens on 127.0.0.1:8765 only, accepts connections from nwaves.ai pages only,
29
+ and sends nothing but the six servo positions to the page in your browser. The page does
30
+ the calibration and forwards the positions to your simulation.
31
+
32
+ What the academy hosts and how to rebuild it (from the repo root): `sh tools/nwaves-leader/build.sh`
33
+
34
+ - `nwaves-leader.pyz`: the self-contained app the installers download (pyserial is vendored, BSD licence
35
+ kept inside), runs with any Python 3.8+ as `python3 nwaves-leader.pyz`
36
+ - `nwaves_leader-<version>-py3-none-any.whl`: for `pip install` and for PyPI
37
+ - `nwaves-leader.py`: the single-file version (needs pyserial)
38
+
39
+ Publishing to PyPI (name `nwaves-leader` was free on 2026-10-17): create the NWaves account on pypi.org with
40
+ 2FA, make an API token, then `python3 -m pip install build twine && python3 -m build && twine upload dist/*`
41
+ with `TWINE_USERNAME=__token__ TWINE_PASSWORD=<token>`. Bump `version` in pyproject.toml and setup.py first.
@@ -0,0 +1,28 @@
1
+ # NWaves leader bridge
2
+
3
+ Drives the NWaves SO-101 simulation with the leader arm plugged into your own computer,
4
+ for browsers that cannot open USB devices themselves (Firefox, Safari). Chrome and Edge
5
+ connect over USB directly from the session page and do not need it.
6
+
7
+ Install and run (the session page shows the same one-liners):
8
+
9
+ macOS / Linux: curl -fsSL https://<academy host>/nwaves-leader.sh | sh
10
+ Windows: irm https://<academy host>/nwaves-leader.ps1 | iex
11
+ any: python3 -m pip install nwaves-leader then nwaves-leader
12
+
13
+ Then press "Connect through the NWaves leader bridge" in the session page.
14
+
15
+ The bridge listens on 127.0.0.1:8765 only, accepts connections from nwaves.ai pages only,
16
+ and sends nothing but the six servo positions to the page in your browser. The page does
17
+ the calibration and forwards the positions to your simulation.
18
+
19
+ What the academy hosts and how to rebuild it (from the repo root): `sh tools/nwaves-leader/build.sh`
20
+
21
+ - `nwaves-leader.pyz`: the self-contained app the installers download (pyserial is vendored, BSD licence
22
+ kept inside), runs with any Python 3.8+ as `python3 nwaves-leader.pyz`
23
+ - `nwaves_leader-<version>-py3-none-any.whl`: for `pip install` and for PyPI
24
+ - `nwaves-leader.py`: the single-file version (needs pyserial)
25
+
26
+ Publishing to PyPI (name `nwaves-leader` was free on 2026-10-17): create the NWaves account on pypi.org with
27
+ 2FA, make an API token, then `python3 -m pip install build twine && python3 -m build && twine upload dist/*`
28
+ with `TWINE_USERNAME=__token__ TWINE_PASSWORD=<token>`. Bump `version` in pyproject.toml and setup.py first.
@@ -0,0 +1,2 @@
1
+ """NWaves leader bridge: an SO-101 leader arm on your computer, driving the NWaves simulation."""
2
+ from .bridge import VERSION as __version__ # noqa: F401
@@ -0,0 +1,4 @@
1
+ from .bridge import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """NWaves leader bridge.
3
+
4
+ Lets a browser without USB access (Firefox, Safari, ...) drive the NWaves SO-101
5
+ simulation with the leader arm plugged into this computer. It reads the arm's six
6
+ servos over its USB adapter and hands the positions to the NWaves session page over
7
+ localhost; the page does the calibration and talks to your simulation.
8
+
9
+ python3 nwaves-leader.py # finds the arm's port by itself
10
+ python3 nwaves-leader.py --port COM5 (or /dev/cu.usbserial-XXXX, /dev/ttyACM0)
11
+
12
+ Needs Python 3.8+ and pyserial: python3 -m pip install pyserial
13
+ Then press "Connect through the NWaves leader bridge" in the session page.
14
+ """
15
+ import argparse
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import socket
20
+ import struct
21
+ import sys
22
+ import threading
23
+ import time
24
+
25
+ try:
26
+ import serial
27
+ from serial.tools import list_ports
28
+ except ImportError:
29
+ print("pyserial is missing. Install it with: python3 -m pip install pyserial")
30
+ sys.exit(1)
31
+
32
+ VERSION = "0.1.1"
33
+ IDS = [1, 2, 3, 4, 5, 6]
34
+ BAUD = 1000000
35
+ PRESENT_POSITION = 56
36
+ RATE_HZ = 30
37
+ HOST, PORT = "127.0.0.1", 8765
38
+ ALLOWED_ORIGIN_SUFFIXES = (".nwaves.ai", "//localhost", "//127.0.0.1")
39
+ KNOWN_ADAPTERS = {0x1A86, 0x10C4, 0x0403, 0x2341, 0x1D50} # CH34x, CP210x, FTDI, Arduino, generic
40
+
41
+
42
+ def checksum(data):
43
+ return (~sum(data)) & 0xFF
44
+
45
+
46
+ def sync_read_packet(ids, addr, length):
47
+ params = [addr, length] + list(ids)
48
+ body = [0xFE, len(params) + 2, 0x82] + params
49
+ return bytes([0xFF, 0xFF] + body + [checksum(body)])
50
+
51
+
52
+ class Arm:
53
+ def __init__(self, port):
54
+ self.ser = serial.Serial(port, BAUD, timeout=0.02)
55
+ self.pending = b""
56
+
57
+ def close(self):
58
+ try:
59
+ self.ser.close()
60
+ except Exception:
61
+ pass
62
+
63
+ def read_positions(self, timeout=0.04):
64
+ self.ser.reset_input_buffer()
65
+ self.ser.write(sync_read_packet(IDS, PRESENT_POSITION, 2))
66
+ want = set(IDS)
67
+ out = {}
68
+ deadline = time.monotonic() + timeout
69
+ buf = b""
70
+ while want and time.monotonic() < deadline:
71
+ chunk = self.ser.read(64)
72
+ if not chunk:
73
+ continue
74
+ buf += chunk
75
+ i = 0
76
+ while i + 5 < len(buf):
77
+ if buf[i] != 0xFF or buf[i + 1] != 0xFF:
78
+ i += 1
79
+ continue
80
+ sid, length = buf[i + 2], buf[i + 3]
81
+ end = i + 4 + length
82
+ if end > len(buf):
83
+ break
84
+ body = buf[i + 2:end - 1]
85
+ if checksum(body) == buf[end - 1]:
86
+ params = buf[i + 5:end - 1]
87
+ if sid in want and len(params) >= 2:
88
+ out[sid] = params[0] | (params[1] << 8)
89
+ want.discard(sid)
90
+ i = end
91
+ else:
92
+ i += 2
93
+ buf = buf[i:]
94
+ return [out.get(i) for i in IDS]
95
+
96
+
97
+ def find_port(explicit=None):
98
+ ports = list(list_ports.comports())
99
+ if explicit:
100
+ return explicit
101
+ known = [p for p in ports if (p.vid or 0) in KNOWN_ADAPTERS]
102
+ candidates = known or [p for p in ports if any(k in (p.device or "") for k in ("usbserial", "usbmodem", "ttyUSB", "ttyACM", "COM"))]
103
+ for p in candidates:
104
+ try:
105
+ arm = Arm(p.device)
106
+ if any(v is not None for v in arm.read_positions(0.2)):
107
+ arm.close()
108
+ return p.device
109
+ arm.close()
110
+ except Exception:
111
+ continue
112
+ return None
113
+
114
+
115
+ # ------------------------------------------------------------------ a minimal WebSocket server
116
+ def ws_accept(key):
117
+ return base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest()).decode()
118
+
119
+
120
+ def ws_send_text(conn, text):
121
+ data = text.encode()
122
+ header = bytes([0x81])
123
+ n = len(data)
124
+ if n < 126:
125
+ header += bytes([n])
126
+ elif n < 65536:
127
+ header += bytes([126]) + struct.pack(">H", n)
128
+ else:
129
+ header += bytes([127]) + struct.pack(">Q", n)
130
+ conn.sendall(header + data)
131
+
132
+
133
+ def serve(state):
134
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
135
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
136
+ srv.bind((HOST, PORT))
137
+ srv.listen(2)
138
+ print(f"[nwaves-leader] ready: press 'Connect through the NWaves leader bridge' in the session page")
139
+ while True:
140
+ conn, _ = srv.accept()
141
+ threading.Thread(target=handle_client, args=(conn, state), daemon=True).start()
142
+
143
+
144
+ def handle_client(conn, state):
145
+ try:
146
+ conn.settimeout(5)
147
+ req = b""
148
+ while b"\r\n\r\n" not in req:
149
+ chunk = conn.recv(4096)
150
+ if not chunk:
151
+ return
152
+ req += chunk
153
+ head = req.decode(errors="replace")
154
+ headers = {}
155
+ for line in head.split("\r\n")[1:]:
156
+ if ":" in line:
157
+ k, v = line.split(":", 1)
158
+ headers[k.strip().lower()] = v.strip()
159
+ origin = headers.get("origin", "")
160
+ if origin and not any(s in origin for s in ALLOWED_ORIGIN_SUFFIXES):
161
+ conn.sendall(b"HTTP/1.1 403 Forbidden\r\n\r\n")
162
+ return
163
+ key = headers.get("sec-websocket-key")
164
+ if not key:
165
+ conn.sendall(b"HTTP/1.1 400 Bad Request\r\n\r\nNWaves leader bridge: WebSocket clients only")
166
+ return
167
+ conn.sendall(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
168
+ f"Sec-WebSocket-Accept: {ws_accept(key)}\r\n\r\n").encode())
169
+ conn.settimeout(None)
170
+ print("[nwaves-leader] session page connected")
171
+ ws_send_text(conn, json.dumps({"hello": "nwaves-leader", "port": state["port"], "rate_hz": RATE_HZ}))
172
+ # Drain anything the page sends (pings, close) in a side thread.
173
+ def drain():
174
+ try:
175
+ while conn.recv(4096):
176
+ pass
177
+ except Exception:
178
+ pass
179
+ state["closed"].add(conn)
180
+ threading.Thread(target=drain, daemon=True).start()
181
+ last_sent = None
182
+ while conn not in state["closed"]:
183
+ raw = state["latest"]
184
+ if raw is not None and raw is not last_sent:
185
+ ws_send_text(conn, json.dumps({"raw": raw}))
186
+ last_sent = raw
187
+ if state["error"]:
188
+ ws_send_text(conn, json.dumps({"error": state["error"]}))
189
+ break
190
+ time.sleep(1.0 / RATE_HZ)
191
+ except Exception:
192
+ pass
193
+ finally:
194
+ try:
195
+ conn.close()
196
+ except Exception:
197
+ pass
198
+ print("[nwaves-leader] session page disconnected")
199
+
200
+
201
+ def list_ports_cmd():
202
+ ports = list(list_ports.comports())
203
+ if not ports:
204
+ print("[nwaves-leader] no serial ports found; plug the leader arm's USB cable in")
205
+ return 1
206
+ print("[nwaves-leader] serial ports on this computer:")
207
+ for p in ports:
208
+ tag = " (likely the arm's USB adapter)" if (p.vid or 0) in KNOWN_ADAPTERS else ""
209
+ print(f" {p.device} {p.description or ''}{tag}")
210
+ return 0
211
+
212
+
213
+ def main():
214
+ ap = argparse.ArgumentParser(prog="nwaves-leader",
215
+ description="NWaves leader bridge: your SO-101 leader arm -> the NWaves session page",
216
+ epilog="Commands: start (default), ports, version.")
217
+ ap.add_argument("command", nargs="?", default="start", choices=["start", "ports", "version"],
218
+ help="start the bridge (default), list serial ports, or print the version")
219
+ ap.add_argument("--port", help="serial port of the leader arm (default: found automatically)")
220
+ ap.add_argument("--version", action="version", version=f"nwaves-leader {VERSION}")
221
+ args = ap.parse_args()
222
+ if args.command == "version":
223
+ print(f"nwaves-leader {VERSION}")
224
+ return
225
+ if args.command == "ports":
226
+ sys.exit(list_ports_cmd())
227
+ print(f"[nwaves-leader] NWaves leader bridge {VERSION} (Ctrl-C stops it; 'nwaves-leader ports' lists the ports)")
228
+ port = find_port(args.port)
229
+ if not port:
230
+ names = [p.device for p in list_ports.comports()]
231
+ print("[nwaves-leader] no leader arm found." + (f" Ports seen: {', '.join(names)}" if names else " No serial ports at all:")
232
+ + "\n - plug the arm's USB cable in and make sure the arm is powered\n"
233
+ " - on Windows and macOS the USB adapter may need its driver installed\n"
234
+ " - or pass the port yourself: python3 nwaves-leader.py --port <port>")
235
+ sys.exit(2)
236
+ print(f"[nwaves-leader] leader arm on {port}")
237
+ arm = Arm(port)
238
+ state = {"latest": None, "error": None, "port": port, "closed": set()}
239
+ threading.Thread(target=serve, args=(state,), daemon=True).start()
240
+ misses = 0
241
+ try:
242
+ while True:
243
+ t = time.monotonic()
244
+ try:
245
+ raw = arm.read_positions()
246
+ except Exception as e:
247
+ state["error"] = f"leader arm unplugged ({e})"
248
+ print(f"[nwaves-leader] {state['error']}")
249
+ break
250
+ if any(v is None for v in raw):
251
+ misses += 1
252
+ if misses % 60 == 1:
253
+ silent = [i for i, v in zip(IDS, raw) if v is None]
254
+ print(f"[nwaves-leader] no answer from servo {silent}; check the cable and power")
255
+ else:
256
+ misses = 0
257
+ state["latest"] = raw
258
+ time.sleep(max(0.0, 1.0 / RATE_HZ - (time.monotonic() - t)))
259
+ except KeyboardInterrupt:
260
+ pass
261
+ finally:
262
+ arm.close()
263
+ print("[nwaves-leader] stopped")
264
+
265
+
266
+ if __name__ == "__main__":
267
+ main()
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: nwaves-leader
3
+ Version: 0.1.1
4
+ Summary: NWaves leader bridge: drive the NWaves SO-101 simulation with the leader arm plugged into your computer
5
+ Author: NWaves
6
+ Author-email: NWaves <support@nwaves.ai>
7
+ License: Proprietary
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: pyserial>=3.5
11
+ Dynamic: author
12
+ Dynamic: requires-python
13
+
14
+ # NWaves leader bridge
15
+
16
+ Drives the NWaves SO-101 simulation with the leader arm plugged into your own computer,
17
+ for browsers that cannot open USB devices themselves (Firefox, Safari). Chrome and Edge
18
+ connect over USB directly from the session page and do not need it.
19
+
20
+ Install and run (the session page shows the same one-liners):
21
+
22
+ macOS / Linux: curl -fsSL https://<academy host>/nwaves-leader.sh | sh
23
+ Windows: irm https://<academy host>/nwaves-leader.ps1 | iex
24
+ any: python3 -m pip install nwaves-leader then nwaves-leader
25
+
26
+ Then press "Connect through the NWaves leader bridge" in the session page.
27
+
28
+ The bridge listens on 127.0.0.1:8765 only, accepts connections from nwaves.ai pages only,
29
+ and sends nothing but the six servo positions to the page in your browser. The page does
30
+ the calibration and forwards the positions to your simulation.
31
+
32
+ What the academy hosts and how to rebuild it (from the repo root): `sh tools/nwaves-leader/build.sh`
33
+
34
+ - `nwaves-leader.pyz`: the self-contained app the installers download (pyserial is vendored, BSD licence
35
+ kept inside), runs with any Python 3.8+ as `python3 nwaves-leader.pyz`
36
+ - `nwaves_leader-<version>-py3-none-any.whl`: for `pip install` and for PyPI
37
+ - `nwaves-leader.py`: the single-file version (needs pyserial)
38
+
39
+ Publishing to PyPI (name `nwaves-leader` was free on 2026-10-17): create the NWaves account on pypi.org with
40
+ 2FA, make an API token, then `python3 -m pip install build twine && python3 -m build && twine upload dist/*`
41
+ with `TWINE_USERNAME=__token__ TWINE_PASSWORD=<token>`. Bump `version` in pyproject.toml and setup.py first.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ nwaves_leader/__init__.py
5
+ nwaves_leader/__main__.py
6
+ nwaves_leader/bridge.py
7
+ nwaves_leader.egg-info/PKG-INFO
8
+ nwaves_leader.egg-info/SOURCES.txt
9
+ nwaves_leader.egg-info/dependency_links.txt
10
+ nwaves_leader.egg-info/entry_points.txt
11
+ nwaves_leader.egg-info/requires.txt
12
+ nwaves_leader.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nwaves-leader = nwaves_leader.bridge:main
@@ -0,0 +1 @@
1
+ pyserial>=3.5
@@ -0,0 +1 @@
1
+ nwaves_leader
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nwaves-leader"
7
+ version = "0.1.1"
8
+ description = "NWaves leader bridge: drive the NWaves SO-101 simulation with the leader arm plugged into your computer"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = {text = "Proprietary"}
12
+ authors = [{name = "NWaves", email = "support@nwaves.ai"}]
13
+ dependencies = ["pyserial>=3.5"]
14
+
15
+ [project.scripts]
16
+ nwaves-leader = "nwaves_leader.bridge:main"
17
+
18
+ [tool.setuptools]
19
+ packages = ["nwaves_leader"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ # Metadata lives in pyproject.toml; this file lets old setuptools (without PEP 621
2
+ # support) build the same wheel.
3
+ from setuptools import setup
4
+
5
+ setup(
6
+ name="nwaves-leader",
7
+ version="0.1.1",
8
+ description="NWaves leader bridge: drive the NWaves SO-101 simulation with the leader arm plugged into your computer",
9
+ long_description=open("README.md").read(),
10
+ long_description_content_type="text/markdown",
11
+ author="NWaves",
12
+ author_email="support@nwaves.ai",
13
+ python_requires=">=3.8",
14
+ packages=["nwaves_leader"],
15
+ install_requires=["pyserial>=3.5"],
16
+ entry_points={"console_scripts": ["nwaves-leader=nwaves_leader.bridge:main"]},
17
+ )