Proxi AI — Spielzeug-Roboter mit natürlicher Sprache steuern

Firmware, Bluetooth-Brücke und Agent-Skill für den KOSMOS-Proxi: einmal
flashen, danach hört der Roboter über Bluetooth LE auf Textbefehle. Was die
Befehle schickt, ist ihm egal — ein Skript, ein curl, oder ein KI-Agent, der
aus "mach mal was Lustiges" eine Choreografie baut.

  firmware/   MakeCode-Projekt (TypeScript) und fertige .hex für V1 und V2
  bridge/     BLE-Brücke und HTTP-API, einzige Abhängigkeit ist bleak
  agent/      Skill-Definition: Alltagssprache -> Befehle
  tools/      Quelltext aus .hex extrahieren, Musik-Tabellen prüfen
  docs/       Protokoll, Einrichtung, Hardware, Lizenzen

Zwei Dinge, die sonst nirgends stehen und in docs/HARDWARE.md dokumentiert
sind: die Pin-Belegung des Roboters ist nicht öffentlich, sie wurde aus den
mitgelieferten .hex-Dateien rekonstruiert (tools/hex_source.py holt sie
heraus). Und der Ton braucht zwingend eine ganzzahlige Frequenz sowie Pin P0
— sonst kracht der Lautsprecher, der Aufruf hängt und die Bluetooth-
Verbindung stirbt.

Am echten Roboter durchgetestet: Verbindung, Sensoren, Display, 41 Sekunden
Musik am Stück, alle Bewegungsrichtungen, Musik und Fahren gleichzeitig,
Tanzfiguren, Not-Stopp mitten in der Bewegung, Autostart nach Neustart.

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