DEV Community

Lars Knudsen 🇩🇰
Lars Knudsen 🇩🇰

Posted on

Thingy52 AI Magic 8-Ball

Spiderman and the LEGO spacemen have been arguing all morning about whether it is safe to deploy to production on a Friday. To settle the debate once and for all, they decided to build a web-enabled Magic 8-Ball...

While they're shaking the hardware, I'll share what's under the hood :)

Upgrading a 1950s classic

The classic Magic 8-Ball is pretty simple: you ask a yes/no question, shake a plastic sphere filled with blue dye, and a 20-sided die floats up to the window with a pre-printed answer like "Outlook good" or "Ask again later".

In a real web app, we could just pick a random string from an array using Math.random(), call it a day, and go grab a coffee.

However, by combining Web Bluetooth, an external multi-sensor device, and the Built-in AI APIs in Chrome (running 100% locally on the device—no cloud, no API keys!), we can make something a lot more fun: an Oracle whose personality is secretly biased by the physical environment of your room.

Anyway, let's get to work!

What this thing does

Instead of just using the accelerometer to detect a shake, we feed a live snapshot of the room's telemetry into the browser's local LLM (LanguageModel) every time we consult the 8-Ball:

  • Accelerometer (Shake intensity): Triggers the fortune roll. A gentle shake gets a polite answer, while shaking it too violently (> 3.0G) makes the AI dizzy and annoyed.

  • Air Quality (eCO2 & TVOC): If your office eCO2 goes above 1000 ppm, the Oracle gets grumpy, suffocated, and tells you to open a window before making any life decisions.

  • Temperature & Humidity: A hot room (> 27°C) triggers heat-fatigued impatience; a cold room makes it frosty and detached.

  • Light Sensor: Dim lighting (< 65 lux) unlocks shadowy, cryptic prophecies; bright light triggers overly enthusiastic optimism.

  • Compass / Magnetometer: Facing North (0°) gives stoic, resolute advice; facing South (180°) gives warm, encouraging answers.

  • RGB LED & Speech: The AI returns a structured JSON payload that includes a hex color code matching its mood, which we write back to the hardware's RGB LED over Web Bluetooth while speaking the prophecy aloud.

In the root of the repository, everything is packed into a single self-contained HTML file (so you can load it directly in the browser or host it anywhere—and yes, I am definitely still not a CSS expert, so please forgive the 3D sphere gradients!).

Thingy:52 Sensor & LED GATT Services

As covered in some of my previous posts (like Creating a Web Thermometer and Generic Sensors and Thingy:52), the Thingy:52 by Nordic Semiconductor is a very powerful little unit, packing a lot of onboard sensors and actuators which are all exposed over BLE GATT.

First, switch it on:

Switching on Thingy52

Looking in the Thingy:52 firmware documentation, we need characteristics from three different services:

  • Environment Service (ef680200-9b35-4933-9b10-52ffa9740042)

    • ef680201-...: Temperature (int8 integer + uint8 decimal)
    • ef680203-...: Humidity (uint8 percentage)
    • ef680204-...: Air Quality / Gas (eCO2 uint16 + TVOC uint16)
    • ef680205-...: Light / Color (R, G, B, Clear uint16)
  • User Interface Service (ef680300-9b35-4933-9b10-52ffa9740042)

    • ef680301-...: RGB LED (Write mode + R, G, B bytes)
    • ef680302-...: Top Button (uint8 press state)
  • Motion Service (ef680400-9b35-4933-9b10-52ffa9740042)

    • ef680406-...: Raw Motion (Accelerometer, Gyro, Compass)
    • ef680409-...: Compass Heading (int32 fixed-point degrees)

Connecting to the Thingy:52 from our web application is straightforward—just remember to include the Environment, UI, and Motion service UUIDs in optionalServices:

const THINGY_UUIDS = {
  configService:      'ef680100-9b35-4933-9b10-52ffa9740042',
  environmentService: 'ef680200-9b35-4933-9b10-52ffa9740042',
  uiService:          'ef680300-9b35-4933-9b10-52ffa9740042',
  motionService:      'ef680400-9b35-4933-9b10-52ffa9740042',
  // ... characteristic UUIDs
};

const bleDevice = await navigator.bluetooth.requestDevice({
  filters: [
    { services: [THINGY_UUIDS.configService] },
    { namePrefix: 'Thingy' }
  ],
  optionalServices: [
    THINGY_UUIDS.environmentService,
    THINGY_UUIDS.uiService,
    THINGY_UUIDS.motionService
  ]
});
Enter fullscreen mode Exit fullscreen mode

Detecting a Shake (without spamming the LLM)

One "gotcha" when combining raw BLE motion sensors with an LLM is the data rate. The Thingy:52's raw motion characteristic (ef680406-9b35-4933-9b10-52ffa9740042) streams accelerometer updates rapidly, where the X, Y, and Z values are formatted as 16-bit signed integers in 6Q10 fixed-point format (meaning we divide by 1024 to get the force in G).

We calculate the total acceleration vector Math.sqrt(ax * ax + ay * ay + az * az) and add a 3-second cooldown so one physical shake doesn't queue up twenty AI prompts:

const rawChar = await motionService.getCharacteristic(THINGY_UUIDS.rawMotionChar);
await rawChar.startNotifications();

rawChar.addEventListener('characteristicvaluechanged', (e) => {
  const dv = e.target.value;
  const ax = dv.getInt16(0, true) / 1024;
  const ay = dv.getInt16(2, true) / 1024;
  const az = dv.getInt16(4, true) / 1024;
  const totalG = Math.sqrt(ax * ax + ay * ay + az * az);

  // Trigger a roll above 1.8G with a 3-second debounce cooldown
  const now = Date.now();
  if (totalG > 1.8 && (now - sensorState.lastShakeTimestamp > 3000)) {
    sensorState.lastShakeTimestamp = now;
    sensorState.shakeG = Math.min(4.5, totalG);
    consultOracle(`BLE Shake ${totalG.toFixed(1)}G`);
  }
});
Enter fullscreen mode Exit fullscreen mode

Browser AI & Web APIs under the hood

To make the Magic 8-Ball feel alive without relying on any external cloud servers, the app combines a handful of modern browser APIs:

  1. Chrome Built-in AI (LanguageModel / Prompt API): Runs Gemini Nano locally inside the browser to generate the sensor-biased fortunes. Since Chrome 148, the Prompt API has graduated to Stable and is enabled by default on Desktop without needing any chrome://flags.

  2. Structured JSON Output (responseConstraint): Forces the local model to reply with a strict JSON object containing the short triangle text, the full fortune, a sentiment tag, and an RGB hex color code.

  3. Web Speech API (SpeechRecognition & speechSynthesis): Allows you to speak your question into the microphone and have the Oracle read its answer aloud (adjusting its pitch and speed depending on whether it's dizzy, grumpy, or ominous).

  4. Web Audio / Tone.js: Plays a mystical synth chord tuned to the sentiment of the fortune.

Wiring up LanguageModel with responseConstraint

What makes the Prompt API really shine for hardware projects is the responseConstraint option. Instead of crossing our fingers and hoping the LLM returns valid JSON that our BLE LED writer can parse, we pass a JSON Schema directly into session.prompt():

const ORACLE_JSON_SCHEMA = {
  type: "object",
  properties: {
    shortAnswer: {
      type: "string",
      description: "2 to 5 words uppercase summary for the floating triangle die"
    },
    fortune: {
      type: "string",
      description: "1 or 2 vivid sentences answering the question while weaving in the room sensors."
    },
    sentiment: {
      type: "string",
      enum: ["positive", "cautious", "ominous", "grumpy", "dizzy"]
    },
    ledColor: {
      type: "string",
      pattern: "^#[0-9A-Fa-f]{6}$",
      description: "Hex RGB color code matching the fortune mood to write back to the Thingy:52 LED"
    }
  },
  required: ["shortAnswer", "fortune", "sentiment", "ledColor"],
  additionalProperties: false
};
Enter fullscreen mode Exit fullscreen mode

Before creating a session, we check LanguageModel.availability(). If the model weights haven't been downloaded yet ("downloadable"), LanguageModel.create() can attach a progress monitor while Chrome fetches the model in the background:

const avail = await LanguageModel.availability();

if (avail === 'available' || avail === 'downloadable') {
  const session = await LanguageModel.create({
    systemPrompt: "You are a witty, mystical Magic 8-Ball powered by real Nordic Thingy:52 Bluetooth sensors.",
    monitor(m) {
      m.addEventListener('downloadprogress', (e) => {
        console.log(`Downloading on-device model: ${Math.round(e.loaded * 100)}%`);
      });
    }
  });

  const rawJson = await session.prompt(buildPromptPayload(question), {
    responseConstraint: ORACLE_JSON_SCHEMA
  });

  const responseObj = JSON.parse(rawJson);
}
Enter fullscreen mode Exit fullscreen mode

Closing the loop: Writing the AI's mood back to the LED

Once the AI returns responseObj.ledColor (for example #f59e0b when the room's CO2 is too high), we update the CSS glow on the page and write the RGB bytes straight back to the Thingy:52's UI LED characteristic (ef680301-9b35-4933-9b10-52ffa9740042).

In Constant RGB mode (0x01), the Thingy:52 expects 4 bytes: [1, R, G, B]:

async function writeThingyLedColor(hexColor) {
  if (!ledCharacteristic) return;
  const cleaned = hexColor.replace('#', '');
  const num = parseInt(cleaned, 16);
  const r = (num >> 16) & 255;
  const g = (num >> 8) & 255;
  const b = num & 255;

  // Mode 1 = Constant RGB [0x01, R, G, B]
  const payload = new Uint8Array([1, r, g, b]);
  await ledCharacteristic.writeValue(payload);
}
Enter fullscreen mode Exit fullscreen mode

Mini Manual: How to use the Thingy:52 Oracle

Whether you have a physical Thingy:52 on your desk or just want to play with the sensor-biased AI in your browser, I built a full Hardware Simulator Mode right into the page so anyone can try it out.

1. Verifying Chrome's Built-in AI (chrome://on-device-internals)

In modern Chrome (Chrome 148+), LanguageModel is built right in—no chrome://flags required!

  1. Open the app in Google Chrome on Desktop (Windows, macOS, Linux, or ChromeOS).

  2. Look at the status badge in the top bar:

  • If the on-device Gemini Nano model is already on your machine, it will turn green and say Chrome LanguageModel: Ready (On-Device).

  • If this is your first time using Built-in AI, calling LanguageModel.create() will trigger the model download automatically.

  1. Want to peek under the hood at the model status or check hardware eligibility? Open a tab to chrome://on-device-internals and select the Model Status tab.

Note: If your machine doesn't meet the GPU/disk requirements (or you're on an unbranded Chromium build), don't worry! The app automatically falls back to a built-in Sensor-Biased Heuristic Oracle so you can still test all the hardware interactions.

2. Connecting your Thingy:52

  1. Flip the power switch on your Thingy:52 (if it has been sitting in a drawer for a while, give it a quick USB charge first).

  2. Click the Connect Thingy:52 button in the top-right corner of the app.

  3. Select Thingy in the browser's pairing dialog and click Pair.

  4. The telemetry badge on the right will switch from Simulator Mode to Live BLE Hardware, and the sliders will start moving automatically as your room temperature, eCO2, light level, and compass orientation update.

NOTE: Spiderman wanted to shake the Thingy:52 himself to test the 3.8G violent shake threshold, though he is not strictly required for the BLE pairing process (please don't tell him).

3. Consulting the Oracle

You have four ways to trigger a prophecy:

  • Shake the physical Thingy:52: Give it a polite roll (~1.9G) for a normal reading, or shake it vigorously (> 3.0G) if you want to make the Oracle dizzy.

  • Press the top button on the Thingy:52: The button notification will immediately trigger a reading for whatever question is in the input box.

  • Use your voice: Click the microphone icon inside the question input box, speak your question aloud, and it will automatically transcribe and consult the 8-Ball.

  • Use the Simulator Controls: If you don't have a Thingy:52 handy, click the quick environment presets (🥵 Stuffy Room, 🌑 Dark & Cryptic, 🧭 Stoic North, ☀️ Crisp & Sunny) or drag the telemetry sliders, then click 🌊 Gentle Shake (1.9G) or ⚡ Violent Shake (3.8G).

4. Inspecting the Live Prompt

At the bottom right of the page, check out the Live Prompt & Schema Inspector. Every time you tweak a sensor slider or breathe on the Thingy:52's gas sensor, you can see the exact prompt string and JSON schema being sent to LanguageModel.prompt(), with a one-click Copy Snippet button.

Final thoughts

Try out the Magic 8-ball here!

I was really pleased with how natural it felt to combine raw GATT sensor streams from the Thingy:52 with an on-device LLM in the browser. Having the AI complain about high CO2 in the room—and simultaneously turn the physical puck's LED amber—makes the hardware feel like it has a genuine personality of its own, without sending a single byte of room telemetry to the cloud.

Try to expand the application by hooking up the Thingy:52's speaker characteristic to play custom 8-bit sound effects over BLE, or add the pressure sensor to let upcoming rainstorms affect your fortune!

Feedback, requests, issue reports and PRs are very welcome!

Enjoy ;)

Top comments (0)