Skip to content

ApfelPresse/scratchlink-fakehub

Repository files navigation

scratchlink-fakehub

logo

A tiny fake hub that behaves like Scratch Link and serves Scratch extensions locally. It starts a WebSocket server (default: ws://127.0.0.1:20111) and publishes fake peripherals that Scratch can discover → connect to.

Quick Start

pip install https://github.com/ApfelPresse/scratchlink-fakehub.git
python3 main.py

Note: Scratch must be open and Scratch Link must be accessible on port 20111.

Using with Scratch

  1. Start Scratch (Desktop or Browser).

  2. Add Extensionmicro:bit or LEGO® Education WeDo 2.0.

  3. The device list should show an entry from this hub (e.g. “Fake-Device”). Connect.

  4. Try blocks (buttons/gestures/tilt/pins for micro:bit; sensors for WeDo).

Tip
Every JSON-RPC message sent/received is logged (depending on your hub setup), which is very handy for debugging and reverse engineering.

micro:bit

The fake micro:bit exposes a session stream on Service 0xF005 (61445) with these characteristics:

  • RX (notify): 5261da01-fa7e-42ab-850b-7c80220097cc — periodic snapshot

  • TX (write): 5261da02-fa7e-42ab-850b-7c80220097cc — display commands

Smoke test (micro:bit)

Minimal example — starts the hub and briefly triggers button A:

import asyncio, logging
from scratchlink_fakehub.device import FakeDevice
from scratchlink_fakehub.hub import ScratchLinkHub
from scratchlink_fakehub.microbit import MicrobitDevice

logging.basicConfig(level=logging.DEBUG, format="[%(asctime)s] [%(levelname)s] %(message)s")

async def sensor_loop(mb: MicrobitDevice):
    await asyncio.sleep(2)     # give Scratch time to connect
    await mb.press_a(); await asyncio.sleep(1); await mb.release_a()

async def main():
    link = FakeDevice()
    mb = MicrobitDevice()
    hub = ScratchLinkHub(link, devices=[mb])
    await asyncio.gather(hub.start(), sensor_loop(mb))

asyncio.run(main())

Snapshot layout (RX)

10-byte frame (tilt is little-endian int16):

Byte Meaning

0–1

tiltX (int16, hi/lo)

2–3

tiltY (int16, hi/lo)

4

btnA (0/1)

5

btnB (0/1)

6

pin0 (0/1)

7

pin1 (0/1)

8

pin2 (0/1)

9

gesture (uint8)

Note
For When pin X connected, set the corresponding pin byte (6–8) from 0→1 and keep it for ~0.3–0.5 s, then set it back to 0.

Gesture mapping

This implementation uses the following values (tested with Scratch):

Gesture Code

moved

4

shaken

2

jumped

1

none

0

Display (TX)

The TX characteristic accepts simple display commands:

Opcode Meaning

0x81

display text — UTF‑8 text

0x82

display 5×5 — 5 bytes bitmap; all zeros ⇒ clear display

0x80

set pixel — 3 bytes: x,y,on (0/1)

Python API (MicrobitDevice)

Buttons

await mb.press_a();  await mb.release_a()
await mb.press_b();  await mb.release_b()

Gestures

await mb.moved()
await mb.shaken()
await mb.jumped()
await mb.clear_gesture()

Tilt

await mb.tilt_front(); await mb.tilt_back()
await mb.tilt_left();  await mb.tilt_right()
await mb.tilt_any();   await mb.reset_tilt()

Pins

await mb.pin_connected(0, True)   # connect P0
await mb.pin_connected(0, False)  # reset

Display callbacks (default: logging; override if you want a UI)

async def on_display_text(self, text: str): ...
async def on_display_matrix(self, rows: Sequence[int]): ...
async def on_clear_display(self): ...
async def on_set_pixel(self, x: int, y: int, on: bool): ...

LEGO WeDo 2.0

Supported Devices

Currently emulates:

  • Port 1: Distance Sensor (Input)

  • Port 2: Tilt Sensor (Input)

Ports and device types are configurable.

Protocol Structure & Communication

JSON-RPC Overview

Scratch Link communicates via JSON-RPC. Each message contains a method, optional params, and an ID.

{
  "jsonrpc": "2.0",
  "method": "startNotifications",
  "params": {
    "serviceId": "...",
    "characteristicId": "..."
  },
  "id": 4
}

Supported Methods

discover

Search for devices

connect

Connects to the fake device

startNotifications

Starts notification loop

stopNotifications

Stops notification loop

write

For motor commands

read

Read sensor value once

WeDo 2.0 Specification

Based on reverse-engineering documentation at https://ofalcao.pt/blog/series/wedo-2-0-reverse-engineering

UUIDs

PORT_SERVICE

00001523-1212-efde-1523-785feabcd123

PORT_CHAR

00001527-1212-efde-1523-785feabcd123

PORT_NOTIFY_CHAR

00001528-1212-efde-1523-785feabcd123

SENSOR_SERVICE

00004f0e-1212-efde-1523-785feabcd123

SENSOR_CHAR

00001560-1212-efde-1523-785feabcd123

CTRL_CHAR_TX

00001565-1212-efde-1523-785feabcd123

These UUIDs define how the simulated device exposes characteristics for motor, LED, sensor values, and notification control.

Sensor Value Handling

Trigger Principle

Scratch only detects new values if an intermediate dummy value (e.g., 0) is sent before the target value.

await send(0)     # Dummy impulse
await send(30)    # Actual value

This is required due to how Scratch deduplicates incoming BLE notifications.

characteristicDidChange

The method characteristicDidChange is how the fake hub pushes sensor or state changes to Scratch Link.

It must be used with startNotifications beforehand, or Scratch will ignore the data.

Byte Message Explanation

Example JSON-RPC message:

{
  "jsonrpc": "2.0",
  "method": "characteristicDidChange",
  "params": {
    "serviceId": "00001523-1212-efde-1523-785feabcd123",
    "characteristicId": "00001527-1212-efde-1523-785feabcd123",
    "encoding": "base64",
    "message": "BQEU"
  }
}

Decoded message (base64.b64decode("BQEU")) → [0x05, 0x01, 0x14]

  • 0x05: Notification prefix (fixed)

  • 0x01: Port 1

  • 0x14: Decimal 20 (value)

For tilt: [0x05, port, x, y] → each axis ranges from 0 to 255

Encoding Details

encode_attach(port, device)

This announces a device (e.g., distance sensor) connected to a port.

bytes([
  port, 0x01, 0x00, DEVICE_TYPES[device],
  0x00, 0x01, 0x01, 0x10,
  0x00, 0x00, 0x00, 0x10
])

Explanation:

  • port: Port index (1–2)

  • 0x01, 0x00: Hub capabilities (fixed)

  • DEVICE_TYPES[device]: 0x23 (distance), 0x22 (tilt), 0x01 (motor)

  • Remaining: likely mode & unit setup. Reverse-engineered but partially undocumented.

encode_sensor(port, device)

Generates the correct sensor payload depending on device type.

# Distance Sensor
[0x05, port, value]

# Tilt Sensor
[0x05, port, x, y]

# Motor Sensor
[0x05, port, speed]

Example: Distance Sensor Loop

device.set_distance(50)
await asyncio.sleep(0.05)
#device.set_distance(0)   # Reset, this happens automatically in the python class
await asyncio.sleep(0.05)
device.set_distance(50)  # Trigger again

This ensures Scratch receives the updated distance.

Tilt Trigger Mapping

The following methods define simulated tilt directions:

def tilt_up(self):    self.set_tilt(0, 60)
def tilt_down(self):  self.set_tilt(0, 30)
def tilt_left(self):  self.set_tilt(60, 0)
def tilt_right(self): self.set_tilt(30, 0)

Thresholds for x/y axis were determined empirically. Scratch blocks trigger based on crossing value thresholds. Scratch ignores gradual or unchanged values.

Communication Flow

@startuml
title WeDo 2.0 ScratchLink Protocol

Scratch -> ScratchLink: discover
ScratchLink -> FakeHub: discover
FakeHub -> ScratchLink: didDiscoverPeripheral

Scratch -> ScratchLink: connect
ScratchLink -> FakeHub: connect

Scratch -> ScratchLink: startNotifications
ScratchLink -> FakeHub: startNotifications
FakeHub -> ScratchLink: characteristicDidChange (attach)

loop Every interval
    FakeHub -> ScratchLink: characteristicDidChange (sensor value)
end

Scratch -> ScratchLink: stopNotifications
ScratchLink -> FakeHub: stopNotifications

@enduml

About

No description, website, or topics provided.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages