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
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+27
View File
@@ -0,0 +1,27 @@
MIT License
Copyright (c) 2026 Christian Gärtner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Dieses Projekt enthält fremden Code, der ebenfalls unter der MIT-Lizenz steht.
Die zugehörigen Copyright-Vermerke stehen in THIRD-PARTY-NOTICES; Herkunft und
Umfang sind in docs/LICENSES.md beschrieben.
+144
View File
@@ -0,0 +1,144 @@
<div align="center">
<img src="docs/img/proxi.png" width="360" alt="Der Proxi-Roboter">
# Proxi AI
**Einen Spielzeug-Roboter mit natürlicher Sprache steuern.**
Ein Kind sagt „lass ihn tanzen" — eine KI macht daraus Befehle, der Roboter tanzt.
[![Lizenz: MIT](https://img.shields.io/badge/Lizenz-MIT-blue.svg)](LICENSE)
[![micro:bit V1 + V2](https://img.shields.io/badge/micro%3Abit-V1%20%2B%20V2-brightgreen.svg)](#)
[![Bluetooth LE](https://img.shields.io/badge/Bluetooth-LE-blue.svg)](#)
</div>
---
Der [KOSMOS Proxi](https://kosmos.de/proxi) ist ein Bausatz-Roboter mit einem
BBC micro:bit als Kopf. Ab Werk programmiert man ihn mit Blöcken im Browser und
spielt das Ergebnis per USB auf.
Dieses Projekt ersetzt das durch eine Firmware, die **einmal** geflasht wird und
danach über Bluetooth auf Textbefehle hört. Was die Befehle schickt, ist ihr
egal — ein Skript, ein `curl`, oder eben ein KI-Agent, der aus „mach mal was
Lustiges" eine Choreografie baut.
```
„Proxi, freu dich!"
KI-Agent ──HTTP──▶ Brücke (Python) ──Bluetooth LE──▶ Proxi
Raspberry Pi
```
## In drei Schritten
```bash
# 1. Firmware aufspielen: firmware/proxi-ai.hex auf das MICROBIT-Laufwerk kopieren
# 2. Brücke starten (irgendein Rechner mit Bluetooth)
pip install bleak
python3 bridge/proxi_bridge.py --serve 8080
# 3. Proxi etwas sagen lassen
curl -X POST localhost:8080/command \
-H 'Content-Type: application/json' \
-d '{"command":"DANCE:3"}'
```
> ⚠️ Die Brücke hat **keine Authentifizierung** — wer sie erreicht, fährt den
> Roboter. Im Heimnetz ist das gewollt und unproblematisch; gib den Port aber
> nicht nach außen frei. Details in [docs/SETUP.md](docs/SETUP.md).
Ausführlich: **[docs/SETUP.md](docs/SETUP.md)**
## Was Proxi kann
| | |
|---|---|
| 🚶 **Laufen** | `MOVE:FWD:1200` · `TURN:LEFT:700` — vor, zurück, drehen, Kurven |
| 💃 **Tanzen** | `DANCE:3` · `STAMP:2` · `SHAKE:2` — oder eigene Choreografien |
| 😀 **Gesichter** | `FACE:HAPPY` · `FACE:RAW:0:10:0:17:14` — 34 Symbole oder eigene Pixelbilder |
| 🎵 **Musik** | `MELODY:NYAN` · `TONE:880:200` — 20 Melodien oder Ton für Ton komponiert |
| 📝 **Text** | `TEXT:Hallo Ben` — läuft über das LED-Display |
| 👀 **Sehen** | `SENSOR:ALL` — Infrarot links und rechts, erkennt Hindernisse |
| 🛑 **Not-Stopp** | `STOP` — wirkt auch mitten in einer Bewegung. Taste A am Roboter immer |
Vollständig: **[docs/PROTOCOL.md](docs/PROTOCOL.md)**
## Warum das interessant ist
**Die Bausteine sind fest, das Gebaute nicht.** Eine KI kann keine neuen
Grundbefehle erfinden — aber aus `FACE:RAW` jedes denkbare Gesicht malen, aus
`TONE` eigene Melodien komponieren und aus `MOVE`/`TURN` Choreografien bauen,
die niemand vorgegeben hat. [`agent/proxi-control.md`](agent/proxi-control.md)
ist der fertige Skill dafür.
**Die Pin-Belegung stand nirgends.** Kosmos dokumentiert sie nicht. Sie steckte
in den mitgelieferten `.hex`-Dateien — MakeCode hängt jedes Projekt komprimiert
an die kompilierte Firmware an. [`tools/hex_source.py`](tools/hex_source.py)
holt sie wieder heraus und funktioniert mit jeder micro:bit-`.hex`, nicht nur
mit denen von Proxi.
**Zwei Fallstricke, die je einen Abend gekostet haben.** Die Frequenz für
`music.playTone()` muss ganzzahlig sein — als Kommazahl kracht der Lautsprecher,
der Aufruf hängt und die Bluetooth-Verbindung stirbt. Und der Ton muss
ausdrücklich auf Pin P0 gelegt werden, sonst läuft er über den Audio-Mixer
des micro:bit V2 und lässt den Funk nicht mehr zum Zug kommen. Beides steht mit
Messwerten in [docs/HARDWARE.md](docs/HARDWARE.md).
## Aufbau
| | |
|---|---|
| [`firmware/`](firmware) | MakeCode-Projekt (TypeScript) und fertige `.hex` für V1 und V2 |
| [`bridge/`](bridge) | Bluetooth-Brücke und HTTP-API — nur `bleak` als Abhängigkeit |
| [`agent/`](agent) | Skill-Definition: Alltagssprache → Befehle |
| [`tools/`](tools) | Quelltext aus `.hex` extrahieren, Musik-Tabellen prüfen |
| [`docs/`](docs) | Protokoll, Einrichtung, Hardware, Lizenzen |
## Stand
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.
## Lizenz
MIT — siehe [LICENSE](LICENSE). Die Hardware-Extension und die Notendaten
stammen aus fremden MIT-Projekten, ihre Vermerke stehen in
[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES).
---
## Wer das gebaut hat
Ich bin **Christian Gärtner**, selbstständiger **KI-Dozent und Berater**. Ich
zeige Unternehmen und Teams, wie künstliche Intelligenz im Alltag tatsächlich
funktioniert — ohne Buzzword-Bingo, dafür an echten Projekten.
Dieses hier ist eins davon, und es zeigt ziemlich genau, was ich meine: Ein
KI-Agent steuert keine Tabellen, sondern einen Roboter, der im Kinderzimmer
herumläuft. Die Bausteine sind fest, was daraus gebaut wird nicht — dieselbe
Frage stellt sich in jedem Unternehmen, das überlegt, wo KI bei ihm konkret
etwas beiträgt. Und die Fallstricke stehen hier dokumentiert statt versteckt,
weil das der Teil ist, an dem Projekte in der Praxis hängenbleiben.
<div align="center">
**KI-Schulungen · KI-Beratung · KI-Projekte**
**[gaertner.onl](https://gaertner.onl)** — Künstliche Intelligenz verständlich gemacht
[**Termin vereinbaren**](https://gaertner.onl/termin/) · [Kontakt](https://gaertner.onl/kontakt/) · [Blog](https://gaertner.onl/blog/)
</div>
<!--
Dieser Ordner ist in sich geschlossen und lässt sich am Stück in ein
eigenes Repo kopieren.
docs/img/proxi.png ist das Produktbild von KOSMOS. Wer das vermeiden will,
ersetzt es durch ein eigenes Foto — es ist genau diese eine Zeile oben.
-->
+60
View File
@@ -0,0 +1,60 @@
Fremder Code in diesem Projekt
=============================
Beide Bestandteile stehen unter der MIT-Lizenz und sind damit mit der Lizenz
dieses Projekts verträglich. Ihre Copyright-Vermerke müssen bei jeder
Weitergabe erhalten bleiben — dafür ist diese Datei da.
Welche Datei woher stammt und warum, steht in docs/LICENSES.md.
-------------------------------------------------------------------------------
1. firmware/proxi.ts — Hardware-Extension
-------------------------------------------------------------------------------
Ursprünglich "TobbieII" von kaku111
https://github.com/kaku111/20190209
Übernommen in der von Kosmos eingedeutschten Fassung, die in den .hex-Dateien
der Proxi-Beispielprogramme mitgeliefert wird. Unverändert bis auf einen
Herkunftshinweis im Dateikopf.
Lizenztext aus LICENSE.txt des Originals:
Copyright (c) Microsoft Corporation
All rights reserved.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
-------------------------------------------------------------------------------
2. Notendaten in firmware/main.ts — melodyBuffer()
-------------------------------------------------------------------------------
Die 20 Melodien sind die eingebauten Melodien von MakeCode für den micro:bit,
Byte für Byte übernommen aus libs/core/melodies.ts von
Microsoft MakeCode für micro:bit (pxt-microbit)
https://github.com/microsoft/pxt-microbit
Lizenz: MIT, Copyright (c) Microsoft Corporation. Der Lizenztext entspricht
wortgleich dem oben abgedruckten.
+233
View File
@@ -0,0 +1,233 @@
---
name: proxi-control
description: Steuert den Proxi-Roboter. Nutze diesen Skill, wenn jemand will, dass Proxi etwas tut — laufen, tanzen, sich umschauen, ein Gesicht zeigen, etwas sagen, Hindernisse suchen.
---
# Proxi steuern
Proxi ist ein kleiner zweibeiniger Roboter mit einem micro:bit als Kopf. Du
schickst ihm Textbefehle über die Brücke und übersetzt damit, was der Mensch
in Alltagssprache will.
## Befehle senden
Du brauchst **keine Dateien und kein Python**. Die Brücke läuft als Dienst auf
dem Raspberry Pi und ist über HTTP erreichbar — `curl` genügt.
Die Adresse der Brücke steht in der Umgebungsvariablen `PROXI_URL`:
```bash
PROXI="${PROXI_URL:?PROXI_URL ist nicht gesetzt — Adresse der Proxi-Brücke, z.B. http://proxi.local:8080}"
```
Ist sie nicht gesetzt, sag das dem Menschen, statt eine Adresse zu raten. Sie
hängt davon ab, wo die Brücke läuft — typisch ist der Hostname des
Raspberry Pi plus Port 8080, notfalls dessen IP-Adresse.
Ein einzelner Befehl:
```bash
curl -sX POST $PROXI/command \
-H 'Content-Type: application/json' \
-d '{"command":"MOVE:FWD:1000"}'
```
Mehrere am Stück — die Brücke arbeitet sie der Reihe nach ab, inklusive der
`WAIT`-Pausen. **So schickst du Choreografien:**
```bash
curl -sX POST $PROXI/commands \
-H 'Content-Type: application/json' \
-d '{"commands":["FACE:HAPPY","MELODY:FUNK","TURN:LEFT:400","WAIT:450","TURN:RIGHT:400","WAIT:450","STOP"]}'
```
Nachsehen, ob Proxi überhaupt da ist:
```bash
curl -s $PROXI/status # {"connected": true, ...}
```
Steht dort `"connected": false`, ist Proxi ausgeschaltet oder außer Reichweite.
Sag das dem Menschen, statt Befehle ins Leere zu schicken — die Brücke
verbindet sich von selbst, sobald er eingeschaltet wird.
### Nicht selbst starten
`proxi_bridge.py` brauchst du nicht und sollst du **nicht** aufrufen. Das Skript
liegt auf dem Pi und läuft dort bereits als Dienst. Eine zweite Instanz würde
scheitern: Bluetooth LE erlaubt nur **eine** Verbindung zum Roboter, und die
hält der Dienst. Alles läuft über HTTP.
## Was Proxi kann
| Befehl | Wirkung |
|---|---|
| `MOVE:FWD[:ms]` / `MOVE:BWD[:ms]` / `MOVE:STOP` | laufen |
| `TURN:LEFT[:ms]` / `TURN:RIGHT[:ms]` / `TURN:STOP` | Oberkörper drehen |
| `DANCE[:n]` / `STAMP[:n]` / `SHAKE[:n]` | tanzen, aufstampfen, Kopf schütteln |
| `FACE:<name>` / `FACE:RAW:z0:z1:z2:z3:z4` | Gesicht zeigen |
| `TEXT:<text>` | Text über das Display laufen lassen |
| `MELODY:<name>` / `TONE:<hz>[:ms]` | Musik und Töne |
| `SENSOR:ALL` / `SENSOR:IR_L` / `SENSOR:IR_R` | Sensoren lesen |
| `CLEAR` / `STOP` / `PING` / `STATUS` | System |
Die vollständige Liste aller Namen für `FACE` und `MELODY` steht in
[`docs/PROTOCOL.md`](../docs/PROTOCOL.md). Rate keine Namen — nimm einen aus
der Liste oder frag nach.
## Regeln
**Es gibt keine Geschwindigkeit.** Die Motoren können nur an oder aus. Alles,
was du steuerst, ist die *Dauer*. "Langsam" gibt es nicht — "kurz" schon.
Erfinde keinen Speed-Parameter.
**Realistische Zeiten.** Grobe Anhaltspunkte, jeder Untergrund ist anders:
| Was | Dauer |
|---|---|
| ein, zwei Schritte | 8001500 ms |
| durchs Zimmer | 40006000 ms |
| Vierteldrehung | ~700 ms |
| halbe Drehung | ~1400 ms |
**Zwischen Bewegungen `WAIT:<ms>` setzen.** Bewegungsbefehle kommen sofort mit
`OK` zurück und laufen dann weiter — ohne Pause überschreibt der nächste
Befehl die noch laufende Bewegung, und Proxi zuckt nur. `WAIT` ist eine Pause in
der Brücke, kein Befehl an den Roboter. Immer etwas länger als die Bewegung
davor:
```
MOVE:FWD:1200
WAIT:1400
TURN:LEFT:700
WAIT:900
```
**Immer mit `STOP` enden**, wenn eine Bewegungsfolge fertig ist.
**Ohne Dauer stoppt eine Bewegung nach 10 Sekunden von selbst.** Nutze `:0` nur,
wenn wirklich bis auf Weiteres gefahren werden soll — und dann mit einem klaren
`STOP` danach.
**Lauf- und Drehmotor sind unabhängig.** Beide zusammen ergibt eine Kurve:
`MOVE:FWD:0` plus `TURN:LEFT:800`.
**Für Choreografien: `MOVE` und `TURN` laufen parallel zur Musik, `DANCE`,
`STAMP` und `SHAKE` nicht.** Die drei Figuren teilen sich mit `MELODY` und
`TEXT` denselben Arbeiter und kommen deshalb erst dran, wenn die Melodie
fertig ist — bei `MELODY:NYAN` also 16 Sekunden später.
Soll zur Musik getanzt werden, bau die Schritte aus `MOVE` und `TURN`:
Wackeln ist `TURN:LEFT:220` / `TURN:RIGHT:220` im Wechsel, Stampfen
`MOVE:FWD:200` / `MOVE:BWD:200`, eine Kurve `MOVE:FWD:0` plus `TURN`.
`DANCE` und Co. gehören vor oder nach die Melodie, nicht mittenrein.
Ein fertiges Beispiel liegt in `bridge/nyan-dance.txt`.
**Aber immer nur ein Klang.** Ein neues `MELODY` oder `TONE` bricht das ab, was
gerade läuft. Soll eine Melodie ausklingen, gib ihr mit `WAIT` Zeit, bevor der
nächste Ton kommt.
## Kleine Choreografie
Auf "Proxi, freu dich!":
```json
{"commands": [
"FACE:FABULOUS",
"MELODY:POWERUP",
"STAMP:2", "WAIT:1200",
"TURN:LEFT:600", "WAIT:800",
"TURN:RIGHT:1200", "WAIT:1400",
"TURN:LEFT:600", "WAIT:800",
"DANCE:2", "WAIT:1500",
"FACE:HEART",
"STOP"
]}
```
## Eine Choreografie zur Musik bauen
Das ist die Königsdisziplin — und es gibt ein paar Regeln, ohne die es
auseinanderfällt. Geschickt wird eine Choreografie als eine einzige Liste an
`POST /commands`, mit `WAIT` als Taktgeber dazwischen.
**Getanzt wird mit `MOVE` und `TURN`, nicht mit `DANCE`.** Nur die beiden
laufen wirklich parallel zur Musik.
**Melodien reihen sich von selbst aneinander.** Schickst du `MELODY:B`,
während A noch läuft, startet B nahtlos danach. So baust du mehrteilige
Stücke.
**Rechne mit den Laufzeiten.** So lange spielt jede Melodie:
| | |
|---|---|
| ~0,51 s | `BADING` `JUMPUP` `JUMPDOWN` `POWERUP` `POWERDOWN` |
| ~2,53,5 s | `WAWAWAWAA` `PUNCHLINE` `RINGTONE` `BADDY` `ENTERTAINER` `FUNERAL` |
| ~4 s | `DADADUM` `FUNK` |
| ~8 s | `CHASE` `WEDDING` `PRELUDE` |
| ~12 s | `BIRTHDAY` `BLUES` |
| ~16 s | `ODE` `NYAN` |
**`WAIT` ist 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 davon.
**Tanzschritte bestehen 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 + TURN währenddessen, danach MOVE:STOP
```
Kurze Zeiten wirken hektisch, lange gemütlich. Genau daraus entsteht Rhythmus.
**Ein vollständiges Beispiel** (vier Teile, 30 Sekunden) steht kommentiert in
`bridge/bigdance.txt` im Repo. Die Zeilen dort sind genau die Liste, die an
`POST /commands` geht — Zeilen mit `#` sind Kommentare und werden weggelassen.
**Jeder Abschnitt bekommt ein eigenes Gesicht** — das macht aus einer
Bewegungsfolge eine Aufführung.
**Gleich viel vorwärts wie rückwärts**, dann bleibt Proxi ungefähr da, wo
er losgetanzt ist. Und am Ende immer `STOP`.
## Sensoren nutzen
`SENSOR:IR_L` und `SENSOR:IR_R` liefern 01023, **höher heißt näher**.
Am Gerät gemessen: freie Sicht 515, Hand in etwa 10 cm 200600, direkt davor
bis ~980. Ab 400 steht etwas im Weg — das entspricht ungefähr einer Handbreit.
**Proxi kann kein Licht messen.** Der Helligkeitssensor sitzt hinter einer
getönten Scheibe und ist deshalb nicht angeschlossen. Sag nicht, Proxi könne
sehen, ob es hell oder dunkel ist — er kann es nicht.
Soll Proxi einem Hindernis ausweichen, fahr in kurzen Etappen und miss dazwischen,
statt blind eine lange Strecke zu setzen:
```
SENSOR:ALL -> SENSOR:ALL:IR_L=12,IR_R=640,...
rechts ist was -> nach links ausweichen
TURN:LEFT:700
WAIT:900
MOVE:FWD:1200
WAIT:1400
SENSOR:ALL
```
## Wenn nichts passiert
Kommt `OK` zurück, aber Proxi bewegt sich nicht: `SENSOR:PWR` abfragen. Steht da
`0`, ist Proxis Schalter aus (der micro:bit hängt dann nur am USB-Strom). Sag
das dem Menschen, statt weiter Fahrbefehle zu schicken.
## Sicherheit
Proxi fährt auf dem Boden herum, oft mit einem Kind daneben.
* Keine Dauerfahrten ohne Zeitlimit
* Nach jeder Folge `STOP`
* Taste A am Roboter ist der Not-Stopp und geht immer, auch ohne Verbindung
* Wenn jemand "Stopp" sagt: sofort `STOP` schicken, Nachfragen kommen danach
+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
+168
View File
@@ -0,0 +1,168 @@
# Proxi — Hardware
Wie der Roboter wirklich verdrahtet ist, und woher diese Angaben stammen.
## Herkunft
Kosmos dokumentiert die Pin-Belegung nirgends: die Anleitung richtet sich an
Kinder, programmiert wird ausschließlich mit den fertigen Blöcken von
[kosmos.de/proxi](https://kosmos.de/proxi). Die Angaben hier sind also nicht
abgeschrieben, sondern rekonstruiert.
Möglich ist das, weil MakeCode jedes Projekt komprimiert an die kompilierte
Firmware anhängt. Die `.hex`-Dateien der Kosmos-Beispielprogramme (Download
über [kosmos.de/proxi](https://kosmos.de/proxi)) enthalten damit ihren eigenen
Quelltext. Herausholen lässt er sich mit dem Werkzeug in diesem Repo:
```bash
python3 tools/hex_source.py 00_Funktionstest.hex -o ./quellen
```
Die interessante Datei darin ist `custom.ts`: die Kosmos-Extension mit allen
Hardware-Zugriffen. Sie liegt hier unverändert als
[`firmware/proxi.ts`](../firmware/proxi.ts) — wer wissen will,
wie sich eine Funktion in Grenzfällen verhält, liest am besten dort nach.
Sie ist eine eingedeutschte Fassung der MIT-lizenzierten Extension
[`kaku111/20190209`](https://github.com/kaku111/20190209) für den **Tobbie II**.
Proxi ist derselbe Roboter unter anderem Namen — Kosmos' eigenes Programm
`12_APP_Fernbedienung` hängt noch direkt an dieser Extension:
```json
"TobbieII": "github:kaku111/20190209#9c728847d1a08815275108ec42009211d57c2f0d"
```
Herkunft und Lizenzen im Detail: [LICENSES.md](LICENSES.md).
## Pin-Belegung
| Pin | Richtung | Funktion |
|---|---|---|
| **P13** | Ausgang | Laufmotor vorwärts |
| **P14** | Ausgang | Laufmotor rückwärts |
| **P15** | Ausgang | Drehmotor links (Oberkörper/Kopf) |
| **P16** | Ausgang | Drehmotor rechts |
| **P12** | Ausgang | IR-Sendediode (Messpuls) |
| **P1** | Analog-Eingang | IR-Empfänger links |
| **P2** | Analog-Eingang | IR-Empfänger rechts |
| **P8** | Digital-Eingang | Enable — wird vor jeder Bewegung geprüft |
| **P0** | Ausgang | Summer (Standard-Audiopin des micro:bit) |
### Motoren
Zwei Motoren, beide an einer H-Brücke mit **zwei digitalen Leitungen**
eine je Drehrichtung:
```
P13=1, P14=0 -> vorwärts
P13=0, P14=1 -> rückwärts
P13=0, P14=0 -> aus
P15=1, P16=0 -> Drehung nach links
P15=0, P16=1 -> Drehung nach rechts
P15=0, P16=0 -> aus
```
**Es gibt keine Geschwindigkeitsregelung.** Kein PWM, kein Servo, keine
Zwischenstufen — ein Motor läuft oder er läuft nicht. Gesteuert wird
ausschließlich über die Dauer, deshalb hat kein Befehl im
[Protokoll](PROTOCOL.md) einen Speed-Parameter.
Der Laufmotor bewegt die Beine, der Drehmotor den Oberkörper. Laufen beide
gleichzeitig, geht Proxi eine Kurve.
### IR-Sensoren
Gemessen wird als Differenz, damit Umgebungslicht herausfällt:
1. Empfänger analog lesen (Grundhelligkeit)
2. Sendediode P12 einschalten
3. 250 µs warten
4. Empfänger erneut lesen
5. Sendediode aus — Messwert ist die Differenz beider Messungen
Hoher Wert = Hindernis nah. Am Gerät gemessen: freie Sicht 5-15, Hand in etwa
10 cm 200-600, Hand direkt davor bis ~980. Beide Seiten erreichen den vollen
Bereich. Die Schwellen 400-600 aus Kosmos' eigenen Programmen entsprechen damit
ungefähr einer Handbreit Abstand.
### Helligkeitssensor: durch die Scheibe blockiert
Den gibt es hier nicht, und zwar aus einem Grund, den keine Software behebt.
`input.lightLevel()` misst über die LED-Matrix — und die sitzt bei Proxi
hinter einer **getönten Scheibe**. Nimmt man die weg, misst der Sensor
einwandfrei. Eingebaut liefert er konstant 0, auch mit einer Taschenlampe
direkt davor.
Deshalb kennt das Protokoll kein `SENSOR:LIGHT`. Wer den Sensor braucht, muss
an der Mechanik ansetzen, nicht am Code.
Die 0 sah lange nach einem Softwarefehler aus: Kosmos' eigenes Programm
`04_Licht-Tracker` benutzt denselben Sensor erfolgreich (mit Schwellen von 1
bis 10 — die Werte sind klein). Widerlegt wurde die Softwarespur erst, als
jemand die Scheibe abgenommen hat.
### P8
`custom.ts` prüft `P8 == 1` vor jeder Motorbewegung, beim Abbremsen der Drehung
und bei der rechten IR-Auswertung. Was genau daran hängt, ist nicht
dokumentiert — plausibel ist der Batterieschalter beziehungsweise eine
Power-Good-Leitung, denn der micro:bit läuft auch dann weiter, wenn Proxis
Schalter auf OFF steht und nur USB-Strom anliegt.
Diese Firmware rät nicht daran herum: alle Motorzugriffe laufen über die
unveränderten Extension-Funktionen, und `SENSOR:PWR` gibt den Rohwert von P8
zurück. **Reagiert Proxi auf Fahrbefehle mit `OK`, bewegt sich aber nicht,
lohnt zuerst ein Blick auf `SENSOR:PWR`** — steht da `0`, ist der Schalter aus.
## micro:bit V1 oder V2
Die Firmware wird als **Universal Hex** gebaut und läuft auf beiden.
Knapp ist es beim V1 trotzdem: der Bluetooth-Stack frisst dort den Großteil des
Flash. Erste Fassungen dieser Firmware passten nicht hinein — Platz geschaffen
hat erst der Verzicht auf den eingebauten Melodie-Player von MakeCode (siehe
`playMelodyBuffer` in [`main.ts`](../firmware/main.ts), der Grund dafür
war allerdings ein anderer). Wer die Firmware erweitert, sollte die V1-Variante
im Blick behalten: `pxt build` bricht ab, sobald sie nicht mehr passt.
V2 erkennt man am goldenen Logo-Taster oben mittig und dem Mikrofon oben rechts.
## Ton: keine Kommazahlen
Die Frequenz für `music.playTone()` **muss** eine Ganzzahl sein.
Sie wurde hier anfangs mit `Math.pow()` gerechnet. Der Wert war korrekt — eine
Diagnoseabfrage lieferte für MIDI 79 sauber 784 —, kam als Kommazahl im
Tonausgang aber falsch an. Der micro:bit machte daraus eine unsinnige
Schwingungsdauer, krachte laut, blieb hängen und bediente den Funk nicht mehr;
nach ein paar Sekunden starb die BLE-Verbindung an `Connection Timeout (0x08)`.
Ein Fehler, drei Symptome, die wie drei verschiedene Probleme aussahen. Gefunden
wurde er durch den direkten Vergleich zweier Aufrufe, die sich nur in diesem
einen Punkt unterschieden:
```
gerechnete 784 Hz -> Krachen, hängt, Verbindung weg
übergebene 784 -> sauberer Ton
```
`midiToFreq()` in `main.ts` rechnet deshalb rein ganzzahlig über eine Tabelle.
`tools/check_melodies.py` prüft das bei jedem Build mit.
Zwei Dinge, die dabei **nicht** die Ursache waren, obwohl sie lange danach
aussahen: der eingebaute Melodie-Player von MakeCode und der interne
Lautsprecher des micro:bit V2. Beides wurde gegengeprüft und entlastet.
## Nicht vorhanden
Damit niemand erneut danach sucht — Proxi hat **keine** Touch-Sensoren, **keinen**
Servo und **keine** regelbaren Motoren. Früher lagen in diesem Repo
`Source/*.py`-Dateien, die genau das behaupteten. Sie waren KI-generiert und
frei erfunden; ihre Pin-Belegung (P0P3 als PWM-Motoren, P4/P10 als IR, P6/P7 als
Touch, P8 als Servo) stimmt an keiner Stelle mit der Hardware überein. Sie sind
durch die echten, aus den `.hex`-Dateien extrahierten Quellen ersetzt.
Nebenbei: MicroPython auf dem micro:bit hat überhaupt kein Bluetooth. Wer BLE
will, muss über MakeCode gehen — daher TypeScript statt Python.
+78
View File
@@ -0,0 +1,78 @@
# Lizenzen und Herkunft
## Eigener Code
**MIT**, Copyright (c) 2026 Christian Gärtner — siehe [`../LICENSE`](../LICENSE).
Betrifft alles in diesem Ordner außer den unten aufgeführten Teilen:
```
firmware/main.ts bridge/proxi_bridge.py tools/hex_source.py
firmware/build.sh bridge/*.txt tools/check_melodies.py
agent/proxi-control.md docs/
```
Damit steht das gesamte Projekt unter MIT — auch die beiden fremden
Bestandteile sind MIT-lizenziert, es gibt also keine Reibung zwischen den
Lizenzen. Die fremden Copyright-Vermerke müssen bei der Weitergabe erhalten
bleiben; sie stehen dafür in
[`../THIRD-PARTY-NOTICES`](../THIRD-PARTY-NOTICES).
## Fremder Code
### `firmware/proxi.ts` — Hardware-Extension
Die komplette Hardware-Anbindung (Motoren, IR-Sensoren) stammt nicht von uns.
| | |
|---|---|
| Ursprünglich | Extension **TobbieII** von [kaku111](https://github.com/kaku111/20190209) |
| Lizenz | MIT (`LICENSE.txt` im Repo, `"license": "MIT"` in dessen `pxt.json`) |
| Weg hierher | Kosmos liefert eine eingedeutschte Fassung als `custom.ts` in den `.hex`-Dateien der Beispielprogramme mit; von dort mit `tools/hex_source.py` extrahiert |
| Änderungen | keine — nur ein Herkunftshinweis als Kommentar im Kopf |
Proxi und Tobbie II sind derselbe Roboter unter verschiedenen Namen. Kosmos'
eigenes Programm `12_APP_Fernbedienung` hängt noch direkt an der
Original-Extension:
```json
"TobbieII": "github:kaku111/20190209#9c728847d1a08815275108ec42009211d57c2f0d"
```
Der MIT-Lizenztext des Originals liegt im Wortlaut in
[`../THIRD-PARTY-NOTICES`](../THIRD-PARTY-NOTICES).
### Notendaten in `firmware/main.ts`
Die Melodien in `melodyBuffer()` sind die 20 eingebauten Melodien von MakeCode,
Byte für Byte übernommen aus
[`pxt-microbit`](https://github.com/microsoft/pxt-microbit)
(`libs/core/melodies.ts`, MIT, Copyright Microsoft Corporation).
Warum kopiert statt benutzt: der eingebaute Melodie-Player spielt in einem
eigenen Fiber ab und stürzt ab, sobald ein anderer Fiber gleichzeitig den
Audio-Pin anfasst. Die Begründung steht ausführlich im Kopf des
Musik-Abschnitts von `main.ts`.
## Nicht mitveröffentlichen
Der Rest dieses Repos gehört Kosmos und ist **kein** Teil des
Proxi-AI-Projekts:
```
Kosmos-Programme/ Beispielprogramme (.hex und extrahierter Quelltext)
Proxi_Manual.pdf Bedienungsanleitung
logo.png proxi.png Produktbilder
```
Für ein öffentliches Repo wandert dieser Ordner am Stück mit — er ist in
sich geschlossen:
```
firmware/ bridge/ agent/ docs/ tools/
LICENSE THIRD-PARTY-NOTICES README.md
```
`docs/HARDWARE.md` ist so geschrieben, dass es auch ohne die übrigen
Kosmos-Programme verständlich bleibt, und `README.md` in diesem Ordner taugt
als Startseite des neuen Repos.
+226
View File
@@ -0,0 +1,226 @@
# Proxi Command Protocol 1.0
Textbefehle über Bluetooth-LE-UART. Ein Befehl pro Zeile, eine Antwortzeile
zurück.
```
BEFEHL:PARAM:PARAM\n -> OK:...\n | ERR:...\n | <Wert>\n
```
* Befehlsname und Schlüsselwörter sind **nicht** groß-/kleinschreibungsabhängig
* Parameter trennt `:`
* Jede Zeile endet mit `\n`
* Fehlende Parameter fallen auf den Default zurück, unlesbare ebenso
* Es gibt **keinen Speed-Parameter** — die Motoren können nur an oder aus,
siehe [HARDWARE.md](HARDWARE.md)
## Dauer-Parameter
Alle Bewegungsbefehle nehmen als letzten Parameter eine Dauer in Millisekunden.
| Angabe | Wirkung |
|---|---|
| weggelassen | fährt los und stoppt nach **10 s** von selbst (Sicherheitsnetz) |
| `1..60000` | fährt genau so lange und stoppt |
| `0` | fährt weiter, bis ein Stopp-Befehl kommt |
Die Firmware blockiert während der Fahrt nicht: der Befehl wird sofort mit `OK`
quittiert, ein Watchdog schaltet die Motoren zur Deadline ab. `STOP` kommt
deshalb auch mitten in einer laufenden Bewegung durch.
## Bewegung
| Befehl | Wirkung |
|---|---|
| `MOVE:FWD[:ms]` | vorwärts laufen |
| `MOVE:BWD[:ms]` | rückwärts laufen |
| `MOVE:STOP` | Laufmotor aus |
| `TURN:LEFT[:ms]` | Oberkörper nach links drehen |
| `TURN:RIGHT[:ms]` | nach rechts drehen |
| `TURN:STOP` | Drehmotor aus |
| `STOP` | alles aus: beide Motoren, Ton, Display, laufende Jobs |
Lauf- und Drehmotor sind unabhängig. Beide gleichzeitig ergibt eine Kurve:
```
MOVE:FWD:0
TURN:LEFT:800
```
## Bewegungsfiguren
Laufen im Hintergrund und melden sich mit `DONE:<name>`, wenn sie fertig sind —
oder mit `DONE:<name>:aborted`, wenn `STOP` oder Taste A dazwischenkam.
| Befehl | Default | Wirkung |
|---|---|---|
| `DANCE[:n]` | 2 | n Takte tanzen (vor/zurück + links/rechts) |
| `STAMP[:n]` | 3 | n mal aufstampfen |
| `SHAKE[:n]` | 3 | n mal den Kopf schütteln |
`n` wird auf 1..20 begrenzt.
**Sie laufen nicht parallel zur Musik.** `DANCE`, `STAMP`, `SHAKE`, `TEXT`,
`TONE` und `MELODY` teilen sich einen Arbeiter und werden nacheinander
abgearbeitet — eine Figur startet also erst, wenn die Melodie durch ist.
`MOVE` und `TURN` laufen dagegen sofort und damit echt gleichzeitig zur Musik.
Für eine Choreografie zur Melodie deshalb `MOVE`/`TURN` verwenden; ein
Beispiel liegt in `bridge/nyan-dance.txt`.
## Display
| Befehl | Wirkung |
|---|---|
| `FACE:<name>` | Symbol anzeigen (bleibt stehen) |
| `FACE:RAW:z0:z1:z2:z3:z4` | eigenes 5×5-Bild, je Zeile eine Bitmaske 031 |
| `TEXT:<text>` | Text durchlaufen lassen, danach Display leer |
| `CLEAR` | Display löschen |
**Namen für `FACE`:**
`HAPPY` `SAD` `ANGRY` `CONFUSED` `ASLEEP` `SURPRISED` `SILLY` `FABULOUS` `MEH`
`YES` `NO` `HEART` `SMALLHEART` `SKULL` `GHOST` `DUCK` `HOUSE` `GIRAFFE` `COW`
`RABBIT` `SNAKE` `BUTTERFLY` `TORTOISE` `STICKFIGURE` `TARGET` `DIAMOND`
`SQUARE` `TRIANGLE` `UMBRELLA` `SWORD` `NORTH` `EAST` `SOUTH` `WEST`
Bei `FACE:RAW` ist in jeder Zeilen-Bitmaske Bit 0 die linke LED:
```
FACE:RAW:0:10:0:17:14 # zwei Augen, lächelnder Mund
```
`TEXT` darf `:` enthalten — alles ab dem ersten Parameter gilt als Text.
## Ton
| Befehl | Default | Wirkung |
|---|---|---|
| `TONE:<hz>[:ms]` | 440, 400 | einzelner Ton (505000 Hz, 105000 ms) |
| `MELODY:<name>` | — | eingebaute Melodie, spielt im Hintergrund |
**Namen für `MELODY`:**
`DADADUM` `ENTERTAINER` `PRELUDE` `ODE` `NYAN` `RINGTONE` `FUNK` `BLUES`
`BIRTHDAY` `WEDDING` `FUNERAL` `PUNCHLINE` `BADDY` `CHASE` `BADING` `WAWAWAWAA`
`JUMPUP` `JUMPDOWN` `POWERUP` `POWERDOWN`
**Laufzeiten** (für Choreografien):
| | |
|---|---|
| ~0,51 s | `BADING` `JUMPUP` `JUMPDOWN` `POWERUP` `POWERDOWN` |
| ~2,53,5 s | `WAWAWAWAA` `PUNCHLINE` `RINGTONE` `BADDY` `ENTERTAINER` `FUNERAL` |
| ~4 s | `DADADUM` `FUNK` |
| ~8 s | `CHASE` `WEDDING` `PRELUDE` |
| ~12 s | `BIRTHDAY` `BLUES` |
| ~16 s | `ODE` `NYAN` |
Mehrere `MELODY` hintereinander reihen sich in der Warteschlange und spielen
nahtlos nacheinander.
## Sensoren
| Befehl | Antwort |
|---|---|
| `SENSOR:ALL` | `SENSOR:ALL:IR_L=..,IR_R=..,TEMP=..,PWR=..` |
| `SENSOR:IR_L` | `SENSOR:IR_L:<0..1023>` — höher = Hindernis näher |
| `SENSOR:IR_R` | `SENSOR:IR_R:<0..1023>` |
| `SENSOR:TEMP` | `SENSOR:TEMP:<°C>` (Prozessortemperatur, träge) |
| `SENSOR:PWR` | `SENSOR:PWR:<0\|1>` — Zustand von P8, siehe HARDWARE.md |
Ohne Parameter verhält sich `SENSOR` wie `SENSOR:ALL`.
**IR-Werte, am Gerät gemessen:**
| Situation | Wert |
|---|---|
| freie Sicht | 515 |
| Hand in ~20 cm | 3090 |
| Hand in ~10 cm | 200600 |
| Hand direkt davor | bis ~980 |
Als Hindernis-Schwelle taugen die 400600, die auch Kosmos' eigene Programme
verwenden — das entspricht etwa einer Handbreit Abstand. Beide Sensoren
erreichen den vollen Bereich; ein dauerhaft niedriger Wert auf einer Seite ist
also ein Defekt und keine Eigenart.
## System
`<build>` ist eine siebenstellige Kennung des Quellstands, die `build.sh` aus
main.ts, proxi.ts und pxt.json errechnet. Geflasht wird von Hand — daran sieht
man, welcher Stand tatsächlich auf dem Roboter liegt. Gleicher Code ergibt
immer dieselbe Kennung.
| Befehl | Antwort |
|---|---|
| `PING` | `PONG` |
| `STATUS` | `STATUS:proxi-ai/1.0+<build>:PWR=<0\|1>` |
## `WAIT:<ms>` — Pause in der Brücke
`WAIT` ist **kein Roboter-Befehl**. Die Firmware kennt ihn nicht;
`proxi_bridge.py` fängt ihn ab und legt die angegebene Pause ein, bevor der
nächste Befehl rausgeht (maximal 60000 ms).
Er wird gebraucht, weil Bewegungsbefehle sofort mit `OK` quittieren und dann im
Hintergrund weiterlaufen. Ohne Pause dazwischen überschreibt der nächste
Befehl die noch laufende Bewegung — Proxi zuckt nur kurz:
```
MOVE:FWD:1200 # läuft 1,2 s ...
WAIT:1400 # ... so lange warten
TURN:LEFT:700
WAIT:900
STOP
```
Faustregel: `WAIT` etwas größer wählen als die Dauer davor.
## Unaufgeforderte Meldungen
Diese Zeilen kommen ohne passenden Befehl und sind keine Antwort:
| Zeile | Bedeutung |
|---|---|
| `READY:proxi-ai/1.0+<build>` | BLE verbunden, Firmware bereit — kommt oft nicht an, weil der Client die Indications meist erst kurz nach dem Verbindungsaufbau aktiviert. Verlass dich nicht darauf, nimm `PING` |
| `DONE:<job>` | `DANCE`/`STAMP`/`SHAKE`/`TEXT`/`TONE`/`MELODY` fertig |
| `DONE:<job>:aborted` | derselbe Job, aber vorzeitig gestoppt |
| `EVENT:ESTOP` | Taste A gedrückt — Not-Stopp |
`proxi_bridge.py` hält sie von den Befehlsantworten getrennt.
## Fehler
| Antwort | Bedeutung |
|---|---|
| `ERR:unknown:<CMD>` | Befehl gibt es nicht |
| `ERR:MOVE:bad_dir` | erwartet `FWD`, `BWD` oder `STOP` |
| `ERR:TURN:bad_dir` | erwartet `LEFT`, `RIGHT` oder `STOP` |
| `ERR:FACE:unknown` | Symbolname unbekannt |
| `ERR:MELODY:unknown` | Melodiename unbekannt |
| `ERR:SENSOR:bad_type` | Sensorname unbekannt |
| `ERR:TEXT:empty` | kein Text angegeben |
| `ERR:<CMD>:busy` | Warteschlange voll (8 Aufträge). Betrifft `TEXT`, `TONE`, `MELODY`, `DANCE`, `STAMP`, `SHAKE` — mit `WAIT` entzerren |
## Tasten am Roboter
Funktionieren auch ohne Verbindung.
| Taste | Wirkung |
|---|---|
| **A** | Not-Stopp — alles aus, sendet `EVENT:ESTOP` |
| **B** | Status senden |
Bricht die BLE-Verbindung ab, stoppen die Motoren sofort von selbst.
## Beispiel
```
PING -> PONG
FACE:HAPPY -> OK:FACE:HAPPY
MOVE:FWD:1500 -> OK:MOVE:FWD:1500
SENSOR:IR_L -> SENSOR:IR_L:612
TURN:RIGHT:700 -> OK:TURN:RIGHT:700
MELODY:POWERUP -> OK:MELODY:POWERUP
DANCE:3 -> OK:DANCE:3
-> DONE:DANCE (später, unaufgefordert)
STOP -> OK:STOP
```
+185
View File
@@ -0,0 +1,185 @@
# Einrichtung
Zwei Schritte: Firmware auf den micro:bit, Brücke auf irgendeinen Rechner mit
Bluetooth.
```
KI / LLM --HTTP--> proxi_bridge.py --BLE--> micro:bit im Proxi
(Pi Zero W, Laptop, ...)
```
## 1. Firmware flashen
Läuft auf micro:bit **V1 und V2** — die `.hex` ist ein Universal Hex und
enthält beides.
1. micro:bit per USB anschließen — er meldet sich als Laufwerk `MICROBIT`
2. [`firmware/proxi-ai.hex`](../firmware/proxi-ai.hex) darauf kopieren
3. Die gelbe LED blinkt, das Laufwerk verschwindet und kommt zurück — fertig
Danach zeigt Proxi ein schlafendes Gesicht: Firmware läuft, wartet auf
Bluetooth.
Ein Nachflashen ist nie nötig. Alles Weitere passiert über Befehle.
### Selbst bauen
Nur nötig, wenn du am Code etwas änderst.
```bash
cd firmware/proxi-ai
npm install pxt
./node_modules/.bin/pxt target microbit
./node_modules/.bin/pxt install
./build.sh # -> proxi-ai.hex
```
Der C++-Teil wird vom MakeCode-Build-Service kompiliert, dafür braucht der
erste Build Internet. `build.sh` prüft am Ende, ob die erzeugte `.hex`
tatsächlich den aktuellen `main.ts` enthält.
Auf dem V1 ist der Flash mit Bluetooth-Stack fast voll. Kommt beim Erweitern
`program too big by ... bytes`, betrifft das die V1-Variante.
Alternativ im Browser: [makecode.microbit.org](https://makecode.microbit.org)
öffnen, neues Projekt, in der JavaScript-Ansicht `main.ts` einfügen, über den
Explorer eine Datei `proxi.ts` anlegen und deren Inhalt einfügen. Die
Bluetooth-Extension muss hinzugefügt und in den Projekteinstellungen
`bluetooth.open` auf `1` gesetzt werden — sonst verlangt der micro:bit Pairing
und die Brücke kommt nicht rein.
## 2. Brücke einrichten
Läuft auf allem mit Linux und Bluetooth. Ein Pi Zero W reicht.
```bash
pip3 install bleak
cd bridge
python3 proxi_bridge.py --scan
```
Ausgabe etwa:
```
C4:1F:2B:AA:BB:CC BBC micro:bit [tuvig]
```
Testen:
```bash
python3 proxi_bridge.py --ping
python3 proxi_bridge.py "FACE:HEART" "MOVE:FWD:1000" "DANCE:2"
python3 proxi_bridge.py --script demo.txt
python3 proxi_bridge.py -i # interaktiv
```
Die Adresse lässt sich festnageln, das spart den Suchlauf:
```bash
export PROXI_ADDRESS=C4:1F:2B:AA:BB:CC
```
## 3. HTTP-API für die KI
```bash
python3 proxi_bridge.py --serve 8080
```
| Route | Zweck |
|---|---|
| `POST /command` | `{"command": "MOVE:FWD:1000"}` |
| `POST /commands` | `{"commands": ["FACE:HAPPY", "DANCE:2"]}` |
| `GET /status` | Verbindungszustand und die letzten Meldungen vom Roboter |
```bash
curl -X POST localhost:8080/command \
-H 'Content-Type: application/json' \
-d '{"command":"DANCE:3"}'
```
```json
{"command": "DANCE:3", "response": "OK:DANCE:3", "ok": true}
```
> ### ⚠️ Nur im eigenen Netz betreiben
>
> Die API hat **keine Authentifizierung**. Wer sie erreicht, fährt den Roboter —
> ohne Passwort, ohne Rückfrage.
>
> Im heimischen WLAN ist das unproblematisch und bewusst so gehalten: es soll
> ohne Hürden funktionieren. **Aber gib den Port nicht nach außen frei** und
> richte keine Portweiterleitung darauf ein. Sonst lässt sich ein Roboter, der
> im Kinderzimmer herumfährt, von jedem beliebigen Fremden steuern.
>
> Soll er über das Heimnetz hinaus erreichbar sein, gehört ein VPN davor —
> nicht eine Portfreigabe.
Im `--serve`-Modus startet der HTTP-Server **sofort** und sucht den Roboter
danach im Hintergrund, alle 10 Sekunden. Die API ist damit auch erreichbar,
wenn Proxi gerade aus ist — `GET /status` meldet dann `"connected": false`,
Befehle antworten mit `ERR:not_connected`. Sobald Proxi eingeschaltet wird,
verbindet sich die Brücke von selbst; niemand muss sich einloggen.
Das ist der Unterschied zu den interaktiven Modi: die brechen sofort ab, wenn
der Roboter nicht da ist, weil man dort auf eine Antwort wartet.
Als Autostart auf einem Pi:
```ini
# /etc/systemd/system/proxi-bridge.service
[Unit]
Description=Proxi BLE Bridge
After=bluetooth.target
[Service]
ExecStart=/usr/bin/python3 /home/pi/proxi/proxi_bridge.py --serve 8080
Environment=PROXI_ADDRESS=C4:1F:2B:AA:BB:CC
Restart=always
RestartSec=5
User=pi
[Install]
WantedBy=multi-user.target
```
## 4. KI anbinden
[`agent/proxi-control.md`](../agent/proxi-control.md) ist ein fertiger
Agent-Skill: er enthält das Protokoll und die Regeln, nach denen aus
"lass ihn tanzen" eine Befehlsfolge wird.
Der Skill enthält **keine feste Adresse** — er erwartet sie in der
Umgebungsvariablen `PROXI_URL`. Die muss in der Umgebung des Agenten gesetzt
sein:
```bash
export PROXI_URL=http://<hostname-des-pi>.local:8080
# oder, falls der Name nicht auflöst:
export PROXI_URL=http://<ip-des-pi>:8080
```
Absichtlich so: die Adresse hängt vom eigenen Netz ab und hat in einer Datei,
die weitergegeben wird, nichts verloren.
**mDNS-Namen (`*.local`) funktionieren nur im selben Netz.** Sitzt der Agent in
einem anderen VLAN als der Pi, nimm die IP-Adresse — oder aktiviere im Router
die mDNS-Weiterleitung.
Der Agent braucht **nur HTTP-Zugriff**, weder das Repo noch Python. Er soll
`proxi_bridge.py` ausdrücklich nicht selbst starten: Bluetooth LE erlaubt nur
eine Verbindung zum Roboter, und die hält der Dienst.
## Wenn etwas klemmt
| Symptom | Ursache |
|---|---|
| `--scan` findet nichts | Proxi aus, oder schon mit einem anderen Gerät verbunden — BLE erlaubt nur eine Verbindung |
| Verbindung bricht sofort ab | Firmware ohne `bluetooth.open=1` gebaut, der micro:bit verlangt dann Pairing |
| Befehle kommen mit `OK` zurück, Proxi bewegt sich nicht | `SENSOR:PWR` prüfen. `0` heißt: Enable-Leitung P8 ist low, meist der Batterieschalter |
| Antworten bleiben aus, Befehle wirken | Auf dem falschen Characteristic gelauscht. Host schreibt auf `…0003`, der micro:bit meldet sich auf `…0002` |
| Lange `TEXT:`-Zeilen abgeschnitten | BLE-Attribute sind 20 Byte, längere Zeilen müssen gestückelt werden — `proxi_bridge.py` macht das |
| Dienst startet immer wieder neu, Port nie offen | Alte Fassung: die Brücke verband sich zuerst und startete den Server danach. Ab Werk behoben — im `--serve`-Modus kommt der Server zuerst |
| Pi nach dem Abziehen des Kabels nicht mehr da | Er hat per WLAN eine **andere** Adresse als per Kabel. `avahi-daemon` installieren, dann geht `<hostname>.local`. mDNS bleibt aber im eigenen Netz — über VLAN-Grenzen hinweg braucht es einen mDNS-Repeater im Router |
| Display bleibt schwarz | Firmware ohne Bluetooth-Extension gebaut, `bluetooth.startUartService()` stirbt dann still beim Start |
| Ton kracht, Proxi hängt, Verbindung weg | Eine Frequenz kam als Kommazahl in `music.playTone()`. Siehe HARDWARE.md, "Ton: keine Kommazahlen" |
| Ein behobener Fehler tritt wieder auf | Vermutlich läuft noch die alte Firmware. `STATUS` schicken und die Build-Kennung dahinter mit der Ausgabe von `build.sh` vergleichen |
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+5
View File
@@ -0,0 +1,5 @@
node_modules/
pxt_modules/
built/
package.json.npm
package-lock.json
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Baut die Proxi-AI-Firmware zu einer flashbaren .hex.
#
# ./build.sh -> proxi-ai.hex
#
# Das Ergebnis ist ein "Universal Hex": es enthaelt den Code für micro:bit V1
# und V2 und läuft auf beiden.
#
# Erstmalige Einrichtung (einmalig, braucht Internet — der C++-Teil wird vom
# MakeCode-Build-Service kompiliert):
# npm install pxt
# ./node_modules/.bin/pxt target microbit
# ./node_modules/.bin/pxt install
set -euo pipefail
cd "$(dirname "$0")"
OUT="built/binary.hex"
if [[ ! -d node_modules/pxt-microbit ]]; then
echo "pxt-microbit fehlt. Erst einrichten:" >&2
echo " npm install pxt && ./node_modules/.bin/pxt target microbit && ./node_modules/.bin/pxt install" >&2
exit 1
fi
# Build-Kennung aus dem Quellstand erzeugen. Geflasht wird von Hand — ohne
# diese Kennung sieht man dem Roboter nicht an, welcher Stand auf ihm liegt.
# Der Hash läuft nur über die Quellen, version.ts selbst zählt nicht mit;
# gleicher Code ergibt also immer dieselbe Kennung.
BUILD_ID=$(cat main.ts proxi.ts pxt.json | sha256sum | cut -c1-7)
cat > version.ts <<EOF
// Erzeugt von build.sh — nicht von Hand ändern.
const FW_BUILD = "$BUILD_ID"
EOF
echo "Build-Kennung: $BUILD_ID"
# Musik-Tabellen prüfen, bevor überhaupt gebaut wird. Der Fehler, der das
# hier nötig gemacht hat, war am Schreibtisch unsichtbar und nur am Gerät zu
# sehen — dieser Test fängt ihn vorher ab.
python3 ../tools/check_melodies.py
rm -f "$OUT"
./node_modules/.bin/pxt build
cp "$OUT" proxi-ai.hex
# Gegenprobe: die eingebetteten Quellen aus der .hex zurückholen und vergleichen.
python3 ../tools/hex_source.py --check proxi-ai.hex main.ts
python3 ../tools/hex_source.py --check proxi-ai.hex proxi.ts
echo
echo "Fertig: $(pwd)/proxi-ai.hex"
echo "Auf das MICROBIT-Laufwerk kopieren — fertig."
+718
View File
@@ -0,0 +1,718 @@
/**
* Proxi AI — BLE UART Command Interface
* =====================================
*
* Firmware für den micro:bit im KOSMOS-Proxi-Roboter (baugleich Tobbie II).
* Nimmt Textbefehle über Bluetooth-LE-UART entgegen und antwortet auf demselben Weg.
*
* Protokoll: CMD:PARAM:PARAM\n -> OK:...\n | ERR:...\n
* Vollständige Referenz: docs/PROTOCOL.md
*
* Wichtig: Die Motoren sind reine An/Aus-H-Brücken — es gibt KEINE
* Geschwindigkeitsregelung. Gesteuert wird ausschließlich über die Dauer.
* Die Hardware-Zugriffe laufen komplett über die Kosmos-Extension in proxi.ts.
*/
// ============================================================
// KONFIGURATION
// ============================================================
/** Obergrenze für eine Bewegung ohne explizite Dauer (ms). Sicherheitsnetz. */
const SAFETY_MS = 10000
/** Obergrenze für jede einzelne Bewegung (ms). */
const MAX_MOVE_MS = 60000
const FW_VERSION = "proxi-ai/1.0"
/**
* Kurzkennung des Quellstands, erzeugt von build.sh in version.ts.
* Geflasht wird von Hand — ohne die Kennung lässt sich nicht feststellen,
* welcher Stand gerade auf dem Roboter liegt, und man testet gegen die
* falsche Firmware. STATUS gibt sie deshalb mit zurück.
*/
function fwId(): string {
return FW_VERSION + "+" + FW_BUILD
}
// ============================================================
// ZUSTAND
// ============================================================
let connected = false
/** control.millis(), zu dem der Laufmotor abgeschaltet wird. 0 = kein Timer. */
let walkUntil = 0
/** control.millis(), zu dem der Drehmotor abgeschaltet wird. 0 = kein Timer. */
let turnUntil = 0
/**
* Warteschlange für den Aux-Fiber (alles was länger blockiert).
* Ein einzelner Slot reichte nicht: während ein langer Job lief, hat der
* nächste den übernächsten überschrieben — quittiert war er da aber
* längst mit OK. Lieber eine kurze Schlange und ein ehrliches "busy".
*/
const AUX_QUEUE_MAX = 8
let auxKinds: string[] = []
let auxTexts: string[] = []
let auxNums: number[] = []
let auxNums2: number[] = []
/** Bricht einen laufenden Job ab. Setzt stopAll(). */
let auxAbort = false
/**
* Sicherung gegen einen hängenden Lautsprecher.
* Reißt die Verbindung mitten in einem Ton ab, kommt der Aux-Fiber unter
* Umständen nicht mehr dazu, ihn abzuschalten — dann tönt der Pin weiter,
* bis jemand den Roboter ausmacht. Vor jedem Ton wird deshalb notiert, wann
* er spätestens vorbei sein muss; der Watchdog-Fiber legt ihn danach still.
* 0 = gerade kein Ton erwartet.
*/
let audioUntil = 0
/** Ton anmelden. ms = erwartete Dauer. */
function audioBegin(ms: number): void {
audioUntil = control.millis() + ms + 300
}
/**
* Lautsprecher stilllegen.
*
* Bewusst NICHT music.stopAllSounds(): das ruft intern stopMelody() auf und
* zieht damit den kompletten Melodie-Player wieder mit ins Programm — also
* genau die rund 3 KB, wegen derer die Firmware nicht mehr auf den micro:bit
* V1 passt. playTone mit Frequenz 0 legt den Pin genauso still.
*/
function audioSilence(): void {
music.playTone(0, 1)
}
/** Ton abmelden — der Aux-Fiber hat regulär zu Ende gespielt. */
function audioEnd(): void {
audioUntil = 0
audioSilence()
}
/** false = Schlange voll. */
function queueAux(kind: string, text: string, n1: number, n2: number): boolean {
if (auxKinds.length >= AUX_QUEUE_MAX) return false
auxKinds.push(kind)
auxTexts.push(text)
auxNums.push(n1)
auxNums2.push(n2)
return true
}
function clearAuxQueue(): void {
auxKinds = []
auxTexts = []
auxNums = []
auxNums2 = []
}
// ============================================================
// STRING-HILFEN
// (MakeCode kennt weder split(), noch toUpperCase(), noch trim())
// ============================================================
function trimLine(s: string): string {
let a = 0
let b = s.length
while (a < b) {
const c = s.charAt(a)
if (c == " " || c == "\t" || c == "\r" || c == "\n") a++
else break
}
while (b > a) {
const c = s.charAt(b - 1)
if (c == " " || c == "\t" || c == "\r" || c == "\n") b--
else break
}
return s.substr(a, b - a)
}
function upper(s: string): string {
let out = ""
for (let i = 0; i < s.length; i++) {
let c = s.charCodeAt(i)
if (c >= 97 && c <= 122) c = c - 32
out = out + String.fromCharCode(c)
}
return out
}
function splitColon(s: string): string[] {
const out: string[] = []
let cur = ""
for (let i = 0; i < s.length; i++) {
const c = s.charAt(i)
if (c == ":") {
out.push(cur)
cur = ""
} else {
cur = cur + c
}
}
out.push(cur)
return out
}
function arg(p: string[], i: number): string {
return i < p.length ? p[i] : ""
}
/** Parameter als Zahl, mit Default bei fehlend/unlesbar. */
function argNum(p: string[], i: number, dflt: number): number {
const s = arg(p, i)
if (s == "") return dflt
const v = parseInt(s)
// NaN-Test ohne isNaN(): NaN ist der einzige Wert, der sich selbst ungleich ist.
if (v != v) return dflt
return v
}
function clamp(v: number, lo: number, hi: number): number {
if (v < lo) return lo
if (v > hi) return hi
return v
}
/** Alles ab Index i wieder mit ":" zusammensetzen — für Freitext. */
function joinFrom(p: string[], i: number): string {
let out = ""
for (let k = i; k < p.length; k++) {
if (k > i) out = out + ":"
out = out + p[k]
}
return out
}
// ============================================================
// ANTWORTEN
// ============================================================
function reply(line: string): void {
if (connected) bluetooth.uartWriteLine(line)
}
// ============================================================
// BEWEGUNG
// ============================================================
/**
* Dauer eines Bewegungsbefehls auflösen.
* fehlt / leer -> SAFETY_MS (Sicherheitsnetz)
* 0 -> 0 = dauerhaft, bis STOP (bewusste Entscheidung des Aufrufers)
*/
function moveDuration(p: string[], i: number): number {
const s = arg(p, i)
if (s == "") return SAFETY_MS
const v = argNum(p, i, SAFETY_MS)
if (v == 0) return 0
if (v < 0) return SAFETY_MS
return clamp(v, 1, MAX_MOVE_MS)
}
function stopWalk(): void {
walkUntil = 0
Proxi.stehenbleiben()
}
function stopTurn(): void {
turnUntil = 0
Proxi.drehungsstopp()
}
function stopAll(): void {
clearAuxQueue()
auxAbort = true
stopWalk()
stopTurn()
// Der Ton muss hier weg, auch wenn stopAll() aus einem fremden Fiber
// kommt: ohne das tönt der Lautsprecher nach einem Verbindungsabbruch
// einfach weiter. Die kurze Frist des Watchdogs fängt den Rest ab.
audioUntil = control.millis() + 50
}
// ============================================================
// BEWEGUNGSFIGUREN
//
// Die Extension bringt tanz()/stampfen()/kopf_schuetteln() schon mit, die
// laufen aber am Stück durch — ein STOP mittendrin käme erst danach an.
// Deshalb hier nachgebaut, Schritt für Schritt und mit Abbruchprüfung.
// Die Pins fasst weiterhin ausschließlich die Extension an.
// ============================================================
/** Pause in kleinen Scheiben. false = abgebrochen. */
function figurePause(ms: number): boolean {
let left = ms
while (left > 0) {
if (auxAbort) return false
const slice = left > 50 ? 50 : left
basic.pause(slice)
left = left - slice
}
return !auxAbort
}
function doDance(times: number): void {
for (let i = 0; i < times; i++) {
Proxi.rückwärts()
Proxi.rechtsdrehung()
if (!figurePause(250)) break
Proxi.vorwärts()
Proxi.linksdrehung()
if (!figurePause(250)) break
}
Proxi.stehenbleiben()
Proxi.drehungsstopp()
}
function doStamp(times: number): void {
for (let i = 0; i < times; i++) {
Proxi.vorwärts()
if (!figurePause(150)) break
Proxi.rückwärts()
if (!figurePause(150)) break
}
Proxi.stehenbleiben()
}
function doShake(times: number): void {
for (let i = 0; i < times; i++) {
Proxi.linksdrehung()
if (!figurePause(250)) break
Proxi.rechtsdrehung()
if (!figurePause(250)) break
}
Proxi.drehungsstopp()
}
function cmdMove(p: string[]): string {
const dir = upper(arg(p, 1))
if (dir == "STOP") {
stopWalk()
return "OK:MOVE:STOP"
}
if (dir != "FWD" && dir != "BWD") return "ERR:MOVE:bad_dir"
const ms = moveDuration(p, 2)
if (dir == "FWD") Proxi.vorwärts()
else Proxi.rückwärts()
walkUntil = ms > 0 ? control.millis() + ms : 0
return "OK:MOVE:" + dir + ":" + ms
}
function cmdTurn(p: string[]): string {
const dir = upper(arg(p, 1))
if (dir == "STOP") {
stopTurn()
return "OK:TURN:STOP"
}
if (dir != "LEFT" && dir != "RIGHT") return "ERR:TURN:bad_dir"
const ms = moveDuration(p, 2)
if (dir == "LEFT") Proxi.linksdrehung()
else Proxi.rechtsdrehung()
turnUntil = ms > 0 ? control.millis() + ms : 0
return "OK:TURN:" + dir + ":" + ms
}
// ============================================================
// DISPLAY
// ============================================================
/** Icon anzeigen, ohne zu blockieren (Intervall 0 = sofort zurück). */
function showIconNow(icon: IconNames): void {
basic.showIcon(icon, 0)
}
function cmdFace(p: string[]): string {
const name = upper(arg(p, 1))
if (name == "RAW") {
// FACE:RAW:r0:r1:r2:r3:r4 — je Zeile eine Bitmaske 0..31, Bit 0 = links
basic.clearScreen()
for (let y = 0; y < 5; y++) {
let bits = clamp(argNum(p, 2 + y, 0), 0, 31)
for (let x = 0; x < 5; x++) {
if (bits % 2 == 1) led.plot(x, y)
bits = Math.idiv(bits, 2)
}
}
return "OK:FACE:RAW"
}
if (name == "HAPPY") showIconNow(IconNames.Happy)
else if (name == "SAD") showIconNow(IconNames.Sad)
else if (name == "ANGRY") showIconNow(IconNames.Angry)
else if (name == "CONFUSED") showIconNow(IconNames.Confused)
else if (name == "ASLEEP") showIconNow(IconNames.Asleep)
else if (name == "SURPRISED") showIconNow(IconNames.Surprised)
else if (name == "SILLY") showIconNow(IconNames.Silly)
else if (name == "FABULOUS") showIconNow(IconNames.Fabulous)
else if (name == "MEH") showIconNow(IconNames.Meh)
else if (name == "YES") showIconNow(IconNames.Yes)
else if (name == "NO") showIconNow(IconNames.No)
else if (name == "HEART") showIconNow(IconNames.Heart)
else if (name == "SMALLHEART") showIconNow(IconNames.SmallHeart)
else if (name == "SKULL") showIconNow(IconNames.Skull)
else if (name == "GHOST") showIconNow(IconNames.Ghost)
else if (name == "DUCK") showIconNow(IconNames.Duck)
else if (name == "HOUSE") showIconNow(IconNames.House)
else if (name == "GIRAFFE") showIconNow(IconNames.Giraffe)
else if (name == "COW") showIconNow(IconNames.Cow)
else if (name == "RABBIT") showIconNow(IconNames.Rabbit)
else if (name == "SNAKE") showIconNow(IconNames.Snake)
else if (name == "BUTTERFLY") showIconNow(IconNames.Butterfly)
else if (name == "TORTOISE") showIconNow(IconNames.Tortoise)
else if (name == "STICKFIGURE") showIconNow(IconNames.StickFigure)
else if (name == "TARGET") showIconNow(IconNames.Target)
else if (name == "DIAMOND") showIconNow(IconNames.Diamond)
else if (name == "SQUARE") showIconNow(IconNames.Square)
else if (name == "TRIANGLE") showIconNow(IconNames.Triangle)
else if (name == "UMBRELLA") showIconNow(IconNames.Umbrella)
else if (name == "SWORD") showIconNow(IconNames.Sword)
else if (name == "NORTH") basic.showArrow(ArrowNames.North, 0)
else if (name == "EAST") basic.showArrow(ArrowNames.East, 0)
else if (name == "SOUTH") basic.showArrow(ArrowNames.South, 0)
else if (name == "WEST") basic.showArrow(ArrowNames.West, 0)
else return "ERR:FACE:unknown"
return "OK:FACE:" + name
}
// ============================================================
// MUSIK
//
// Der eingebaute Melodie-Player von MakeCode spielt in einem EIGENEN Fiber ab,
// auf den man von außen keinen Zugriff hat. Ruft irgendein anderer Fiber
// gleichzeitig playTone() oder stopAllSounds() auf, greifen zwei Fibers
// parallel auf den Audio-Pin zu und der micro:bit stürzt ab (Panic).
// Ausgelöst hat das hier zuverlässig die Folge MELODY -> TONE -> MELODY.
//
// Deshalb wird er nicht benutzt. Die Notendaten sind dieselben wie in der
// MakeCode-Bibliothek (je zwei Bytes: MIDI-Notennummer und Dauer in
// Viertel-Schlägen, Notennummer 0 = Pause), abgespielt werden sie hier Note
// für Note — ausschließlich aus dem Aux-Fiber. Damit fasst genau ein Fiber
// jemals den Lautsprecher an, und ein Not-Stopp greift zwischen zwei Noten.
// ============================================================
const MELODY_BPM = 120
function melodyBuffer(name: string): Buffer {
if (name == "DADADUM") return hex`00024f024f024f024b0800024d024d024d024a08`
if (name == "ENTERTAINER") return hex`4a014b014c0154024c0154024c0154035401560157015801540156015802530156025404`
if (name == "PRELUDE") return hex`48014c014f01540158014f015401580148014c014f01540158014f015401580148014a014f01560159014f015601590148014a014f01560159014f015601590147014a014f01560159014f015601590147014a014f01560159014f015601590148014c014f01540158014f015401580148014c014f01540158014f0154015801`
if (name == "ODE") return hex`4c044c044d044f044f044d044c044a04480448044a044c044c064a024a084c044c044d044f044f044d044c044a04480448044a044c044a0648024808`
if (name == "NYAN") return hex`5a025c02550157025301560155015302530255025602560155015301550157015a015c0157015a015501560153015501530157025a025c0157015a0155015701530156015701560155015301550156025301550157015a01550156015501530155025302550253024e01500153024e01500153015501570153015801570158015a01530253024e01500153014e0158015701550153014e014b014c014e0153024e01500153024e015001530153015501570153014e0150014e0153025301520153014e01500153015801570158015a0153025502`
if (name == "RINGTONE") return hex`48014a014c024f024a014c014d0251024c014d014f0253025404`
if (name == "FUNK") return hex`300230023302300135023001350236023702300230023702300136023001360235023302`
if (name == "BLUES") return hex`30023402370239023a0239023702340230023402370239023a02390237023402350239023c023e023f023e023c02390230023402370239023a0239023702340237023b023e024102350239023c023f0230023402370234023702350234023202`
if (name == "BIRTHDAY") return hex`480348014a0448044d044c08480348014a0448044f044d0848034801540451044d044c044a045203520151044d044f044d08`
if (name == "WEDDING") return hex`48044d034d014d0848044f034c014d0848044d035101540451034d014d044c034d014f08`
if (name == "FUNERAL") return hex`3c043c033c013c043f033e013e033c013c033b013c04`
if (name == "PUNCHLINE") return hex`480343014201430144034303000347034803`
if (name == "BADDY") return hex`3c0300033e023f0200023c0200024208`
if (name == "CHASE") return hex`5101530154015301510200025101530154015301510200025102580257025802590258025702580253015401560154015302000253015401560154015302000253025802570258025902580257025802`
if (name == "BADING") return hex`5f016403`
if (name == "WAWAWAWAA") return hex`400300013f0300013e0400013d08`
if (name == "JUMPUP") return hex`54015601580159015b01`
if (name == "JUMPDOWN") return hex`5b015901580156015401`
if (name == "POWERUP") return hex`4f01540158015b0258015b03`
if (name == "POWERDOWN") return hex`5b01570154014f0253015403`
return null
}
/**
* Halbtöne der Oktave ab C4 (MIDI 60), mit 16 multipliziert.
* Geteilt wird erst ganz am Schluss, damit beim Oktavieren nach unten
* nichts wegläuft — sonst liegen tiefe Töne bis zu 11 Cent daneben.
*/
const NOTE_HZ16 = [4186, 4435, 4699, 4978, 5274, 5588, 5920, 6272, 6645, 7040, 7459, 7902]
/**
* MIDI-Notennummer -> Frequenz in Hz. 69 = A4 = 440 Hz.
*
* Bewusst reine Ganzzahl-Arithmetik, KEIN Math.pow und kein Math.round.
* Das Ergebnis einer Kommazahl-Rechnung ist zwar rechnerisch richtig, kommt
* aber in music.playTone() falsch an: der Tonausgang macht daraus eine
* unsinnige Schwingungsdauer, kracht laut und bleibt hängen — und riss dabei
* zuverlässig die BLE-Verbindung ab. Ein Ton mit ganzzahliger Frequenz
* (z.B. direkt aus TONE:784:200) lief dagegen immer sauber durch.
*
* Also: hier nie wieder Fließkomma einbauen.
*/
function midiToFreq(midi: number): number {
let hz16 = NOTE_HZ16[midi % 12]
let oct = Math.idiv(midi, 12) - 5
while (oct > 0) {
hz16 = hz16 * 2
oct = oct - 1
}
while (oct < 0) {
hz16 = Math.idiv(hz16, 2)
oct = oct + 1
}
return Math.idiv(hz16 + 8, 16) | 0
}
/**
* Spielt eine Notenfolge ab. Nur aus dem Aux-Fiber aufrufen.
* Gibt zurück, wie viele Noten tatsächlich gespielt wurden — steht in der
* DONE-Meldung und zeigt, wo es hängt, falls es hängt.
*/
function playMelodyBuffer(buf: Buffer): number {
// wie im Original: eine Viertel-Schlag-Einheit bei 120 bpm = 125 ms
const beat = Math.idiv(Math.idiv(60000, MELODY_BPM), 4)
let played = 0
for (let i = 0; i + 1 < buf.length; i += 2) {
if (auxAbort) break
const midi = buf[i]
const ms = buf[i + 1] * beat
if (midi == 0) {
basic.pause(ms)
} else {
audioBegin(ms)
music.playTone(midiToFreq(midi), ms)
audioUntil = 0
}
played++
}
audioEnd()
return played
}
function cmdMelody(p: string[]): string {
const name = upper(arg(p, 1))
if (!melodyBuffer(name)) return "ERR:MELODY:unknown"
if (!queueAux("MELODY", name, 0, 0)) return "ERR:MELODY:busy"
return "OK:MELODY:" + name
}
// ============================================================
// SENSOREN
// ============================================================
/**
* Es gibt bewusst kein SENSOR:LIGHT.
*
* Der Helligkeitssensor des micro:bit misst über die LED-Matrix, und die
* sitzt bei Proxi hinter einer getönten Scheibe. Ausgebaut misst der Sensor
* einwandfrei, eingebaut liefert er 0 — auch mit einer Taschenlampe direkt
* davor. Das ist kein Softwareproblem und mit Software auch nicht zu lösen.
*/
function cmdSensor(p: string[]): string {
const what = upper(arg(p, 1))
if (what == "" || what == "ALL") {
return "SENSOR:ALL:IR_L=" + Proxi.Lese_LBlock()
+ ",IR_R=" + Proxi.Lese_RBlock()
+ ",TEMP=" + input.temperature()
+ ",PWR=" + pins.digitalReadPin(DigitalPin.P8)
}
if (what == "IR_L" || what == "IR_LEFT") return "SENSOR:IR_L:" + Proxi.Lese_LBlock()
if (what == "IR_R" || what == "IR_RIGHT") return "SENSOR:IR_R:" + Proxi.Lese_RBlock()
if (what == "TEMP") return "SENSOR:TEMP:" + input.temperature()
if (what == "PWR") return "SENSOR:PWR:" + pins.digitalReadPin(DigitalPin.P8)
return "ERR:SENSOR:bad_type"
}
// ============================================================
// DISPATCHER
// ============================================================
function execute(raw: string): string {
const line = trimLine(raw)
if (line == "") return ""
const p = splitColon(line)
const cmd = upper(p[0])
if (cmd == "PING") return "PONG"
if (cmd == "STATUS") return "STATUS:" + fwId() + ":PWR=" + pins.digitalReadPin(DigitalPin.P8)
if (cmd == "STOP") {
stopAll()
basic.clearScreen()
return "OK:STOP"
}
if (cmd == "MOVE") return cmdMove(p)
if (cmd == "TURN") return cmdTurn(p)
if (cmd == "FACE") return cmdFace(p)
if (cmd == "MELODY") return cmdMelody(p)
if (cmd == "SENSOR") return cmdSensor(p)
if (cmd == "CLEAR") {
basic.clearScreen()
return "OK:CLEAR"
}
// --- Jobs, die blockieren: an den Aux-Fiber übergeben ---
if (cmd == "TEXT") {
const t = joinFrom(p, 1)
if (t == "") return "ERR:TEXT:empty"
if (!queueAux("TEXT", t, 0, 0)) return "ERR:TEXT:busy"
return "OK:TEXT"
}
if (cmd == "TONE") {
const hz = clamp(argNum(p, 1, 440), 50, 5000)
const ms = clamp(argNum(p, 2, 400), 10, 5000)
if (!queueAux("TONE", "", hz, ms)) return "ERR:TONE:busy"
return "OK:TONE:" + hz
}
if (cmd == "DANCE") {
const n = clamp(argNum(p, 1, 2), 1, 20)
if (!queueAux("DANCE", "", n, 0)) return "ERR:DANCE:busy"
return "OK:DANCE:" + n
}
if (cmd == "STAMP") {
const n = clamp(argNum(p, 1, 3), 1, 20)
if (!queueAux("STAMP", "", n, 0)) return "ERR:STAMP:busy"
return "OK:STAMP:" + n
}
if (cmd == "SHAKE") {
const n = clamp(argNum(p, 1, 3), 1, 20)
if (!queueAux("SHAKE", "", n, 0)) return "ERR:SHAKE:busy"
return "OK:SHAKE:" + n
}
return "ERR:unknown:" + cmd
}
// ============================================================
// FIBER 1 — Bewegungs-Watchdog
// Hält den BLE-Handler frei: Bewegungen laufen mit Deadline,
// STOP kommt dadurch auch mitten in einer Fahrt noch durch.
// ============================================================
control.inBackground(function () {
while (true) {
const now = control.millis()
if (walkUntil > 0 && now >= walkUntil) {
walkUntil = 0
Proxi.stehenbleiben()
}
if (turnUntil > 0 && now >= turnUntil) {
turnUntil = 0
Proxi.drehungsstopp()
}
if (audioUntil > 0 && now >= audioUntil) {
// Notbremse — hier darf der Lautsprecher ausnahmsweise aus einem
// fremden Fiber abgeschaltet werden, weil der Aux-Fiber es
// offensichtlich nicht mehr selbst tut.
audioUntil = 0
audioSilence()
}
basic.pause(10)
}
})
// ============================================================
// FIBER 2 — Aux-Worker für alles Blockierende
// ============================================================
control.inBackground(function () {
while (true) {
if (auxKinds.length > 0) {
// Die vier shift() laufen ohne Yield dazwischen und bleiben
// dadurch gegenüber den Event-Handlern konsistent.
const job = auxKinds.shift()
const text = auxTexts.shift()
const n1 = auxNums.shift()
const n2 = auxNums2.shift()
auxAbort = false
if (job == "TEXT") {
basic.showString(text)
basic.clearScreen()
} else if (job == "TONE") {
audioBegin(n2)
music.playTone(n1, n2)
audioEnd()
} else if (job == "MELODY") {
const buf = melodyBuffer(text)
if (buf) playMelodyBuffer(buf)
} else if (job == "DANCE") {
doDance(n1)
} else if (job == "STAMP") {
doStamp(n1)
} else if (job == "SHAKE") {
doShake(n1)
}
reply(auxAbort ? "DONE:" + job + ":aborted" : "DONE:" + job)
}
basic.pause(20)
}
})
// ============================================================
// BLUETOOTH
// ============================================================
bluetooth.onBluetoothConnected(function () {
connected = true
showIconNow(IconNames.Yes)
bluetooth.uartWriteLine("READY:" + fwId())
})
bluetooth.onBluetoothDisconnected(function () {
connected = false
stopAll()
showIconNow(IconNames.Asleep)
})
bluetooth.onUartDataReceived(serial.delimiters(Delimiters.NewLine), function () {
const raw = bluetooth.uartReadUntil(serial.delimiters(Delimiters.NewLine))
// Ein Absturz im Handler würde den micro:bit anhalten — dann lieber
// einen Fehler zurückmelden und weitermachen.
let response = ""
try {
response = execute(raw)
} catch (e) {
response = "ERR:exception"
}
if (response != "") reply(response)
})
// ============================================================
// TASTEN — funktionieren auch ohne Verbindung
// ============================================================
input.onButtonPressed(Button.A, function () {
stopAll()
showIconNow(IconNames.No)
reply("EVENT:ESTOP")
})
input.onButtonPressed(Button.B, function () {
reply("STATUS:" + fwId() + ":PWR=" + pins.digitalReadPin(DigitalPin.P8))
showIconNow(IconNames.Target)
})
// ============================================================
// START
// ============================================================
Proxi.stehenbleiben()
Proxi.drehungsstopp()
// Ton fest auf P0 legen — dort sitzt Proxis Summer, und darüber klingen auch
// alle Kosmos-Programme.
//
// Das ist kein Schönheitsfix, sondern zwingend: der micro:bit V2 gibt Töne
// sonst über seinen internen Audio-Mixer aus, und der lässt während des
// Tönens den Funk nicht mehr zum Zug kommen. Die BLE-Verbindung stirbt dann
// nach wenigen Sekunden an Connection Timeout (0x08) — reproduzierbar mit
// jeder längeren Melodie. Auf P0 ist der Mixer aus dem Spiel und die
// Verbindung hält auch über die 16 Sekunden von NYAN.
//
// Der eingebaute Lautsprecher des V2 darf dabei anbleiben; getestet mit
// music.setBuiltInSpeakerEnabled(true) und voller Melodie ohne Abbruch.
// Entscheidend ist allein der Pitch-Pin.
pins.analogSetPitchPin(AnalogPin.P0)
bluetooth.startUartService()
showIconNow(IconNames.Asleep)
+19116
View File
File diff suppressed because it is too large Load Diff
+365
View File
@@ -0,0 +1,365 @@
// ============================================================
// Proxi \u2014 Hardware-Extension
//
// FREMDER CODE. Unveraendert uebernommen bis auf diesen Kopf.
// Hier bitte nichts aendern: solange das die Original-Funktionen sind,
// verhaelt sich diese Firmware bei den Motoren exakt wie die Programme,
// die Kosmos mitliefert \u2014 inklusive der P8-Pruefung, deren genaue
// Bedeutung nicht dokumentiert ist (siehe docs/HARDWARE.md).
//
// Herkunft: aus der .hex von Kosmos' Beispielprogrammen extrahiert
// (dort als custom.ts), siehe tools/hex_source.py.
// Eingedeutschte Fassung der Extension "TobbieII" von kaku111,
// https://github.com/kaku111/20190209 \u2014 MIT.
// Lizenz: MIT, siehe docs/LICENSES.md
// ============================================================
//% weight=0 color=#0033E6 icon="\uf1b9" block="KOSMOS - Proxi"
//uf1b9
namespace Proxi {
let ADL_R: number = 0;
let ADH_R: number = 0;
let ADL_L: number = 0;
let ADH_L: number = 0;
let Read_LIR: number = 0;
let Read_RIR: number = 0;
let event_src_ir = 12;
let event_ir_sensor = 1;
let Motor_R: boolean = false;
let Motor_L: boolean = false;
let PX: number = 0;
let PY: number = 0;
let Force: number = 10;
function IR_sensorL(irdataL: number) { //此為中斷觸發方塊
control.inBackground(() => {
let flag = false
let last_flag = false
while (true) {
let ob: boolean = LBlock();
if (ob) { flag = true } else { flag = false }
if (flag != last_flag) {
if (flag) {
control.raiseEvent(event_src_ir, event_ir_sensor)
basic.pause(300) //300ms
}
last_flag = flag
}
basic.pause(1)
}
}
)
}
//
// 背景執行紅外線測距
// @param irdata_Set ; eg: 512
//
// //% blockId="IR_EVENTL" block="ON obstacles on the left: |%irdata_Set"
// //% irdata_Set.min=0 irdata_Set.max=1023
// //% blockGap=10 weight=99 //代表其重要性,越重放越高
// export function onIRL(irdata_Set: number = 512, handler: Action) {
// IR_sensorL(irdata_Set);
// control.onEvent(event_src_ir, event_ir_sensor, handler);
// }
// function IR_sensorR(irdata: number) {
// control.inBackground(() => {
// let flag = false
// let last_flag = false
// while (true) {
// let ob: boolean = LBlock();
// if(ob){flag=true}else{flag=false}
// if (flag != last_flag) {
// if (flag) {
// control.raiseEvent(event_src_ir, event_ir_sensor)
// basic.pause(3)
// }
// last_flag = flag
// }
// basic.pause(1)
// }
// }
// )
// }
/** Read the value sensed by the right side of the infrared sensor.
*/
//% blockId="Lese_RBlock" block="Lese rechten IR-Wert aus (Ergebnis 0 - 1024)"
//% blockGap=5 weight=65 //與下一個方塊的間隙及排重
export function Lese_RBlock(): number {
ADL_R = pins.analogReadPin(AnalogPin.P2)
pins.digitalWritePin(DigitalPin.P12, 1)
control.waitMicros(250)
ADH_R = pins.analogReadPin(AnalogPin.P2)
pins.digitalWritePin(DigitalPin.P12, 0)
if (pins.digitalReadPin(DigitalPin.P8) == 1) Read_RIR = ADH_R - ADL_R;
return (Read_RIR)
}
/** Read the value sensed by the left side of the infrared sensor.
*/
//% blockId="Lese_LBlock" block="Lese linken IR-Wert aus (Ergebnis 0 - 1024)"
//% blockGap=15 weight=60 //與下一個方塊的間隙及排重
export function Lese_LBlock(): number {
ADL_L = pins.analogReadPin(AnalogPin.P1)
pins.digitalWritePin(DigitalPin.P12, 1)
control.waitMicros(250)
ADH_L = pins.analogReadPin(AnalogPin.P1)
pins.digitalWritePin(DigitalPin.P12, 0)
Read_LIR = ADH_L - ADL_L;
return (Read_LIR)
}
/**
*Determine if there are obstacles on the right side.
*@param thresholdR ; eg: 512
*/
//% blockId="RBlock" block="wenn der rechte IR-Wert über %thresholdR liegt"
//% thresholdR.min=0 thresholdR.max=1023
//% blockGap=5 weight=58
export function RBlock(thresholdR: number = 512): boolean {
ADL_R = pins.analogReadPin(AnalogPin.P2)
pins.digitalWritePin(DigitalPin.P12, 1)
control.waitMicros(250)
ADH_R = pins.analogReadPin(AnalogPin.P2)
pins.digitalWritePin(DigitalPin.P12, 0)
if (((ADH_R - ADL_R) > thresholdR) && (pins.digitalReadPin(DigitalPin.P8) == 1)) {
//basic.showIcon(IconNames.House)
return (true)
} else {
//basic.showIcon(IconNames.Cow)
return (false)
}
}
/**
*Determine if there are obstacles on the left side.
*@param thresholdL ; eg: 512
*/
//% blockId="LBlock" block="wenn der linke IR-Wert über %thresholdL liegt"
//% thresholdL.min=0 thresholdL.max=1023
//% blockGap=10 weight=57
export function LBlock(thresholdL: number = 512): boolean {
ADL_L = pins.analogReadPin(AnalogPin.P1)
pins.digitalWritePin(DigitalPin.P12, 1)
control.waitMicros(250)
ADH_L = 0
if (pins.digitalReadPin(DigitalPin.P8) == 1) {
ADH_L = pins.analogReadPin(AnalogPin.P1)
pins.digitalWritePin(DigitalPin.P12, 0)
}
if ((ADH_L - ADL_L) > thresholdL) {//512) {
//basic.showIcon(IconNames.House)
return (true)
} else {
//basic.showIcon(IconNames.Cow)
return (false)
}
}
//輸出脈波
//% blockId="IRbolck" block="Out pulse & show-04"
//% blockGap=10 weight=55
//export function IRblock() {
// ADL_L = pins.analogReadPin(AnalogPin.P1)
// ADL_R = pins.analogReadPin(AnalogPin.P2)
// pins.digitalWritePin(DigitalPin.P12, 1)
// control.waitMicros(250)
// ADH_L = pins.analogReadPin(AnalogPin.P1)
// ADH_R = pins.analogReadPin(AnalogPin.P2)
// pins.digitalWritePin(DigitalPin.P12, 0)
// if ((ADH_L-ADL_L) > 512) {
//basic.showIcon(IconNames.House)
// led.plot(0, 0)
// led.unplot(0,4)
// } else {
//basic.showIcon(IconNames.Cow)
// led.plot(0, 4)
// led.unplot(0,0)
// }
// if ((ADH_R-ADL_R) > 512) {
//basic.showIcon(IconNames.House)
// led.plot(4, 0)
// led.unplot(4, 4)
// } else {
//basic.showIcon(IconNames.Cow)
// led.plot(4, 4)
// led.unplot(4,0)
// }
//return(true)
//}
/**
*Proxi läuft vorwaerts.
*/
//% blockId="vorwärts" block="Proxi läuft vorwärts"
//% blockGap=3 weight=35
export function vorwärts() {
if (pins.digitalReadPin(DigitalPin.P8) == 1) {
pins.digitalWritePin(DigitalPin.P13, 1)
pins.digitalWritePin(DigitalPin.P14, 0)
}
}
/**
*Proxi läuft rückwärts.
*/
//% blockId="rückwärts" block="Proxi läuft rückwärts"
//% blockGap=3 weight=34
export function rückwärts() {
if (Force != 0) {
pins.digitalWritePin(DigitalPin.P13, 0)
pins.digitalWritePin(DigitalPin.P14, 1)
Force = Force - 1;
}
if (pins.digitalReadPin(DigitalPin.P8) == 1) { Force = 10 }
}
/**
*Proxi bleibt stehen.
*/
//% blockId="stehenbleiben" block="Proxi bleibt stehen"
//% blockGap=10 weight=33
export function stehenbleiben() {
pins.digitalWritePin(DigitalPin.P13, 0)
pins.digitalWritePin(DigitalPin.P14, 0)
}
/**
*Proxi Rechtsdrehung.
*/
//% blockId="rechtsdrehung" block="Proxi Rechtsdrehung"
//% blockGap=3 weight=32
export function rechtsdrehung() {
pins.digitalWritePin(DigitalPin.P15, 0)
pins.digitalWritePin(DigitalPin.P16, 1)
Motor_L = false
Motor_R = true
}
/**
*Proxi Linksdrehung.
*/
//% blockId="linksdrehung" block="Proxi Linksdrehung"
//% blockGap=3 weight=31
export function linksdrehung() {
pins.digitalWritePin(DigitalPin.P15, 1)
pins.digitalWritePin(DigitalPin.P16, 0)
Motor_L = true
Motor_R = false
}
/**
*Proxi stoppt Drehung
*/
//% blockId="drehungstopp" block="Proxi stoppt Drehung"
//% blockGap=10 weight=30
export function drehungsstopp() {
if (Motor_L || Motor_R) {
if (Motor_R) {
pins.digitalWritePin(DigitalPin.P15, 1)
pins.digitalWritePin(DigitalPin.P16, 0)
} else {
pins.digitalWritePin(DigitalPin.P15, 0)
pins.digitalWritePin(DigitalPin.P16, 1)
}
basic.pause(50)
}
if (pins.digitalReadPin(DigitalPin.P8) == 1) {
pins.digitalWritePin(DigitalPin.P15, 0)
pins.digitalWritePin(DigitalPin.P16, 0)
Motor_L = false
Motor_R = false
}
}
/**
*Proxi stampft eine bestimmte Anzahl auf.
*@param time describe parameter here, eg:5
*/
//% blockId="stampfen" block="Proxi stampft %time mal auf"
//% time.min=1 time.max=100
//% blockGap=5 weight=25
//% advanced=true
export function stampfen(time: number): void {
for (let i = 0; i < time; i++) {
pins.digitalWritePin(DigitalPin.P13, 1) //向前
pins.digitalWritePin(DigitalPin.P14, 0)
basic.pause(150)
pins.digitalWritePin(DigitalPin.P13, 0) //向後
pins.digitalWritePin(DigitalPin.P14, 1)
basic.pause(150)
}
pins.digitalWritePin(DigitalPin.P13, 0) //停止
pins.digitalWritePin(DigitalPin.P14, 0)
}
/**
*Proxi schüttelt seinen Kopf eine bestimmte Anzahl.
*@param time describe parameter here, eg:5
*/
//% blockId="kopf_schuetteln" block="Proxi %time mal Kopfschütteln"
//% time.min=1 time.max=100
//% blockGap=5 weight=26
//% advanced=true
export function kopf_schuetteln(time: number): void {
for (let i = 0; i < time; i++) {
pins.digitalWritePin(DigitalPin.P15, 1) //左轉
pins.digitalWritePin(DigitalPin.P16, 0)
basic.pause(250)
pins.digitalWritePin(DigitalPin.P15, 0) //右轉
pins.digitalWritePin(DigitalPin.P16, 1)
basic.pause(250)
}
pins.digitalWritePin(DigitalPin.P15, 0) //停止行走
pins.digitalWritePin(DigitalPin.P16, 0)
}
/**
*Proxi tanzt eine bestimmte Anzahl.
*@param time describe parameter here, eg:5
*/
//% blockId="tanz" block="Proxi tanzt %time mal"
//% time.min=1 time.max=100
//% blockGap=5 weight=24
//% advanced=true
export function tanz(time: number): void {
for (let i = 0; i < time; i++) {
pins.digitalWritePin(DigitalPin.P13, 0) //向後
pins.digitalWritePin(DigitalPin.P14, 1)
pins.digitalWritePin(DigitalPin.P15, 0) //右轉
pins.digitalWritePin(DigitalPin.P16, 1)
basic.pause(250)
pins.digitalWritePin(DigitalPin.P13, 1) //向前
pins.digitalWritePin(DigitalPin.P14, 0)
pins.digitalWritePin(DigitalPin.P15, 1) //左轉
pins.digitalWritePin(DigitalPin.P16, 0)
basic.pause(250)
}
pins.digitalWritePin(DigitalPin.P13, 0)
pins.digitalWritePin(DigitalPin.P14, 0)
pins.digitalWritePin(DigitalPin.P15, 0)
pins.digitalWritePin(DigitalPin.P16, 0)
}
/**
*Proxi zeigt Stimmung auf Gesicht an (nur APP).
*@param RX_Data describe parameter here
*/
//% blockId="BLE_DOT" block="Proxi zeigt Stimmung auf Gesicht an (nur APP) %RX_Data"
//% blockGap=5 weight=23
//% advanced=true
export function zeige_Gesicht(RX_Data: string): void {
basic.clearScreen()
for (let PY = 0; PY <= 4; PY++) {
let PLOT_DATA: number = parseInt(RX_Data.substr(PY * 2 + 1, 2))
for (let PX = 0; PX <= 4; PX++) {
if (PLOT_DATA % 2 == 1) {
led.plot(PX, PY)
PLOT_DATA = PLOT_DATA - 1
}
PLOT_DATA = PLOT_DATA / 2
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "proxi-ai",
"description": "BLE-Kommando-Interface fuer den KOSMOS-Proxi-Roboter",
"dependencies": {
"core": "*",
"bluetooth": "*",
"microphone": "*"
},
"files": [
"version.ts",
"main.ts",
"proxi.ts"
],
"preferredEditor": "tsprj",
"yotta": {
"config": {
"microbit-dal": {
"bluetooth": {
"open": 1,
"whitelist": 0,
"event_service": 0,
"dfu_service": 0,
"device_info_service": 0,
"eddystone_url": 0,
"eddystone_uid": 0,
"partial_flashing": 0
}
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES5",
"noImplicitAny": true,
"outDir": "built",
"rootDir": "."
},
"exclude": ["pxt_modules/**/*test.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
// Erzeugt von build.sh — nicht von Hand ändern.
const FW_BUILD = "0fae7b1"
+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())