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.
719 lines
25 KiB
TypeScript
719 lines
25 KiB
TypeScript
/**
|
|
* 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)
|