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:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
[](LICENSE)
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
</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.
|
||||
-->
|
||||
@@ -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.
|
||||
@@ -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 | 800–1500 ms |
|
||||
| durchs Zimmer | 4000–6000 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,5–1 s | `BADING` `JUMPUP` `JUMPDOWN` `POWERUP` `POWERDOWN` |
|
||||
| ~2,5–3,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 0–1023, **höher heißt näher**.
|
||||
Am Gerät gemessen: freie Sicht 5–15, Hand in etwa 10 cm 200–600, 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
|
||||
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+412
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
bleak>=0.21
|
||||
@@ -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 (P0–P3 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.
|
||||
@@ -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.
|
||||
@@ -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 0–31 |
|
||||
| `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 (50–5000 Hz, 10–5000 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,5–1 s | `BADING` `JUMPUP` `JUMPDOWN` `POWERUP` `POWERDOWN` |
|
||||
| ~2,5–3,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 | 5–15 |
|
||||
| Hand in ~20 cm | 30–90 |
|
||||
| Hand in ~10 cm | 200–600 |
|
||||
| Hand direkt davor | bis ~980 |
|
||||
|
||||
Als Hindernis-Schwelle taugen die 400–600, 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
@@ -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 |
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
pxt_modules/
|
||||
built/
|
||||
package.json.npm
|
||||
package-lock.json
|
||||
Executable
+53
@@ -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."
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES5",
|
||||
"noImplicitAny": true,
|
||||
"outDir": "built",
|
||||
"rootDir": "."
|
||||
},
|
||||
"exclude": ["pxt_modules/**/*test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Erzeugt von build.sh — nicht von Hand ändern.
|
||||
const FW_BUILD = "0fae7b1"
|
||||
Executable
+127
@@ -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())
|
||||
Executable
+164
@@ -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())
|
||||
Reference in New Issue
Block a user