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.
165 lines
5.8 KiB
Python
Executable File
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())
|