Files
christian 3905647b7f 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.
2026-08-22 11:41:00 +00:00

165 lines
5.8 KiB
Python
Executable File

#!/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())