Proxi AI — Spielzeug-Roboter mit natürlicher Sprache steuern

Firmware, Bluetooth-Brücke und Agent-Skill für den KOSMOS-Proxi: einmal
flashen, danach hört der Roboter über Bluetooth LE auf Textbefehle. Was die
Befehle schickt, ist ihm egal — ein Skript, ein curl, oder ein KI-Agent, der
aus "mach mal was Lustiges" eine Choreografie baut.

  firmware/   MakeCode-Projekt (TypeScript) und fertige .hex für V1 und V2
  bridge/     BLE-Brücke und HTTP-API, einzige Abhängigkeit ist bleak
  agent/      Skill-Definition: Alltagssprache -> Befehle
  tools/      Quelltext aus .hex extrahieren, Musik-Tabellen prüfen
  docs/       Protokoll, Einrichtung, Hardware, Lizenzen

Zwei Dinge, die sonst nirgends stehen und in docs/HARDWARE.md dokumentiert
sind: die Pin-Belegung des Roboters ist nicht öffentlich, sie wurde aus den
mitgelieferten .hex-Dateien rekonstruiert (tools/hex_source.py holt sie
heraus). Und der Ton braucht zwingend eine ganzzahlige Frequenz sowie Pin P0
— sonst kracht der Lautsprecher, der Aufruf hängt und die Bluetooth-
Verbindung stirbt.

Am echten Roboter durchgetestet: Verbindung, Sensoren, Display, 41 Sekunden
Musik am Stück, alle Bewegungsrichtungen, Musik und Fahren gleichzeitig,
Tanzfiguren, Not-Stopp mitten in der Bewegung, Autostart nach Neustart.

MIT-Lizenz. Die Hardware-Extension stammt von kaku111 (TobbieII) und die
Notendaten aus pxt-microbit, beide ebenfalls MIT — siehe THIRD-PARTY-NOTICES.
This commit is contained in:
2026-08-22 11:41:00 +00:00
commit 3905647b7f
26 changed files with 22414 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.venv/
+170
View File
@@ -0,0 +1,170 @@
# bigdance.txt — große Choreografie in vier Teilen, rund 30 Sekunden
#
# python3 proxi_bridge.py --script bigdance.txt
#
# ===========================================================================
# WIE SO EINE CHOREOGRAFIE GEBAUT WIRD
# ===========================================================================
#
# 1. MELODY blockiert die Tanzfiguren, MOVE und TURN nicht.
# DANCE, STAMP und SHAKE teilen sich mit MELODY einen Arbeiter und würden
# erst NACH der Musik loslaufen. Zur Musik getanzt wird deshalb mit MOVE und
# TURN — die starten sofort.
#
# 2. Melodien reihen sich von selbst aneinander.
# Schickt man MELODY:B während A läuft, wartet B und startet nahtlos,
# sobald A fertig ist. So entstehen mehrteilige Stücke.
#
# 3. Die Länge jeder Melodie ist bekannt — danach wird die Zeit eingeteilt:
# ENTERTAINER 3.4s | FUNK 4.0s | CHASE 8.0s | NYAN 16.0s
# Zusammen 31.4 Sekunden, die mit Bewegung gefüllt werden wollen.
#
# 4. WAIT ist immer rund 150 ms kürzer als die Bewegung davor.
# So lange braucht die Brücke selbst zwischen zwei Befehlen. Ohne diesen
# Abzug läuft der Tanz der Musik langsam davon.
#
# 5. Tanzschritte entstehen aus drei Bausteinen:
# Wackeln TURN:LEFT:220 / TURN:RIGHT:220 im Wechsel
# Stampfen MOVE:FWD:200 / MOVE:BWD:200 im Wechsel
# Kurve MOVE:FWD:0 plus TURN währenddessen, danach MOVE:STOP
# Kurze Zeiten wirken hektisch, lange gemütlich — das ist der Rhythmus.
#
# 6. Jeder Teil bekommt ein eigenes Gesicht. Das macht aus einer Bewegungsfolge
# eine Aufführung.
#
# 7. Am Ende immer STOP.
#
# Gleich viel vorwärts wie rückwärts halten — dann bleibt Proxi ungefähr
# da stehen, wo er losgetanzt ist, und fällt nicht vom Tisch.
#
FACE:ASLEEP
# --- TEIL 1: Aufwachen (Entertainer, 3.4s) ---
MELODY:ENTERTAINER
WAIT:250
FACE:SURPRISED
TURN:LEFT:300
WAIT:230
TURN:RIGHT:600
WAIT:550
TURN:LEFT:300
WAIT:230
WAIT:1240
# --- TEIL 2: Groove (Funk, 4.0s) ---
MELODY:FUNK
FACE:HAPPY
TURN:LEFT:420
WAIT:320
TURN:RIGHT:420
WAIT:320
TURN:LEFT:420
WAIT:320
TURN:RIGHT:420
WAIT:320
MOVE:FWD:500
WAIT:410
MOVE:BWD:500
WAIT:410
WAIT:700
# --- TEIL 3: Jagd (Chase, 8.0s) ---
MELODY:CHASE
FACE:SILLY
TURN:LEFT:200
WAIT:100
TURN:RIGHT:200
WAIT:100
TURN:LEFT:200
WAIT:100
TURN:RIGHT:200
WAIT:100
TURN:LEFT:200
WAIT:100
TURN:RIGHT:200
WAIT:100
TURN:LEFT:200
WAIT:100
TURN:RIGHT:200
WAIT:100
FACE:NORTH
MOVE:FWD:0
TURN:LEFT:700
WAIT:650
TURN:RIGHT:700
WAIT:650
MOVE:STOP
MOVE:BWD:900
WAIT:850
WAIT:2650
# --- TEIL 4: Finale (Nyan, 16.0s) ---
MELODY:NYAN
FACE:FABULOUS
MOVE:FWD:220
WAIT:130
MOVE:BWD:220
WAIT:130
MOVE:FWD:220
WAIT:130
MOVE:BWD:220
WAIT:130
FACE:HEART
TURN:LEFT:230
WAIT:140
TURN:RIGHT:230
WAIT:140
TURN:LEFT:230
WAIT:140
TURN:RIGHT:230
WAIT:140
TURN:LEFT:230
WAIT:140
TURN:RIGHT:230
WAIT:140
FACE:DIAMOND
MOVE:FWD:0
TURN:LEFT:1200
WAIT:1150
TURN:RIGHT:1200
WAIT:1150
MOVE:STOP
FACE:SILLY
TURN:RIGHT:180
WAIT:80
TURN:LEFT:180
WAIT:80
TURN:RIGHT:180
WAIT:80
TURN:LEFT:180
WAIT:80
TURN:RIGHT:180
WAIT:80
TURN:LEFT:180
WAIT:80
FACE:TARGET
MOVE:BWD:200
WAIT:110
MOVE:FWD:200
WAIT:110
MOVE:BWD:200
WAIT:110
MOVE:FWD:200
WAIT:110
FACE:FABULOUS
TURN:LEFT:260
WAIT:170
TURN:RIGHT:260
WAIT:170
TURN:LEFT:260
WAIT:170
TURN:RIGHT:260
WAIT:170
FACE:HEART
TURN:LEFT:1500
WAIT:1450
TURN:RIGHT:1500
WAIT:1450
STOP
FACE:HEART
+32
View File
@@ -0,0 +1,32 @@
# demo.txt — kleine Vorführung
# Abspielen: python3 proxi_bridge.py --script demo.txt
FACE:HAPPY
MELODY:POWERUP
TEXT:Hallo!
# einmal umschauen
TURN:LEFT:700
WAIT:900
TURN:RIGHT:1400
WAIT:1600
TURN:LEFT:700
WAIT:900
# ein paar Schritte
FACE:NORTH
MOVE:FWD:1500
WAIT:1700
MOVE:BWD:1500
WAIT:1700
# großer Auftritt
FACE:FABULOUS
MELODY:FUNK
DANCE:3
WAIT:2000
STAMP:2
WAIT:1200
FACE:HEART
STOP
+83
View File
@@ -0,0 +1,83 @@
# nyan-dance.txt — Choreografie zu MELODY:NYAN (16 s)
#
# python3 proxi_bridge.py --script nyan-dance.txt
#
# Getanzt wird ausschließlich mit MOVE und TURN, nicht mit DANCE/STAMP/SHAKE:
# die teilen sich mit MELODY denselben Worker und würden erst NACH der Melodie
# loslaufen. MOVE und TURN laufen dagegen parallel zur Musik.
#
# Die WAIT-Zeiten sind um 150 ms kürzer als der Schritt davor — so lange
# braucht die Brücke selbst zwischen zwei Befehlen.
FACE:FABULOUS
MELODY:NYAN
WAIT:300
# 1. Groove von einer Seite zur anderen
TURN:LEFT:400
WAIT:300
TURN:RIGHT:400
WAIT:300
TURN:LEFT:400
WAIT:300
TURN:RIGHT:400
WAIT:300
# 2. Vor und zurück im Takt
FACE:SURPRISED
MOVE:FWD:550
WAIT:470
MOVE:BWD:550
WAIT:470
MOVE:FWD:550
WAIT:470
MOVE:BWD:550
WAIT:470
# 3. Kurve — laufen und drehen gleichzeitig
FACE:HAPPY
MOVE:FWD:0
TURN:LEFT:800
WAIT:750
TURN:RIGHT:800
WAIT:750
MOVE:STOP
# 4. Schnelles Wackeln
FACE:SILLY
TURN:LEFT:220
WAIT:130
TURN:RIGHT:220
WAIT:130
TURN:LEFT:220
WAIT:130
TURN:RIGHT:220
WAIT:130
TURN:LEFT:220
WAIT:130
TURN:RIGHT:220
WAIT:130
# 5. Stampfen
FACE:FABULOUS
MOVE:FWD:200
WAIT:110
MOVE:BWD:200
WAIT:110
MOVE:FWD:200
WAIT:110
MOVE:BWD:200
WAIT:110
MOVE:FWD:200
WAIT:110
MOVE:BWD:200
WAIT:110
# 6. Große Schlussdrehung
FACE:HEART
TURN:LEFT:1300
WAIT:1250
TURN:RIGHT:1300
WAIT:1250
STOP
FACE:HEART
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env python3
"""
proxi_bridge.py — Bluetooth-LE-Brücke zum Proxi-Roboter.
Verbindet sich per BLE mit dem micro:bit im Roboter und schiebt Textbefehle
über dessen UART-Service. Läuft auf allem mit Linux + Bluetooth (Pi Zero W,
Laptop, NUC). Die einzige Abhängigkeit ist `bleak`, der Rest ist Stdlib.
pip install bleak
python3 proxi_bridge.py --scan Geräte suchen
python3 proxi_bridge.py --ping Lebenszeichen
python3 proxi_bridge.py "MOVE:FWD:1500" einzelner Befehl
python3 proxi_bridge.py -i interaktiv
python3 proxi_bridge.py --script demo.txt Skript abspielen
python3 proxi_bridge.py --serve 8080 HTTP-API für die KI
Befehlsreferenz: ../docs/PROTOCOL.md
"""
import argparse
import asyncio
import json
import os
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
try:
from bleak import BleakClient, BleakScanner
except ImportError:
sys.exit("bleak fehlt. pip install bleak")
# --------------------------------------------------------------------------
# micro:bit BLE UART
#
# Aus codal-microbit-v2/source/bluetooth/MicroBitUARTService.cpp:
# mbbs_cIdxTX = 0x0002 -> propINDICATE micro:bit -> Host
# mbbs_cIdxRX = 0x0003 -> propWRITE | WRITE_WITHOUT Host -> micro:bit
#
# Die Namen sind aus Sicht des micro:bit vergeben. Wer sie mit den Nordic-UART-
# Konventionen verwechselt, schreibt auf das Indicate-Handle und wartet auf
# Benachrichtigungen vom Write-Handle — es passiert dann schlicht nichts.
#
# MICROBIT_UART_S_ATTRSIZE ist 20 Byte, längere Zeilen müssen gestückelt werden.
# --------------------------------------------------------------------------
UART_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e"
UART_FROM_MICROBIT = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" # indicate
UART_TO_MICROBIT = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" # write
ATTR_SIZE = 20
#: Zeilen mit diesen Präfixen sind unaufgefordert und keine Antwort auf einen Befehl.
UNSOLICITED = ("EVENT:", "DONE:", "READY:")
class ProxiBLE:
"""Asynchrone BLE-Verbindung zum Roboter."""
def __init__(self, address=None, timeout=15.0):
self.address = address
#: vom Aufrufer vorgegebene Adresse; None heißt "jedes Mal neu suchen"
self.fixed_address = address
self.timeout = timeout
self.client = None
self._rx = bytearray()
self._responses = None
self.events = []
self.on_event = None
# -- Verbindung ---------------------------------------------------------
@staticmethod
async def discover(timeout=8.0):
"""Alle micro:bits in Reichweite."""
found = []
for dev in await BleakScanner.discover(timeout=timeout):
name = dev.name or ""
if "micro:bit" in name.lower() or "microbit" in name.lower():
found.append((name, dev.address))
return found
async def connect(self):
if not self.address:
found = await self.discover()
if not found:
raise RuntimeError(
"kein micro:bit gefunden — ist Proxi an und nicht schon "
"mit einem anderen Gerät verbunden?"
)
name, self.address = found[0]
print(f"gefunden: {name}{self.address}", file=sys.stderr)
self._responses = asyncio.Queue()
self.client = BleakClient(self.address, timeout=self.timeout)
await self.client.connect()
# start_notify deckt auch Indications ab — der micro:bit nutzt Indicate.
await self.client.start_notify(UART_FROM_MICROBIT, self._on_data)
return self.address
async def disconnect(self):
if self.client and self.client.is_connected:
try:
await self.send("STOP", wait=False)
await asyncio.sleep(0.2)
except Exception:
pass
await self.client.disconnect()
@property
def connected(self):
return bool(self.client and self.client.is_connected)
# -- Datenstrom ---------------------------------------------------------
def _on_data(self, _handle, data):
"""Bytes einsammeln und in Zeilen zerlegen."""
self._rx.extend(data)
while b"\n" in self._rx:
head, _, rest = bytes(self._rx).partition(b"\n")
self._rx = bytearray(rest)
line = head.decode("utf-8", "replace").strip()
if not line:
continue
if line.startswith(UNSOLICITED):
self.events.append(line)
if self.on_event:
self.on_event(line)
else:
self._responses.put_nowait(line)
# -- Senden -------------------------------------------------------------
async def send(self, command, wait=True, timeout=10.0):
if not self.connected:
return "ERR:not_connected"
while not self._responses.empty():
self._responses.get_nowait()
payload = (command.strip() + "\n").encode("utf-8")
for i in range(0, len(payload), ATTR_SIZE):
await self.client.write_gatt_char(
UART_TO_MICROBIT, payload[i:i + ATTR_SIZE], response=False
)
if not wait:
return None
try:
return await asyncio.wait_for(self._responses.get(), timeout=timeout)
except asyncio.TimeoutError:
return "ERR:timeout"
async def send_many(self, commands, gap=0.15):
"""Befehle der Reihe nach senden.
`WAIT:<ms>` ist kein Roboter-Befehl, sondern eine Pause hier in der
Brücke. Bewegungsbefehle kommen sofort mit OK zurück und laufen dann
weiter — ohne WAIT dazwischen würde der nächste Befehl die laufende
Bewegung sofort überschreiben.
"""
results = []
for raw in commands:
cmd = raw.strip()
if not cmd or cmd.startswith("#"):
continue
head, sep, rest = cmd.partition(":")
if head.upper() == "WAIT":
try:
ms = int(rest) if sep else 0
except ValueError:
results.append((cmd, "ERR:WAIT:bad_ms"))
continue
await asyncio.sleep(max(0, min(ms, 60000)) / 1000)
results.append((cmd, f"OK:WAIT:{ms}"))
continue
results.append((cmd, await self.send(cmd)))
await asyncio.sleep(gap)
return results
class Bridge:
"""Synchrone Hülle: hält die Verbindung in einem eigenen Event-Loop-Thread."""
def __init__(self, address=None):
self.ble = ProxiBLE(address)
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()
def _run_loop(self):
asyncio.set_event_loop(self._loop)
self._loop.run_forever()
def _call(self, coro, timeout=60):
return asyncio.run_coroutine_threadsafe(coro, self._loop).result(timeout)
def connect(self):
return self._call(self.ble.connect(), timeout=90)
def start_autoconnect(self, interval=10.0):
"""Im Hintergrund verbinden und verbunden halten.
Für den Dauerbetrieb als Dienst: der Roboter ist nicht immer an. Ohne
das beendet sich die Brücke beim Startversuch, systemd startet sie neu,
und der HTTP-Server kommt nie hoch — die API wäre also genau dann tot,
wenn jemand den Roboter einschaltet.
"""
asyncio.run_coroutine_threadsafe(self._keep_connected(interval), self._loop)
async def _keep_connected(self, interval):
while True:
if not self.ble.connected:
try:
await self.ble.connect()
print(f"verbunden mit {self.ble.address}", file=sys.stderr)
except Exception as exc:
# Roboter aus oder außer Reichweite — gleich nochmal probieren
print(f"warte auf Proxi ({exc})", file=sys.stderr)
self.ble.address = self.ble.fixed_address
await asyncio.sleep(interval)
def send(self, command, wait=True):
return self._call(self.ble.send(command, wait=wait))
def send_many(self, commands):
return self._call(self.ble.send_many(commands), timeout=600)
def close(self):
try:
self._call(self.ble.disconnect(), timeout=20)
finally:
self._loop.call_soon_threadsafe(self._loop.stop)
@property
def connected(self):
return self.ble.connected
# --------------------------------------------------------------------------
# HTTP-API
# --------------------------------------------------------------------------
def make_handler(bridge):
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
print(f"[http] {fmt % args}", file=sys.stderr)
def _send(self, code, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self._send(204, {})
def do_GET(self):
if self.path.rstrip("/") in ("", "/status"):
self._send(200, {
"connected": bridge.connected,
"address": bridge.ble.address,
"events": bridge.ble.events[-20:],
})
else:
self._send(404, {"error": "not found"})
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
try:
data = json.loads(self.rfile.read(length) or b"{}")
except json.JSONDecodeError:
return self._send(400, {"error": "kein gültiges JSON"})
path = self.path.rstrip("/")
if path == "/command":
cmd = data.get("command")
if not isinstance(cmd, str):
return self._send(400, {"error": "Feld 'command' fehlt"})
response = bridge.send(cmd)
self._send(200, {
"command": cmd,
"response": response,
"ok": not str(response).startswith("ERR:"),
})
elif path == "/commands":
cmds = data.get("commands")
if not isinstance(cmds, list):
return self._send(400, {"error": "Feld 'commands' fehlt"})
results = bridge.send_many([str(c) for c in cmds])
self._send(200, {"results": [
{"command": c, "response": r, "ok": not str(r).startswith("ERR:")}
for c, r in results
]})
else:
self._send(404, {"error": "not found"})
return Handler
# --------------------------------------------------------------------------
# CLI
# --------------------------------------------------------------------------
def run_scan():
found = asyncio.run(ProxiBLE.discover())
if not found:
print("kein micro:bit gefunden.")
return 1
for name, address in found:
print(f"{address} {name}")
return 0
def run_interactive(bridge):
print("Interaktiv — 'quit' beendet, leere Zeile wiederholt nichts.")
print("Befehle z.B.: MOVE:FWD:1500 | TURN:LEFT:600 | FACE:HAPPY | DANCE:3")
while True:
try:
line = input("proxi> ").strip()
except (EOFError, KeyboardInterrupt):
print()
return 0
if not line:
continue
if line.lower() in ("quit", "exit", "q"):
return 0
print(" ", bridge.send(line))
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("command", nargs="*", help="Befehl(e), direkt gesendet")
ap.add_argument("--address", "-a", help="BLE-Adresse, überspringt die Suche")
ap.add_argument("--scan", action="store_true", help="micro:bits suchen und beenden")
ap.add_argument("--ping", action="store_true", help="PING senden")
ap.add_argument("--interactive", "-i", action="store_true", help="Eingabeschleife")
ap.add_argument("--script", help="Datei mit einem Befehl pro Zeile")
ap.add_argument("--serve", type=int, metavar="PORT", help="HTTP-API starten")
args = ap.parse_args()
if args.scan:
return run_scan()
address = args.address or os.environ.get("PROXI_ADDRESS")
bridge = Bridge(address)
bridge.ble.on_event = lambda line: print(f"[proxi] {line}", file=sys.stderr)
if args.serve:
# Erst den Server, dann die Verbindung: die API muss auch dann
# erreichbar sein, wenn Proxi gerade aus ist.
server = ThreadingHTTPServer(("0.0.0.0", args.serve), make_handler(bridge))
bridge.start_autoconnect()
print(f"HTTP-API auf http://0.0.0.0:{args.serve} "
f"(POST /command, POST /commands, GET /status)", file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
bridge.close()
return 0
# Alle anderen Modi sind interaktiv — da ist sofortiges Scheitern richtig.
try:
bridge.connect()
except Exception as exc:
print(f"Verbindung fehlgeschlagen: {exc}", file=sys.stderr)
return 1
print(f"verbunden mit {bridge.ble.address}", file=sys.stderr)
try:
if args.interactive:
return run_interactive(bridge)
if args.script:
with open(args.script) as fh:
for cmd, response in bridge.send_many(fh.readlines()):
print(f"{cmd:<28} {response}")
return 0
commands = list(args.command)
if args.ping:
commands.insert(0, "PING")
if not commands:
ap.error("kein Befehl angegeben — siehe --help")
failed = False
for cmd, response in bridge.send_many(commands):
print(f"{cmd:<28} {response}")
failed = failed or str(response).startswith("ERR:")
return 1 if failed else 0
except KeyboardInterrupt:
return 0
finally:
bridge.close()
if __name__ == "__main__":
sys.exit(main())
+1
View File
@@ -0,0 +1 @@
bleak>=0.21