Interfacing JACDAC Services on BBC micro:bit w/ Node.js & TypeScript

Alan Wang
15 min readJun 25, 2022
Photo by Christopher Gower on Unsplash

JACDAC is officially here. It’s a plug-and-play hardware-software protocol for STEM purposes developed by Microsoft. It is not only designed for Microsoft’s MakeCode online blocky editors, but also provides (or planned to provide) many supports to various development tools, like in a pure Node.js environment or a React app.

JACDAC is perhaps sort of like Johnny-Five or other Firmata-related packages slash littleBits. Instead of uploading compiled code to the board, you link up JACDAC services from your computer. You can read and/or write data from and to sensors connected to a “brain” (a microcontroller loaded with special firmware). All devices can be added or removed at runtime. (People finally figure out that kids are the natural nemesis of jumper wires! lol)

The first commercial JACDAC product set is sold by KittenBot, which includes a micro:bit edge adapter

Can you preserve code on the micro:bit itself? I think so: looks like you can add MakeCode blocks to access services (either real or simulated) in the micro:bit. The code and the JACDAC functionality can be packed into a single .hex. Although we just can’t access the onboard services via this way.

Normally the microcontroller is only used as the channel for other services, but since the hardware may be a bit difficult to get right now, I’ll demonstrate how to use the micro:bit V2 itself with its onboard sensors.

The early version of JACDAC was using 3.5mm audio jacks

However, the JACDAC JDOM package is not designed for education purposes, since it’s too complicated than using MakeCode blocks or even writing in MicroPython. It’s for people to combining JACDAC with things and stuff. The possibility is…well, interesting.

Unfortunately — but also sort of expected — the documentation of JACDAC’s JDOM package is as sketchy as it is. As a technical writer this is aggrieving to me. So the goal of this article is to explore

  • How to flash JACDAC firmware which enables services on the micro:bit
  • To have a (better) basic understanding of JACDAC package
  • How to discover devices and services
  • Auto-mounting device (the micro:bit)
  • How to read and/or write

I’ll write a second post later, which is to build a React app based on the knowledge of this part.

Flashing the JACDAC Firmware

On the getting started page, you can find a “Jukebox” firmware which will install JACDAC on your micro:bit V2. However it doesn't enable any services from its onboard sensors.

I actually got this answer from Microsoft’s team on Twitter:

  • Click JavaScript on top of the editor to swith to JS mode. Delete everything and enter the following code as the first line:
jacdac.startServer()
  • Switch back to Blocks mode, drag “start…server" from the Servers group to add services you want. The micro:bit might not be able to run everything at once though.
Here we added four services: temperature, accelerometer, mic and LED screen.
  • Download the code and you’re done. (I assume that you knew how to pair your micro:bit V2 to the editor). You can close the editor.
  • Now go to Device Dashboard or Device Tester. Pair your micro:bit via WebUSB here and you should able to see the services are indeed running. Also close it when you are done, or you won’t be able to connect it from elsewhere.

Create a TypeScript Project

Firstly, of course, you need to install Node.js.

(If you are using Linux, you can install it like this in the terminal:)

sudo apt install curl
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get update
sudo apt install nodejs

I use Node.js 16. I also use Visual Studio Code as the editor. Technically you can do this without an editor, but it would be a lot more helpful to get type checking and auto-complete if you have the extension JavaScript and TypeScript Nightly installed as well.

Now

  • Create a project directory, and go to that dir in your terminal or console.
  • Initialize a Node.js project:
npm init -y
  • Install JACDAC, node-usb, TypeScript and tsc-watch:
npm install --save jacdac-ts usb typescript tsc-watch

tsc-watch is a tool that can monitor the code and auto compile/execute the new version when you save.

  • Initialize the project to use TyprScript:
tsc --init
  • Modify tsconfig.json as follows:
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",

"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
"strict": true,
"skipLibCheck": true
}
}

Most of the options are auto-created by the TypeScript compiler, but we updated the JavaScript version it compiles, and change the source code/compiled code sub-directories.

  • Modify package.json:
{
"name": "jacdac-node",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "npx tsc-watch --onsuccess \"node ./dist/index.js\""

},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jacdac-ts": "^1.24.40",
"tsc-watch": "^5.0.3",
"typescript": "^4.7.4",
"usb": "^2.4.3"
}
}

This is to add a short, convenient command so that we can run the code quickly.

  • Finally, create sub-directory src with a file index.ts in it. This is where our main code will be.

Discover the Device and its Services

In JACDAC, the JDOM tree is made of a hierarchy of objects:

  • JDBus is the USB connection, which may have multiple devices. The connection has to be establish first before doing anything.
  • JDDevice is a device which may have multiple services. (Right now I don’t know if a separate JACDAC sensor can count as a device itself or only the “brain” can be a device.)
  • JDService is a functionality provided from a sensor or controller, for example, light sensor or buzzer.
  • A service would have some registers (JDRegister) even events (JDEvent), both are the actual “methods” to interact with the service.

Here is the first version of /src/index.ts:

import { WebUSB } from "usb";
import { createUSBBus, createNodeUSBOptions } from "jacdac-ts";
import { CONNECTION_STATE, DEVICE_ANNOUNCE, ERROR, REPORT_RECEIVE, EVENT } from "jacdac-ts";
import type { JDDevice, JDRegister, JDEvent } from "jacdac-ts";
const options = createNodeUSBOptions(WebUSB);
const bus = createUSBBus(options);
// bus event when the connection state changes
bus.on(CONNECTION_STATE, () => {
console.log(`Bus ${bus.connected ? "connected" : "disconnected"}`);
});
// bus event when the device is connected and announced itself
bus.on(DEVICE_ANNOUNCE, (device: JDDevice) => {
main(device);
});
// bus event when error occurred
bus.on(ERROR, (context, exception) => {
console.error(`Bus error: ${context}`, exception);
});
bus.autoConnect = true; // enable auto-(re)connection
bus.connect(true); // enable background connection
const main = (device: JDDevice) => { // flash micro:bit LEDs for visual identification
device.identify();
// print out device name
console.log(`Device: ${device.name}`);
// print out services
device.services().forEach(service => {
console.log(`Service [${service.serviceIndex}]: ${service.name} (identifier: ${service.serviceClass})`);
});
}

The code has two parts:

  • The first part will connect micro:bit on USB and set up a few events.
  • The second part, our “main” function, is called in one of the event in the first part. It will list all the services (indexes, names and identifiers) it can find and print them in the console.

There are many events available in JACDAC, but they are mostly not documented so I’m kind of guessing. For now DEVICE_ANNOUNCE seems to be a good choice to find the device which represents our micro:bit. And when a device announced itself, all of its services will be accessible from there.

JACDAC is event-driven. Apparently things mostly only work in events. You can’t do it like using loops in Arduino.

REPORT_RECEIVE and EVENT are events that we will use later for registers and service events.

Now execute the code (using the script we’ve added in package.json):

npm start

tsc-watch will compile your TypeScript code into JavaScript, put index.js in /dist and execute it, then wait for futher changes you make to /src/index.ts.

Result:

jacdac: creating usb transport
usb: connect
usb: connecting
usb: requesting device...
usb: found micro:bit v2
usb: connect device ARM "BBC micro:bit CMSIS-DAP"
usb: claim interface 5
usb: all connected
Bus connected
Device: ZJ62
Service [0]: control (identifier: 0)
Service [1]: temperature (identifier: 337754823)
Service [2]: accelerometer (identifier: 521405449)
Service [3]: sound level (identifier: 346888797)
Service [4]: dot matrix (identifier: 286070091)

When the micro:bit is connected, the LED screen would flash a few times to tell you it is the one connected (the device.identify() method). You can see my device is called “ZJ62” and has five services: the first one is control and the rest are the ones we’ve enabled in the firmware.

You can try to unplugged the micro:bit and plug it again. JACDAC will auto-reconnect it and run the main function again.

Reading Temperature

Photo by noe fornells on Unsplash

Now we’ll take a look how to access the temperature sensor on micro:bit.

In our main() function, we can get a specific service with index (temperature sensor is at index 1):

const sensor = device.service(1);

However, you have to knew the index number to do so. Another way is to filter a specific type of sensor:

const main = (device: JDDevice) => {
...
// get the first filtered temperature service
const sensor = bus.services({ serviceClass: 337754823 })[0];
}

We ask the bus to return any services with the service class or identifier 337754823, which came with the temperature sensor we saw earlier in the previous discovery process.

In fact, if you go to the JACDAC device list, you can find the temperature device with the identifier 0x1421bac7 — the hex value of 337754823.

However, a more readable way is to use built-in constants from the JACDAC package:

import { SRV_TEMPERATURE, TemperatureReg } from "jacdac-ts";
...
const main = (device: JDDevice) => {
...
// get the first filtered temperature service
const sensor = bus.services({ serviceClass: SRV_TEMPERATURE })[0];
}

There are a lot of constant variables defined in JACDAC, representing different services. SRV_TEMPERATURE is the temperature sensor identifier (337754823) and TemperatureReg is an object which contains all its register ids — we will need those later. This time it’s also slightly different: the service filtering is done on bus instead of device.

Now we can print out the service’s specification:

console.log(sensor.specification);

Which will print out a lot of stuff:

{
name: 'Temperature',
status: 'rc',
shortId: 'temperature',
camelName: 'temperature',
shortName: 'temperature',
extends: [ '_base', '_sensor' ],
notes: { short: 'A thermometer measuring outside or inside environment.' },
classIdentifier: 337754823,
enums: { Variant: { name: 'Variant', storage: 1, members: [Object] } },
constants: {},
packets: [
{
kind: 'report',
name: 'command_not_implemented',
identifier: 3,
description: 'This report may be emitted by a server in response to a command (action or register operation)\n' +
'that it does not understand.\n' +
'The `service_command` and `packet_crc` fields are copied from the command packet that was unhandled.\n' +
"Note that it's possible to get an ACK, followed by such an error report.",
fields: [Array],
identifierName: 'command_not_implemented',
packFormat: 'u16 u16',
derived: '_base'
},
{
kind: 'const',
name: 'instance_name',
identifier: 265,
description: 'A friendly name that describes the role of this service instance in the device.\n' +
"It often corresponds to what's printed on the device:\n" +
'for example, `A` for button A, or `S0` for servo channel 0.\n' +
'Words like `left` should be avoided because of localization issues (unless they are printed on the device).',
fields: [Array],
optional: true,
identifierName: 'instance_name',
packFormat: 's',
derived: '_base'
},
{
kind: 'ro',
name: 'status_code',
identifier: 259,
description: 'Reports the current state or error status of the device. ``code`` is a standardized value from \n' +
'the Jacdac status/error codes. ``vendor_code`` is any vendor specific error code describing the device\n' +
'state. This report is typically not queried, when a device has an error, it will typically\n' +
'add this report in frame along with the announce packet. If a service implements this register,\n' +
'it should also support the ``status_code_changed`` event defined below.',
fields: [Array],
optional: true,
identifierName: 'status_code',
packFormat: 'u16 u16',
derived: '_base'
},
...
{
kind: 'ro',
name: 'temperature',
identifier: 257,
description: 'The temperature.',
fields: [Array],
volatile: true,
identifierName: 'reading',
preferredInterval: 1000,
packFormat: 'i22.10'
},
{
kind: 'const',
name: 'min_temperature',
identifier: 260,
description: 'Lowest temperature that can be reported.',
fields: [Array],
identifierName: 'min_reading',
packFormat: 'i22.10'
},
{
kind: 'const',
name: 'max_temperature',
identifier: 261,
description: 'Highest temperature that can be reported.',
fields: [Array],
identifierName: 'max_reading',
packFormat: 'i22.10'
},
{
kind: 'ro',
name: 'temperature_error',
identifier: 262,
description: 'The real temperature is between `temperature - temperature_error` and `temperature + temperature_error`.',
fields: [Array],
volatile: true,
optional: true,
identifierName: 'reading_error',
packFormat: 'u22.10'
},
{
kind: 'const',
name: 'variant',
identifier: 263,
description: 'Specifies the type of thermometer.',
fields: [Array],
optional: true,
identifierName: 'variant',
packFormat: 'u8'
}
],
tags: [ 'C', '8bit' ],
group: 'Environment'
}

This is essentially the API document of the service — what it is, what registers and events it has, how often it updates, what kind of parameters and return values a register uses, etc. It’s not easy to read, but probably the only thing you can get for now.

The ro, rw and const means the register is either read only, read/write or a constant value.

So if we want to read the temperature from register “temperature”:

// get register
const register = sensor.register(TemperatureReg.Temperature);
// register event when it receive new values
register.on(REPORT_RECEIVE, (reg: JDRegister) => {
const t = reg.intValue;
console.log(`Temp: ${t / 1000} *C`);
});

If you use VS Code, you can see TemperatureReg.Temperature is number 257, which is exactly the identifier we’ve seen from the specification.

This register returns only one value (actually it’s an array with only one element). We read it as a int and it would be Celsius times 1000.

Temp: 24.576 *C
Temp: 24.576 *C
Temp: 23.552 *C
Temp: 24.576 *C
Temp: 24.576 *C
Temp: 23.552 *C
Temp: 24.576 *C

There is also a REPORT_UPDATE event, which only triggers when the new value is different from the previous one.

Reading 3 Axis Acceleration and Gesture Event

Photo by Jr Korpa on Unsplash

The next example is a bit more complicated.

import { SRV_ACCELEROMETER, AccelerometerReg, AccelerometerEvent } from "jacdac-ts";
...
const main = (device: JDDevice) => { const sensor = bus.services({ serviceClass: SRV_ACCELEROMETER })[0];
const register = sensor.register(AccelerometerReg.Forces);
register.on(REPORT_RECEIVE, (reg: JDRegister) => {
const { x, y, z } = reg.objectValue;
console.log(`X=${x} | Y=${y} | Z=${z}`);
// or:
// const [x, y, z] = reg.unpackedValue;
// console.log(`X=${x} | Y=${y} | Z=${z}`);
});
}

The accelerometer can be identified with SRV_ACCELEROMETER and the register “forces” (AccelerometerReg.Forces) returns the acceleration of the 3 axis (value 1 means 1G). This is the register’s specification:

{
kind: 'ro',
name: 'forces',
identifier: 257,
description: 'Indicates the current forces acting on accelerometer.',
fields: [Array],
volatile: true,
identifierName: 'reading',
packFormat: 'i12.20 i12.20 i12.20'
},

There are some ways to unpack the three values, either via an object or an array. However, you’ll have to know the register returns 3 values in advance.

Result:

X=0.04399967193603515 | Y=0.03599929809570312 | Z=-1.003999710083007
X=0.0519990921020507 | Y=0.03999996185302734 | Z=-1
X=0.0519990921020507 | Y=0.03999996185302734 | Z=-1.007999420166015
X=0.0519990921020507 | Y=0.03999996185302734 | Z=-1.007999420166015
X=0.0519990921020507 | Y=0.03599929809570312 | Z=-1.011999130249023
X=0.0479993820190429 | Y=0.03199958801269531 | Z=-1.015999794006347
X=0.0519990921020507 | Y=0.03599929809570312 | Z=-1.003999710083007
X=0.0479993820190429 | Y=0.03599929809570312 | Z=-1.011999130249023
X=0.055999755859375 | Y=0.039999961853027344 | Z=-1.007999420166015
X=0.0519990921020507 | Y=0.03599929809570312 | Z=-1.011999130249023

Unpacking Unknown Number of Values

As an experiment — I wrote the following version, which is capable to unpack any number of values and field names then print them into a single string:

const fields = register.fields;
const values = register.unpackedValue;
let output: string[] = [];
for (let i = 0; i < fields.length; i++) {
output.push(`${fields[i].name}: ${values[i]}`);
}
console.log(output.join(" | "));

Shake Event

Now, the accelerometer also has a series of gesture events, which can be triggered when the micro:bit is moved in a certain way. The “shake” event is defined like this:

{
kind: 'event',
name: 'shake',
identifier: 139,
description: 'Emitted when forces change violently a few times.',
fields: []
},

The event can be identified with number 139 or AccelerometerEvent.Shake:

const event = sensor.event(AccelerometerEvent.Shake);event.on(EVENT, (event: JDEvent) => {
console.log(`=====${event.name}=====`);
});

We don’t to much here, only print out a line when the shake event happens. But after saving the code you’ll see the event works like the MakeCode event.

Enabling and Reading the Mic

Photo by BRUNO EMMANUELLE on Unsplash

The mic seems to be a simple, which only returns a sound level value. But there is a twist: you need to turn the mic on first.

To do that, we’ll have to write a boolean value to the register SoundLevelReg.Enabled. After that, we will be able to read the mic sound level from the register SoundLevelReg.SoundLevel.

import { SRV_SOUND_LEVEL, SoundLevelReg } from "jacdac-ts";
...
async function main(device: JDDevice) { const mic = bus.services({ serviceClass: SRV_SOUND_LEVEL })[0]; const micEnable = mic.register(SoundLevelReg.Enabled); // write "true" to the register to enable the mic
await micEnable.sendSetBoolAsync(true);
const micLevel = mic.register(SoundLevelReg.SoundLevel); // read from the mic
micLevel.on(REPORT_RECEIVE, async (reg: JDRegister) => {
const soundLevel = reg.uintValue;
console.log(`Sound level: ${soundLevel}`);
});

}

Again, there are several methods you can write values to registers, but they are either a string, a boolean or an Uint8Array. In the VS Code you can quickly explore them with auto-complete.

For example, I can change the line to await micEnable.sendSetAsync(new Uint8Array([1])) and the effect is the same.

We also rewrite the main() function with async since sendSetBoolAsync() returns a Promise. However from my experiments there are practically no difference with or without await.

Sound Loudness Indicator

We still have one service left, which is the LED screen or dot matrix. This is a pure input device (the light sensor is counted as a separate service). We will use it to create a simple loudness indicator, a demonstration of combining two JACDAC services — one input and one output.

import { SRV_SOUND_LEVEL, SoundLevelReg } from "jacdac-ts";
import { SRV_DOT_MATRIX, DotMatrixReg } from "jacdac-ts";
...
async function main(device: JDDevice) { const dotMatrix = bus.services({ serviceClass: SRV_DOT_MATRIX })[0]; const dotMatrixBrightness = dotMatrix.register(DotMatrixReg.Brightness);
await dotMatrixBrightness.sendSetAsync(new Uint8Array([255]));

const dotMatrixDots = dotMatrix.register(DotMatrixReg.Dots);
const mic = bus.services({ serviceClass: SRV_SOUND_LEVEL })[0]; const micEnable = mic.register(SoundLevelReg.Enabled);
await micEnable.sendSetBoolAsync(true);
const micLevel = mic.register(SoundLevelReg.SoundLevel); let event_lock = false;
micLevel.on(REPORT_RECEIVE, async (register: JDRegister) => {
const soundLevel = register.uintValue;
console.log(`Sound level: ${soundLevel}`);
if (soundLevel >= 12800) {
await dotMatrixDots.sendSetAsync(new Uint8Array(
[
0b00000,
0b10000,
0b01000,
0b00101,
0b00010,
]
));
event_lock = true;
await sleep(1000);
event_lock = false;
} else {
if (event_lock) return;
await dotMatrixDots.sendSetAsync(new Uint8Array(
[
0b00000,
0b00000,
0b00000,
0b00000,
0b00000,
]
));
}
});
}async function sleep(ms: number = 0) {
return new Promise(r => setTimeout(r, ms));
}

This is a simple application: whenever the sound level is over the threshold (12800; change this if you like. the max sound level value seems to be ~32767), the LED screen will display a check mark and wait a second. Since the event is asynchronous, other events will be fired right after this, I use an variable to “lock” other events’ actions while waiting the countdown on a sleep() function.

For the dot matrix, we use two registers: DotMatrixReg.Brightness controls the LED brightness level (0–255) and DotMatrixReg.Dots allows us to write a Uint8Array with five numbers, each one representing a row (the specification says column, but it is not the case on micro:bit). I wrote it as binary numbers so you can see the relationship to the LED matrix better.

Also: the MSB (most significant bit) of each row is actually the rightmost LED. So essentially you have to horizontally flipped the dots in the Uint8Array so it would look right on the LED screen.

Github Example

For reference, I’ve also uploaded an example on Github. It is extended from the accelerometer example:

Final Thoughts

Photo by Antonio Gabola on Unsplash

The JACDAC JDOM package is event-driven, so it’s not easy to control the program flow in Node.js. In a more reactive environment like React or p5.js, you can have the user to decide how to control things. Although, since different sensors may require different control or data presentation, you may have to import a lot of stuff in order to support “auto-detection” on unknown devices.

There’s lots of sensors including WiFi. It can be combined with various stuff like machine learning (I’ve seen the ML4F package appear somewhere on the JACDAC site last year. And remember how micro:bit V2 was marketed that it can run AI models back in 2020?). But I think the majority of action will still happen in MakeCode, including Maker MakeCode.

I worked for a small STEM group a few years back and am still very interested in this subject (actually, when I was looking for a new job earlier this year, I did apply a STEM support engineer opening at Microsoft — only got a curtly rejection like 2 months later.) It’s still early to say whether or not JACDAC will be successful in the world of STEM, since it’s heavily relying on this special hardware — the product price and availability of the JACDAC ecosystem are something we’ll have to wait and see.

--

--