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