diff --git a/lib/mquickjs_headers/mqjs_stdlib.build.sha256 b/lib/mquickjs_headers/mqjs_stdlib.build.sha256 index e65aa38a6e..a829e7abb7 100644 --- a/lib/mquickjs_headers/mqjs_stdlib.build.sha256 +++ b/lib/mquickjs_headers/mqjs_stdlib.build.sha256 @@ -1 +1 @@ -a31c20af84d4aa73d94ac2ea57bcf6ac743bff534435e256d2acb5068abd1d53 \ No newline at end of file +a31c20af84d4aa73d94ac2ea57bcf6ac743bff534435e256d2acb5068abd1d53 diff --git a/sd_files/interpreter/crafty.js b/sd_files/interpreter/crafty.js new file mode 100644 index 0000000000..489428a229 --- /dev/null +++ b/sd_files/interpreter/crafty.js @@ -0,0 +1,258 @@ +// CURSEDSOUND // CRAFTY remote for Bruce (M5StickC Plus2) +// Controls a Storz & Bickel CRAFTY / CRAFTY+ over BLE. +// Needs the ble.connect/read/write GATT functions (Bruce fork "ble-gatt-client"). +// +// Buttons: M5 (front) = select TEMP / BOOST / HEAT +// B (right) = + (on HEAT: on) +// PWR (left) = - (on HEAT: off) +// B + PWR together = exit + +var display = require('display'); +var keyboard = require('keyboard'); +var ble = require('ble'); + +var BASE = '-4c45-4b43-4942-265a524f5453'; +var SVC1 = '00000001' + BASE; +var SVC2 = '00000002' + BASE; +var SVC3 = '00000003' + BASE; +var CUR_TEMP = '00000011' + BASE; +var TARGET = '00000021' + BASE; +var BOOST = '00000031' + BASE; +var BATTERY = '00000041' + BASE; +var HEAT_ON = '00000081' + BASE; +var HEAT_OFF = '00000091' + BASE; +var SERIAL = '00000052' + BASE; +var PRJ = '00000093' + BASE; +var PRJ2 = '000001c3' + BASE; + +var BLACK = display.color(4, 4, 8); +var PANEL = display.color(18, 18, 24); +var ACID = display.color(200, 255, 0); +var HOT = display.color(255, 46, 136); +var WHITE = display.color(255, 255, 255); +var DIM = display.color(110, 110, 120); + +var W = display.width(); +var H = display.height(); +var spr = display.createSprite(); + +function rd16(svc, chr) { + var v = ble.read(svc, chr); + if (!v || v.length < 2) return -1; + return v[0] | (v[1] << 8); +} + +function wr16(svc, chr, val) { + return ble.write(svc, chr, new Uint8Array([val & 255, (val >> 8) & 255])); +} + +function tenths(v) { + return Math.round(v / 10); +} + +// ---------------------------------------------------------------- drawing +function glitchTitle(x, y, size) { + spr.setTextSize(size); + spr.setTextAlign('left', 'top'); + var g = Math.floor(now() / 120) % 19 === 0; + var dx = g ? 3 : 1; + spr.setTextColor(HOT); + spr.drawText('CURSEDSOUND', x - dx, y + (g ? 1 : 0)); + spr.setTextColor(ACID); + spr.drawText('CURSEDSOUND', x + dx, y); + spr.setTextColor(WHITE); + spr.drawText('CURSEDSOUND', x, y); +} + +function message(line1, line2, color) { + spr.fill(BLACK); + glitchTitle(8, 8, 2); + spr.setTextAlign('center', 'middle'); + spr.setTextSize(2); + spr.setTextColor(color || WHITE); + spr.drawText(line1, W / 2, H / 2 + 6); + if (line2) { + spr.setTextSize(1); + spr.setTextColor(DIM); + spr.drawText(line2, W / 2, H / 2 + 30); + } + spr.pushSprite(); +} + +function box(x, y, w, h, label, value, selected, accent) { + if (selected) { + spr.drawFillRect(x, y, w, h, accent); + } else { + spr.drawFillRect(x, y, w, h, PANEL); + spr.drawRect(x, y, w, h, accent); + } + spr.setTextAlign('center', 'top'); + spr.setTextSize(1); + spr.setTextColor(selected ? BLACK : DIM); + spr.drawText(label, x + w / 2, y + 4); + spr.setTextSize(2); + spr.setTextColor(selected ? BLACK : WHITE); + spr.drawText(value, x + w / 2, y + 16); +} + +function draw(s) { + spr.fill(BLACK); + + // header + glitchTitle(6, 4, 1); + spr.setTextAlign('right', 'top'); + spr.setTextSize(1); + spr.setTextColor(s.battery < 20 ? HOT : ACID); + spr.drawText('BAT ' + s.battery + '%', W - 6, 4); + + // big current temperature + var hot = s.heater; + var big = s.cur > 0 ? String(s.cur) : '---'; + spr.setTextAlign('center', 'middle'); + spr.setTextSize(5); + spr.setTextColor(s.ready ? ACID : WHITE); + spr.drawText(big, W / 2 - 8, 42); + spr.drawCircle(W / 2 - 8 + big.length * 15 + 7, 27, 4, s.ready ? ACID : WHITE); + + // status + mode + spr.setTextSize(1); + spr.setTextColor(s.ready ? ACID : (hot ? HOT : DIM)); + var status = s.ready ? 'READY' : (hot ? 'HEATING' : 'IDLE'); + if (s.mode !== 'NORMAL' && hot) status = status + ' / ' + s.mode; + spr.drawText(status, W / 2, 68); + + // progress bar current -> target + var bx = 10, by = 78, bw = W - 20; + spr.drawFillRect(bx, by, bw, 4, PANEL); + var frac = Math.max(0, Math.min(1, (s.cur - 40) / (s.target + s.boost - 40))); + if (s.cur > 0) spr.drawFillRect(bx, by, Math.round(bw * frac), 4, hot ? HOT : ACID); + + // three controls + var bwid = Math.floor((W - 24) / 3); + var y = 88; + box(6, y, bwid, 40, 'TEMP', s.target + 'C', s.sel === 0, ACID); + box(12 + bwid, y, bwid, 40, 'BOOST', '+' + s.boost, s.sel === 1, HOT); + box(18 + bwid * 2, y, bwid, 40, 'HEAT', hot ? 'ON' : 'OFF', s.sel === 2, hot ? HOT : ACID); + + spr.pushSprite(); +} + +// ---------------------------------------------------------------- device +function connect() { + while (true) { + message('SEARCHING...', 'turn CRAFTY on, close other apps', ACID); + if (ble.connect('STORZ&BICKEL', 6)) return true; + message('NOT FOUND', 'M5: retry B+PWR: exit', HOT); + while (true) { + if (keyboard.getEscPress()) return false; + if (keyboard.getSelPress()) break; + delay(40); + } + } +} + +function readState(s, full) { + var v; + v = rd16(SVC1, CUR_TEMP); + if (v >= 0) s.cur = tenths(v); + v = rd16(SVC1, BATTERY); + if (v >= 0) s.battery = v; + v = rd16(SVC3, PRJ); + if (v >= 0) { + s.heater = (v & 0x10) !== 0; + s.mode = (v & 0x40) ? 'SUPERBOOST' : ((v & 0x20) ? 'BOOST' : 'NORMAL'); + } + v = rd16(SVC3, PRJ2); + if (v >= 0) s.ready = (v & 0x04) !== 0 && s.heater; + if (full) { + v = rd16(SVC1, TARGET); + if (v >= 0) { + var t = tenths(v); + if (t > 210) t = Math.round((t - 32) / 1.8); + s.target = t; + } + v = rd16(SVC1, BOOST); + if (v >= 0) s.boost = tenths(v); + } +} + +function clampBoost(s) { + if (s.boost < 0) s.boost = 0; + if (s.boost > 99) s.boost = 99; + if (s.target + s.boost > 210) s.boost = 210 - s.target; +} + +function main() { + if (!connect()) return; + + var s = { + cur: 0, target: 180, boost: 10, battery: 0, + heater: false, ready: false, mode: 'NORMAL', sel: 0 + }; + readState(s, true); + + var lastPoll = 0; + var lastFull = now(); + var pendingAt = 0; // when a temp/boost change should be sent + var dirty = true; + + while (true) { + var t = now(); + + // ---- input + if (keyboard.getEscPress()) break; + if (keyboard.getSelPress()) { s.sel = (s.sel + 1) % 3; dirty = true; } + var up = keyboard.getNextPress(); + var down = keyboard.getPrevPress(); + if (up || down) { + var d = up ? 1 : -1; + if (s.sel === 0) { + s.target = Math.max(40, Math.min(210, s.target + d)); + clampBoost(s); + pendingAt = t + 700; + } else if (s.sel === 1) { + s.boost = s.boost + d; + clampBoost(s); + pendingAt = t + 700; + } else { + ble.write(SVC1, up ? HEAT_ON : HEAT_OFF, new Uint8Array([0, 0])); + s.heater = up; + } + dirty = true; + } + + // ---- send pending temperature / boost once buttons are released + if (pendingAt && t >= pendingAt) { + wr16(SVC1, TARGET, s.target * 10); + wr16(SVC1, BOOST, s.boost * 10); + pendingAt = 0; + lastFull = t; + } + + // ---- poll device + if (t - lastPoll > 1000 && !pendingAt) { + if (!ble.isConnected()) { + message('LINK LOST', 'reconnecting...', HOT); + if (!connect()) break; + readState(s, true); + } else { + var full = t - lastFull > 5000; + readState(s, full); + if (full) lastFull = t; + } + lastPoll = now(); + dirty = true; + } + + if (dirty) { + draw(s); + dirty = false; + } + delay(30); + } +} + +main(); +ble.disconnect(); +spr.deleteSprite(); +display.fill(0); diff --git a/src/modules/bjs_interpreter/ble_js.cpp b/src/modules/bjs_interpreter/ble_js.cpp index ee9d38a8f1..f81c1d384e 100644 --- a/src/modules/bjs_interpreter/ble_js.cpp +++ b/src/modules/bjs_interpreter/ble_js.cpp @@ -6,9 +6,7 @@ JSValue native_bleScan(JSContext *ctx, JSValue *this_val, int argc, JSValue *arg // usage: ble.scan(timeout_seconds) // returns: array of objects [{name, address, rssi}, ...] int timeout = 5; // default 5 seconds - if (argc > 0 && JS_IsNumber(ctx, argv[0])) { - JS_ToInt32(ctx, &timeout, argv[0]); - } + if (argc > 0 && JS_IsNumber(ctx, argv[0])) { JS_ToInt32(ctx, &timeout, argv[0]); } if (timeout < 1) timeout = 1; if (timeout > 30) timeout = 30; @@ -51,9 +49,7 @@ JSValue native_bleAdvertise(JSContext *ctx, JSValue *this_val, int argc, JSValue // usage: ble.advertise(name_string) const char *name = "Bruce-App"; JSCStringBuf name_buf; - if (argc > 0 && JS_IsString(ctx, argv[0])) { - name = JS_ToCString(ctx, argv[0], &name_buf); - } + if (argc > 0 && JS_IsString(ctx, argv[0])) { name = JS_ToCString(ctx, argv[0], &name_buf); } BLEDevice::init(name); NimBLEServer *pServer = BLEDevice::createServer(); @@ -68,4 +64,136 @@ JSValue native_bleStopAdvertise(JSContext *ctx, JSValue *this_val, int argc, JSV return JS_UNDEFINED; } +// --------------------------------------------------------------------------- +// GATT client: connect to a peripheral and read/write its characteristics. +// +// ble.connect(nameOrAddress, timeoutSec?) -> bool +// Scans and connects to the first device whose name starts with +// nameOrAddress (case-insensitive) or whose MAC equals it. +// ble.isConnected() -> bool +// ble.read(serviceUUID, charUUID) -> Uint8Array | null +// ble.write(serviceUUID, charUUID, Uint8Array|string, withResponse?) -> bool +// ble.disconnect() +// --------------------------------------------------------------------------- +#if __has_include() +static NimBLEClient *jsBleClient = nullptr; + +static bool jsBleIsConnected() { return jsBleClient != nullptr && jsBleClient->isConnected(); } + +JSValue native_bleConnect(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + if (argc < 1 || !JS_IsString(ctx, argv[0])) { + return JS_ThrowTypeError(ctx, "ble.connect: arg0 must be a device name prefix or MAC address"); + } + JSCStringBuf target_buf; + const char *target_c = JS_ToCString(ctx, argv[0], &target_buf); + String target = target_c ? target_c : ""; + int timeout = 5; + if (argc > 1 && JS_IsNumber(ctx, argv[1])) JS_ToInt32(ctx, &timeout, argv[1]); + if (timeout < 1) timeout = 1; + if (timeout > 30) timeout = 30; + + if (jsBleIsConnected()) jsBleClient->disconnect(); + + BLEDevice::init(""); + NimBLEScan *pScan = NimBLEDevice::getScan(); + pScan->setActiveScan(true); + pScan->setInterval(100); + pScan->setWindow(99); + NimBLEScanResults results = pScan->getResults(timeout * 1000, false); + + const NimBLEAdvertisedDevice *found = nullptr; + for (int i = 0; i < results.getCount(); i++) { + const NimBLEAdvertisedDevice *dev = results.getDevice(i); + String name = dev->getName().c_str(); + String addr = dev->getAddress().toString().c_str(); + if ((name.length() >= target.length() && + strncasecmp(name.c_str(), target.c_str(), target.length()) == 0) || + addr.equalsIgnoreCase(target)) { + found = dev; + break; + } + } + + bool ok = false; + if (found != nullptr) { + if (jsBleClient == nullptr) jsBleClient = NimBLEDevice::createClient(); + jsBleClient->setConnectTimeout(timeout * 1000); + ok = jsBleClient->connect(found); + } + pScan->clearResults(); + return JS_NewBool(ok); +} + +JSValue native_bleIsConnected(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + return JS_NewBool(jsBleIsConnected()); +} + +static NimBLERemoteCharacteristic *jsBleGetChr(JSContext *ctx, int argc, JSValue *argv) { + if (!jsBleIsConnected() || argc < 2 || !JS_IsString(ctx, argv[0]) || !JS_IsString(ctx, argv[1])) { + return nullptr; + } + JSCStringBuf svc_buf, chr_buf; + const char *svc_c = JS_ToCString(ctx, argv[0], &svc_buf); + std::string svc_uuid = svc_c ? svc_c : ""; + const char *chr_c = JS_ToCString(ctx, argv[1], &chr_buf); + std::string chr_uuid = chr_c ? chr_c : ""; + + NimBLERemoteService *svc = jsBleClient->getService(NimBLEUUID(svc_uuid)); + if (svc == nullptr) return nullptr; + return svc->getCharacteristic(NimBLEUUID(chr_uuid)); +} + +JSValue native_bleRead(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + NimBLERemoteCharacteristic *chr = jsBleGetChr(ctx, argc, argv); + if (chr == nullptr || !chr->canRead()) return JS_NULL; + NimBLEAttValue val = chr->readValue(); + return JS_NewUint8ArrayCopy(ctx, val.data(), val.size()); +} + +JSValue native_bleWrite(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + NimBLERemoteCharacteristic *chr = jsBleGetChr(ctx, argc, argv); + if (chr == nullptr || argc < 3) return JS_NewBool(false); + + const uint8_t *buf = nullptr; + size_t len = 0; + JSCStringBuf str_buf; + if (JS_IsString(ctx, argv[2])) { + buf = (const uint8_t *)JS_ToCStringLen(ctx, &len, argv[2], &str_buf); + } else if (JS_IsTypedArray(ctx, argv[2])) { + buf = (const uint8_t *)JS_GetTypedArrayBuffer(ctx, &len, argv[2]); + } else { + return JS_ThrowTypeError(ctx, "ble.write: arg2 must be Uint8Array or string"); + } + if (buf == nullptr) return JS_NewBool(false); + + bool response = chr->canWrite(); + if (argc > 3) response = JS_ToBool(ctx, argv[3]); + return JS_NewBool(chr->writeValue(buf, len, response)); +} + +JSValue native_bleDisconnect(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + if (jsBleIsConnected()) jsBleClient->disconnect(); + return JS_UNDEFINED; +} +#else +static JSValue jsBleGattUnsupported(JSContext *ctx) { + return JS_ThrowInternalError(ctx, "ble GATT client requires NimBLE-Arduino 2.x"); +} +JSValue native_bleConnect(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + return jsBleGattUnsupported(ctx); +} +JSValue native_bleIsConnected(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + return JS_NewBool(false); +} +JSValue native_bleRead(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + return jsBleGattUnsupported(ctx); +} +JSValue native_bleWrite(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + return jsBleGattUnsupported(ctx); +} +JSValue native_bleDisconnect(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv) { + return JS_UNDEFINED; +} +#endif + #endif diff --git a/src/modules/bjs_interpreter/ble_js.h b/src/modules/bjs_interpreter/ble_js.h index 97fe89eca6..f1d01e7ebb 100644 --- a/src/modules/bjs_interpreter/ble_js.h +++ b/src/modules/bjs_interpreter/ble_js.h @@ -8,6 +8,11 @@ extern "C" { JSValue native_bleScan(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv); JSValue native_bleAdvertise(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv); JSValue native_bleStopAdvertise(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv); +JSValue native_bleConnect(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv); +JSValue native_bleIsConnected(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv); +JSValue native_bleRead(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv); +JSValue native_bleWrite(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv); +JSValue native_bleDisconnect(JSContext *ctx, JSValue *this_val, int argc, JSValue *argv); } #endif diff --git a/src/modules/bjs_interpreter/mqjs_stdlib.c b/src/modules/bjs_interpreter/mqjs_stdlib.c index 1fa4cc78d8..03d37565bf 100644 --- a/src/modules/bjs_interpreter/mqjs_stdlib.c +++ b/src/modules/bjs_interpreter/mqjs_stdlib.c @@ -591,6 +591,11 @@ static const JSPropDef js_ble[] = { JS_CFUNC_DEF("scan", 1, native_bleScan), JS_CFUNC_DEF("advertise", 1, native_bleAdvertise), JS_CFUNC_DEF("stopAdvertise", 0, native_bleStopAdvertise), + JS_CFUNC_DEF("connect", 2, native_bleConnect), + JS_CFUNC_DEF("isConnected", 0, native_bleIsConnected), + JS_CFUNC_DEF("read", 2, native_bleRead), + JS_CFUNC_DEF("write", 4, native_bleWrite), + JS_CFUNC_DEF("disconnect", 0, native_bleDisconnect), JS_PROP_END, }; diff --git a/src/modules/bjs_interpreter/mqjs_stdlib.h b/src/modules/bjs_interpreter/mqjs_stdlib.h index 9b4e314b49..cd0ab5c048 100644 --- a/src/modules/bjs_interpreter/mqjs_stdlib.h +++ b/src/modules/bjs_interpreter/mqjs_stdlib.h @@ -4352,7 +4352,6 @@ static const uint32_t __attribute((aligned(64))) js_stdlib_table[] = { 5 << 1, /* n_props */ 3 << 1, /* hash_mask */ 18 << 1, - 0 << 1, 9 << 1, 12 << 1, JS_ROM_VALUE(1218) /* begin */, @@ -5853,6 +5852,21 @@ static const JSCFunctionDef js_c_function_table[] = { { { .generic = native_bleStopAdvertise }, JS_ROM_VALUE(1495) /* stopAdvertise */, JS_CFUNC_generic, 0, 0 }, + { { .generic = native_bleConnect }, + JS_ROM_VALUE(1451) /* connect */, + JS_CFUNC_generic, 2, 0 }, + { { .generic = native_bleIsConnected }, + JS_ROM_VALUE(1483) /* isConnected */, + JS_CFUNC_generic, 0, 0 }, + { { .generic = native_bleRead }, + JS_ROM_VALUE(1227) /* read */, + JS_CFUNC_generic, 2, 0 }, + { { .generic = native_bleWrite }, + JS_ROM_VALUE(1224) /* write */, + JS_CFUNC_generic, 4, 0 }, + { { .generic = native_bleDisconnect }, + JS_ROM_VALUE(1454) /* disconnect */, + JS_CFUNC_generic, 0, 0 }, { { .generic = native_nrf24Begin }, JS_ROM_VALUE(1218) /* begin */, JS_CFUNC_generic, 1, 0 },