From 73dcb1d8cf5c1096c4ba6efc3b7ea4d76c740042 Mon Sep 17 00:00:00 2001 From: JesseSChang Date: Fri, 31 Jul 2026 13:09:51 -0700 Subject: [PATCH] Add O6 web controller --- examples/o6_web_bridge.cpp | 119 ++++++++++++++ examples/sources.cmake | 1 + web/o6_web.py | 325 +++++++++++++++++++++++++++++++++++++ 3 files changed, 445 insertions(+) create mode 100644 examples/o6_web_bridge.cpp create mode 100644 web/o6_web.py diff --git a/examples/o6_web_bridge.cpp b/examples/o6_web_bridge.cpp new file mode 100644 index 0000000..9f996f2 --- /dev/null +++ b/examples/o6_web_bridge.cpp @@ -0,0 +1,119 @@ +// o6_web_bridge — stdin->CAN bridge for the O6 hand, with position readback. +// Reads commands on stdin and drives the hand; periodically emits the hand's +// actual joint positions so the UI can show commanded-vs-actual. +// +// Commands (whitespace-separated, one per line): +// P v0..v5 -> setPosition (6 bytes, each clamped 0..255) +// S v0..v5 -> setSpeed +// T v0..v5 -> setTorque +// Q -> quit +// Emits: +// READY once initialized +// POS v0 v1 .. v5 ~6-7 Hz, the hand's measured joint positions +// +// A reader thread only queues stdin lines; ALL SDK calls happen on the main +// thread, so commands and the readback poll never race on the CAN bus. +// Based on examples/test_o6_can_0.cpp. +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "LinkerHandApi.h" +#include "CommFactory.h" + +static std::mutex g_qmtx; +static std::deque g_queue; +static std::atomic g_running{true}; + +int main(int argc, char** argv) { + HAND_TYPE side = HAND_TYPE::LEFT; + if (argc > 1 && std::string(argv[1]) == "right") side = HAND_TYPE::RIGHT; + + try { + auto hand = std::make_shared(LINKER_HAND::O6, side); + std::shared_ptr bus = Communication::CommFactory::createCanBus(side); + + hand->setCanTxCallback([bus](uint32_t can_id, const uint8_t* data, uintptr_t len) -> int32_t { + std::vector v(data, data + len); + bus->send(v, can_id); + return 0; + }); + hand->setCanRxCallback([bus](uint32_t* id_out, uint8_t* data_out, uint8_t* len_out) -> int32_t { + auto f = bus->recv(); + if (f.can_id == 0 && f.can_dlc == 0) return -1; + *id_out = f.can_id; + *len_out = f.can_dlc; + memcpy(data_out, f.data, f.can_dlc); + return 0; + }); + + hand->setTorque({200, 200, 200, 200, 200, 200}); + hand->setSpeed({200, 200, 200, 200, 200, 200}); + + std::cout << "READY" << std::endl; + + // Reader thread: queue stdin lines only. EOF -> stop. + std::thread reader([] { + std::string line; + while (std::getline(std::cin, line)) { + std::lock_guard lk(g_qmtx); + g_queue.push_back(line); + } + g_running = false; + }); + reader.detach(); + + auto apply = [&](const std::string& line) { + std::istringstream ss(line); + std::string cmd; + if (!(ss >> cmd)) return; + if (cmd == "Q" || cmd == "q") { g_running = false; return; } + if (cmd != "P" && cmd != "S" && cmd != "T") return; + std::vector vals; + int x; + while (ss >> x) { + if (x < 0) x = 0; + if (x > 255) x = 255; + vals.push_back(static_cast(x)); + } + if (vals.size() != 6) return; + if (cmd == "P") hand->setPosition(vals); + else if (cmd == "S") hand->setSpeed(vals); + else hand->setTorque(vals); + }; + + // Main loop: 25 ms tick. Apply queued commands every tick (snappy); + // poll readback every 6th tick (~6-7 Hz). + int tick = 0; + while (g_running) { + std::deque local; + { + std::lock_guard lk(g_qmtx); + local.swap(g_queue); + } + for (auto& ln : local) apply(ln); + + if (++tick % 6 == 0) { + std::vector pos = hand->getPosition(); + if (pos.size() >= 6) { + std::ostringstream out; + out << "POS"; + for (int i = 0; i < 6; i++) out << ' ' << (int)pos[i]; + std::cout << out.str() << std::endl; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(25)); + } + } catch (const std::exception& e) { + std::cerr << "BRIDGE_ERROR: " << e.what() << std::endl; + return 1; + } + return 0; +} diff --git a/examples/sources.cmake b/examples/sources.cmake index 943d42c..a87f72c 100644 --- a/examples/sources.cmake +++ b/examples/sources.cmake @@ -18,6 +18,7 @@ set(LINKERHAND_EXAMPLES test_l21_can_0 test_g20_can_0 test_g20_can_1 + o6_web_bridge test_o6_can_0 test_o6_can_1 test_o6_can_3 diff --git a/web/o6_web.py b/web/o6_web.py new file mode 100644 index 0000000..5ba2769 --- /dev/null +++ b/web/o6_web.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +"""Web slider UI for the LinkerHand O6, with live position readback + torque control. + +Launches the compiled `o6_web_bridge` (build/bin) as a subprocess and forwards +slider poses / torque to it over stdin, while reading back the hand's measured +joint positions. Pure Python stdlib -- no pip installs. + +Usage: + python3 web/o6_web.py [--side left|right] [--host 0.0.0.0] [--port 8080] + +Then open http://:8080/ in a browser. +""" +import argparse +import json +import os +import subprocess +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BRIDGE = os.path.join(REPO, "build", "bin", "o6_web_bridge") + +bridge = None +bridge_lock = threading.Lock() + +state_lock = threading.Lock() +latest_actual = None # last measured [v0..v5] from the hand, or None + + +def start_bridge(side): + global bridge + if not os.path.exists(BRIDGE): + raise SystemExit(f"bridge binary not found: {BRIDGE}\nCompile it first.") + bridge = subprocess.Popen( + [BRIDGE, side], + cwd=os.path.dirname(BRIDGE), # so $ORIGIN .so resolution works + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, + ) + for line in bridge.stdout: # wait for READY + line = line.rstrip() + print(f"[bridge] {line}", flush=True) + if line == "READY": + break + else: + raise SystemExit("bridge exited before READY -- is can0 up and the hand powered?") + + def drain(): + global latest_actual + for ln in bridge.stdout: + ln = ln.rstrip() + if ln.startswith("POS "): + try: + vals = [int(x) for x in ln.split()[1:7]] + if len(vals) == 6: + with state_lock: + latest_actual = vals + except ValueError: + pass + elif ln: # surface anything unexpected + print(f"[bridge] {ln}", flush=True) + threading.Thread(target=drain, daemon=True).start() + + +def _write(line): + with bridge_lock: + if bridge.poll() is not None: + raise RuntimeError("bridge process has exited") + bridge.stdin.write(line) + bridge.stdin.flush() + + +def send_pose(vals): + _write("P " + " ".join(str(int(v)) for v in vals) + "\n") + + +def send_torque(vals): + _write("T " + " ".join(str(int(v)) for v in vals) + "\n") + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, body, ctype="text/html; charset=utf-8"): + data = body.encode() if isinstance(body, str) else body + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == "/" or self.path.startswith("/?"): + self._send(200, PAGE) + elif self.path == "/state": + with state_lock: + a = latest_actual + self._send(200, json.dumps({"actual": a}), "application/json") + else: + self._send(404, "not found", "text/plain") + + def do_POST(self): + try: + n = int(self.headers.get("Content-Length", 0)) + payload = json.loads(self.rfile.read(n) or b"{}") + vals = payload["vals"] + assert isinstance(vals, list) and len(vals) == 6 + vals = [max(0, min(255, int(v))) for v in vals] + if self.path == "/pose": + send_pose(vals) + elif self.path == "/torque": + send_torque(vals) + else: + return self._send(404, "not found", "text/plain") + self._send(200, json.dumps({"ok": True, "vals": vals}), "application/json") + except Exception as e: + self._send(400, json.dumps({"ok": False, "error": str(e)}), "application/json") + + +PAGE = r""" + +LinkerHand O6 Control +
+

LinkerHand O6 — live control

+

Drag a slider to move that joint immediately. 255 = open/extended, 0 = fully bent. +The index finger is the trigger finger. The thin bar shows the joint's actual measured +position; a joint turns orange when it can't reach its target (hit something).

+ +
+ +
+

Trigger

+
+ + + + (moves only the index finger) +
+
+ +
+

Safety / torque

+
+ + 200 + lower = gentler grip +
+
+ + + drops torque on a finger that stalls against the drill, so it holds instead of straining +
+
+ +
+ + + +
+
+
ready
+ + +
+""" + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--side", choices=["left", "right"], default="left") + ap.add_argument("--host", default="0.0.0.0") + ap.add_argument("--port", type=int, default=8080) + args = ap.parse_args() + + print(f"Starting O6 bridge (side={args.side})...", flush=True) + start_bridge(args.side) + srv = ThreadingHTTPServer((args.host, args.port), Handler) + shown = "localhost" if args.host in ("0.0.0.0", "") else args.host + print(f"\n O6 control UI -> http://{shown}:{args.port}/\n", flush=True) + if args.host == "0.0.0.0": + print(" (bound on all interfaces -- reachable from other devices on your LAN)\n", flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + print("\nshutting down", flush=True) + finally: + try: + _write("Q\n") + except Exception: + pass + + +if __name__ == "__main__": + main()