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
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
check_melodies.py — prüft die Musik-Tabellen der Firmware, ohne zu flashen.
python3 tools/check_melodies.py
Hintergrund: die Frequenzen wurden ursprünglich mit Math.pow() gerechnet. Das
Ergebnis war rechnerisch korrekt, kam aber als Kommazahl in music.playTone()
an — der micro:bit machte daraus eine unsinnige Schwingungsdauer, krachte laut,
blieb hängen und riss dabei die Bluetooth-Verbindung ab (Connection Timeout
0x08). Zu finden war das nur am Gerät.
Damit so etwas künftig schon beim Bauen auffällt, rechnet dieses Skript die
Ganzzahl-Tabelle aus main.ts gegen die exakte Formel nach und prüft die
Notendaten auf Werte, die den Tonausgang durcheinanderbringen.
Rückgabewert 0 = alles in Ordnung.
"""
import math
import os
import re
import sys
MAIN_TS = os.path.join(os.path.dirname(__file__), "..", "firmware", "main.ts")
#: Ab hier wird es hörbar. 100 Cent sind ein Halbton.
MAX_CENTS = 8.0
def strip_comments(src):
"""Kommentare entfernen — sonst schlägt die Suche nach Math.pow auf dem
Kommentar an, der genau davor warnt."""
src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
src = re.sub(r"//[^\n]*", "", src)
return src
def parse_main(path):
src = open(path, encoding="utf-8").read()
m = re.search(r"const NOTE_HZ16 = \[([0-9,\s]+)\]", src)
if not m:
sys.exit("NOTE_HZ16 nicht in main.ts gefunden")
table = [int(x) for x in m.group(1).replace("\n", "").split(",") if x.strip()]
melodies = re.findall(r'name == "([A-Z]+)"\) return hex`([0-9a-f]+)`', src)
return src, table, melodies
def midi_to_freq(table, midi):
"""Muss sich genauso verhalten wie midiToFreq() in main.ts."""
hz16 = table[midi % 12]
octave = midi // 12 - 5
while octave > 0:
hz16 *= 2
octave -= 1
while octave < 0:
hz16 //= 2
octave += 1
return (hz16 + 8) // 16
def main():
src, table, melodies = parse_main(MAIN_TS)
code = strip_comments(src)
problems = []
if len(table) != 12:
problems.append(f"NOTE_HZ16 hat {len(table)} Einträge, erwartet 12")
# Kein Fließkomma im Tonpfad — das war die Ursache des Fehlers.
for bad in ("Math.pow", "Math.round", "parseFloat"):
if bad in code:
problems.append(f"{bad} steht wieder in main.ts — Frequenzen müssen ganzzahlig bleiben")
if not melodies:
problems.append("keine Melodien gefunden")
notes = set()
for name, hexdata in melodies:
data = bytes.fromhex(hexdata)
if len(data) % 2:
problems.append(f"{name}: ungerade Byte-Zahl, Noten sind Paare aus MIDI und Dauer")
continue
for i in range(0, len(data) - 1, 2):
midi, dur = data[i], data[i + 1]
if dur == 0:
problems.append(f"{name}: Note {i//2} hat Dauer 0 — der Ton liefe endlos")
if midi > 127:
problems.append(f"{name}: Note {i//2} hat MIDI {midi} (>127)")
if midi:
notes.add(midi)
worst = 0.0
worst_note = None
for midi in sorted(notes):
got = midi_to_freq(table, midi)
if got <= 0:
problems.append(f"MIDI {midi} ergibt {got} Hz — playTone braucht einen positiven Wert")
continue
exact = 440.0 * 2 ** ((midi - 69) / 12)
cents = abs(1200 * math.log2(got / exact))
if cents > worst:
worst, worst_note = cents, (midi, got, round(exact))
if cents > MAX_CENTS:
problems.append(f"MIDI {midi}: {got} Hz statt {exact:.1f} Hz ({cents:.1f} Cent daneben)")
print(f"{len(melodies)} Melodien, {len(notes)} verschiedene Noten "
f"(MIDI {min(notes)}-{max(notes)})")
if worst_note:
midi, got, exact = worst_note
print(f"größte Abweichung: {worst:.2f} Cent bei MIDI {midi} "
f"({got} Hz statt {exact} Hz), erlaubt sind {MAX_CENTS}")
if problems:
print("\nFEHLER:", file=sys.stderr)
for p in problems:
print(f" - {p}", file=sys.stderr)
return 1
print("alles in Ordnung")
return 0
if __name__ == "__main__":
sys.exit(main())
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
hex_source.py — holt den eingebetteten MakeCode-Quelltext aus einer micro:bit .hex.
Jede von MakeCode erzeugte .hex trägt das komplette Projekt (main.ts, alle
Extensions, pxt.json) LZMA-komprimiert am Ende mit. Genau daraus stammen die
echten Kosmos-Proxi-Quellen inklusive der Hardware-Extension `proxi.ts` — die
Pin-Belegung des Roboters ist sonst nirgends dokumentiert (siehe
docs/HARDWARE.md).
Funktioniert mit jeder MakeCode-.hex, nicht nur mit denen von Proxi.
python3 hex_source.py *.hex -o ./quellen
python3 hex_source.py --list 00_Funktionstest.hex
python3 hex_source.py --check firmware.hex main.ts
Format des Anhangs (ab der Magic-Sequenz):
+0 8 Byte Magic 41 14 0E 2F B8 2F A2 BB
+8 2 Byte Länge des JSON-Kopfes (little endian)
+10 4 Byte Länge der komprimierten Nutzdaten (little endian)
+14 2 Byte reserviert
+16 n Byte JSON-Kopf {"compression":"LZMA","textSize":...,"name":...}
+16+n LZMA-Strom (alone/legacy-Header) ->
JSON-Kopf (Projekt-Metadaten) + JSON-Objekt {dateiname: inhalt}
"""
import argparse
import binascii
import json
import lzma
import os
import sys
MAGIC = bytes.fromhex("41140E2FB82FA2BB")
# Intel-HEX-Record-Typen, die Nutzdaten tragen. 0x0D/0x0E sind Erweiterungen des
# micro:bit-"Universal Hex" (eine Datei für V1 und V2), 0x0C ist reine Füllung.
DATA_RECORDS = {0x00, 0x0C, 0x0D, 0x0E}
def _hex_payload(path):
"""Alle Datenbytes der .hex hintereinander — ohne Adressauswertung.
Reicht hier, weil der Quelltext-Anhang am Stück in aufeinanderfolgenden
Records liegt und ab der Magic-Sequenz gelesen wird.
"""
out = bytearray()
with open(path, "r", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line.startswith(":"):
continue
try:
raw = binascii.unhexlify(line[1:])
except binascii.Error:
continue
if len(raw) < 5:
continue
length, rectype = raw[0], raw[3]
if rectype in DATA_RECORDS:
out += raw[4:4 + length]
return bytes(out)
def read_project(path):
"""(metadaten, {dateiname: inhalt}) aus einer .hex lesen."""
blob = _hex_payload(path)
start = blob.find(MAGIC)
if start < 0:
raise ValueError("kein eingebetteter MakeCode-Quelltext gefunden")
header_len = int.from_bytes(blob[start + 8:start + 10], "little")
packed_len = int.from_bytes(blob[start + 10:start + 14], "little")
packed_at = start + 16 + header_len
text = lzma.decompress(
blob[packed_at:packed_at + packed_len], format=lzma.FORMAT_ALONE
).decode("utf-8", "replace")
decoder = json.JSONDecoder()
meta, offset = decoder.raw_decode(text)
files, _ = decoder.raw_decode(text[offset:].lstrip())
return meta, files
def cmd_extract(paths, outdir):
for path in paths:
name = os.path.splitext(os.path.basename(path))[0]
try:
meta, files = read_project(path)
except Exception as exc:
print(f"!! {name}: {exc}", file=sys.stderr)
continue
target = os.path.join(outdir, name)
os.makedirs(target, exist_ok=True)
for filename, content in files.items():
dest = os.path.join(target, filename)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "w") as fh:
fh.write(content)
print(f"{name}: {', '.join(sorted(files))} -> {target}")
def cmd_list(paths):
for path in paths:
meta, files = read_project(path)
print(f"== {os.path.basename(path)} ({meta.get('name', '?')})")
for filename in sorted(files):
print(f" {filename:<16} {len(files[filename]):>7} Zeichen")
def _normalize(text):
"""Zeilenenden vereinheitlichen.
MakeCode bettet den Quelltext so ein, wie er auf der Platte lag — bei
Dateien mit CRLF also mit CRLF. Beim Lesen übersetzt Python das
stillschweigend nach LF. Ohne Normalisierung meldet der Vergleich deshalb
einen Unterschied, den es gar nicht gibt.
"""
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
def cmd_check(hexfile, source):
"""Prüft, ob die .hex genau den übergebenen Quelltext enthält."""
_, files = read_project(hexfile)
name = os.path.basename(source)
if name not in files:
print(f"FEHLER: {name} steckt nicht in {hexfile}", file=sys.stderr)
return 1
with open(source) as fh:
expected = _normalize(fh.read())
found = _normalize(files[name])
if found != expected:
print(f"FEHLER: {name} in {hexfile} weicht von {source} ab "
f"({len(found)} vs. {len(expected)} Zeichen) — "
f".hex ist veraltet.", file=sys.stderr)
return 1
print(f"OK: {hexfile} enthält den aktuellen {name}")
return 0
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("files", nargs="+", help=".hex-Dateien (bei --check: <hex> <quelldatei>)")
ap.add_argument("-o", "--outdir", default=".", help="Zielordner für die Extraktion")
ap.add_argument("--list", action="store_true", help="nur auflisten, nichts schreiben")
ap.add_argument("--check", action="store_true",
help="prüfen, ob <hex> den aktuellen <quelldatei>-Stand enthält")
args = ap.parse_args()
if args.check:
if len(args.files) != 2:
ap.error("--check braucht genau zwei Argumente: <hex> <quelldatei>")
return cmd_check(args.files[0], args.files[1])
if args.list:
cmd_list(args.files)
return 0
cmd_extract(args.files, args.outdir)
return 0
if __name__ == "__main__":
sys.exit(main())