efferent-ble-simulator
by dncore
README.md
<p align="center">
<img src="assets/efferent-ble-simulator.svg" width="128" height="128" alt="Efferent BLE Simulator">
</p>
<h1 align="center">Efferent BLE Simulator</h1>
<p align="center"><strong>Efferent — Turn Linux into a Bluetooth peripheral.</strong></p>
<p align="center">
<a href="README.md">English</a> · <a href="README.zh-CN.md">简体中文</a>
</p>
<p align="center">
<img src="https://img.shields.io/badge/license-MIT-blue" alt="license">
<img src="https://img.shields.io/badge/version-1.0.0-green" alt="version">
<img src="https://img.shields.io/badge/platform-Linux%20%2F%20BlueZ-lightgrey" alt="platform">
</p>
Efferent simulates Bluetooth Low Energy (BLE) peripheral devices over a real radio (Linux BlueZ): **FTMS smart trainers**, **cycling power meters**, **speed/cadence sensors** and **heart rate monitors**. It exposes an **MCP server** (stdio or HTTP) so AI agents — and the bundled Web dashboard — can start, configure and monitor simulations as if controlling a real trainer.
---
## Features
- **4 device types** over real BLE radio: FTMS smart trainer (0x1826), Cycling Power (0x1818), Speed/Cadence (0x1816), Heart Rate (0x180D)
- **Full FTMS control point** support: Request Control / Start / Stop / Reset / Set Target Power / Set Resistance / Set Indoor Bike Simulation Params
- **Dynamic simulation**: human-like power/cadence drift + physics-based speed model (weight, grade, Crr, CdA), 5 riding scenarios including the scripted **ride_script**
- **Hot parameter update**: change params while running — the connected phone keeps its link and receives new values on the next notify (no re-pair)
- **Named config presets**: save / load / rename / delete up to 20 named configs, shared between MCP and Dashboard
- **17 MCP tools** for agents: control, configure, config presets, interaction rules, sessions, logs, live device state, version
- **Web dashboard** (React + shadcn/ui): status, forms, one-click ride templates, interaction rules, session/config management, live log side-panel
- **Docker Compose** one-command deployment (MCP + Dashboard + Skill file over HTTP), long-running with health checks
- **Versioned**: MCP `serverInfo.version` + `ble_get_version` let agents detect updates
---
## Runtime Environment
| Requirement | Detail |
|---|---|
| OS | **Linux only** — the simulator drives the host **BlueZ** over D-Bus |
| BlueZ | ≥ 5.87 (5.86 has an advertisement-registration bug) |
| Hardware | A **BLE-capable adapter** (built-in or USB dongle) is required to actually broadcast |
| D-Bus policy | `/etc/dbus-1/system.d/ble-simulator.conf` (installed by `npm run setup`) — required for non-root GATT registration |
| Docker | The mcp container **mounts the host D-Bus socket**; the BLE radio always stays on the host |
| Not supported | macOS / Windows natively (would need a different BLE stack); without any BLE adapter the MCP service runs but nothing is transmitted |
### Why Linux only
The fundamental reason: Efferent does not "pretend" to be a Bluetooth device at the application layer — it runs as a **D-Bus client of BlueZ**, the Linux Bluetooth stack. The BLE peripheral role (GATT server + advertising) is a first-class citizen on Linux: the kernel's `net/bluetooth` subsystem handles the radio protocol, and the userspace `bluetoothd` daemon exposes the full `org.bluez` API over the system D-Bus — any process can register custom GATT services via `GattManager1` and control advertising precisely via `LEAdvertisingManager1`. All of Efferent's peripheral logic (FTMS characteristics, notify push, hot parameter updates, stable serial numbers) is calls against these interfaces, backed by a system-level D-Bus policy file (`/etc/dbus-1/system.d/ble-simulator.conf`) that grants non-root GATT registration. No other desktop OS offers this.
Both the Microsoft Bluetooth Stack (Windows) and CoreBluetooth (macOS) are closed high-level APIs: no BlueZ D-Bus interface, no HCI-level control. Efferent's code cannot run on top of them — details below.
#### Windows
Windows' WinRT API (`Windows.Devices.Bluetooth`) is centered on the central role (GATT client). A peripheral-role (GATT server) API exists, but it is hardware-dependent — per [Microsoft's official FAQ](https://learn.microsoft.com/en-us/windows/apps/develop/devices-sensors/bluetooth-dev-faq), not all Windows devices support the Peripheral Role; whether emulation works at all depends on the adapter's firmware. Even when it does, Windows' GATT server is far weaker than BlueZ: advertising parameters and custom advertisement data are restricted, so you cannot control how the device is discovered the way `LEAdvertisingManager1` allows. And since the codebase is written against BlueZ D-Bus, it cannot run on Windows as-is; an equivalent rewrite is both hardware-limited and loses core capabilities.
#### macOS
macOS uses the proprietary CoreBluetooth stack. It does offer a peripheral mode (`CBPeripheralManager`), but as a closed high-level wrapper it is not on par with BlueZ: advertising is limited to the local name and service UUIDs — custom manufacturer data cannot actually be broadcast; advertising parameters and the HCI layer are not controllable; GATT behavior is subject to system policy. Efferent's core features — multiple device types, runtime hot updates, fine-grained advertising/notification control — depend on BlueZ-level openness that CoreBluetooth cannot express. There is also no D-Bus or BlueZ on macOS, so the code cannot run there.
#### WSL2
WSL2 is a lightweight Hyper-V virtual machine, not "Linux built into Windows", so it inherits three fundamental VM limitations:
- **No radio of its own.** The Bluetooth adapter is owned exclusively by the Windows Bluetooth stack; the WSL2 VM sees no HCI device. A USB dongle can be shared in via the official usbipd-win route, but that requires detaching the adapter from Windows first — the host's Bluetooth dies while WSL uses the hardware, a fragile trade-off.
- **No Bluetooth in the default kernel.** Microsoft's [official WSL kernel config](https://github.com/microsoft/WSL/blob/master/Microsoft/config-wsl) does not enable Bluetooth (`CONFIG_BT`); you must recompile the kernel and enable the HCI drivers. This is a long-standing community request ([microsoft/WSL#242](https://github.com/microsoft/WSL/issues/242)) with no official built-in support.
- **Missing userspace components.** Even with a custom kernel, WSL2 ships without `bluez`/`bluetoothd`; you would install and run them manually with no official support path.
In short: running Efferent in WSL2 means recompiling the kernel + USB passthrough + hand-rolling the Bluetooth userspace while the host gives up its own Bluetooth — replicating an entire Bluetooth stack inside a VM and fighting Windows for the same hardware. WSL1 shares the Windows kernel and has no Linux Bluetooth stack at all. Therefore: native Linux only.
## Architecture
```
┌──────────────┐ MCP protocol ┌───────────────────────────┐
│ AI Agent / │ ◄───────────────► │ MCP Server (HTTP :3300) │
│ MCP Client │ stdio / :3300 │ Web Dashboard (:3330) │
│ (Claude, │ └────────────┬──────────────┘
│ Codex, pi) │ │ BlueZ D-Bus (system bus)
└──────────────┘ ▼
┌───────────────────────────┐
│ Host Linux BlueZ │
│ bluetoothd + BLE adapter │
│ (D-Bus policy, ≥ 5.87) │
└────────────┬──────────────┘
│ HCI / radio
▼
┌───────────────────────────┐
│ BLE advertisement + GATT │
│ FTMS / Power / CSC / HR │
└────────────┬──────────────┘
│
┌────────────▼──────────────┐
│ Phone / Cycling App │
│ (Zwift, Garmin, Strava...)│
└───────────────────────────┘
```
## Device Types
| Type | BLE Service | Characteristics |
|---|---|---|
| **FTMS** smart trainer | 0x1826 | FM Feature, Indoor Bike Data (notify), Control Point (write/indicate), Status, Resistance/Power ranges |
| **Cycling Power** | 0x1818 | Power/Cadence measurement, wheel & crank data |
| **Speed/Cadence (CSC)** | 0x1816 | Wheel & crank revolution counters |
| **Heart Rate** | 0x180D | HR measurement, body sensor location, battery |
---
## Quick Start
### Prerequisites
- Linux with **BlueZ ≥ 5.87** (bluetoothd) and a BLE-capable adapter
> BlueZ 5.86 has an advertisement-registration bug; on kernels with strict MGMT validation every `RegisterAdvertisement` fails. Upgrade first: `sudo pacman -S bluez && sudo systemctl restart bluetooth` (Arch) / `sudo apt install bluez` (Debian).
- Node.js ≥ 18
### 1. Native install
```bash
npm install
npm run setup # sudo — installs D-Bus policy, enables bluetoothd, verifies GATT
npm run build
npm run start:http # HTTP mode on :3300 (or npm start for stdio)
```
`npm run setup` writes `/etc/dbus-1/system.d/ble-simulator.conf` — **required** for non-root GATT registration (without it the device advertises but cannot be connected).
### 2. Docker Compose (recommended for servers)
```bash
bash scripts/docker-up.sh # host prep (sudo, once) + build + start
```
| URL | Purpose |
|---|---|
| `http://<host>:3300/mcp` | MCP endpoint |
| `http://<host>:3330/` | Web dashboard |
| `http://<host>:3330/skill/SKILL.md` | Operation skill (for agents) |
| `http://<host>:3330/help` | Dashboard help page |
Zero-compile build (better-sqlite3 prebuilt binary); on restricted networks use a mirror:
```bash
NPM_REGISTRY=https://registry.npmmirror.com \
BETTER_SQLITE3_BINARY_HOST_MIRROR=https://registry.npmmirror.com/-/binary/better-sqlite3 \
bash scripts/docker-up.sh
```
Management: `bash scripts/docker-up.sh status|logs|down`
---
## MCP Tools (17)
| Tool | Purpose |
|---|---|
| `ble_start` / `ble_stop` / `ble_restart` | Start / stop / restart a simulation |
| `ble_status` | Controller state + active config + **live device state** (phase/power/cadence/HR) |
| `ble_configure` | Update config — **hot-updates in place** if running with the same device type |
| `ble_get_config` | Current/latest config as JSON (form backfill) |
| `ble_save_config` | Save current params as a named preset (**max 20**) |
| `ble_list_configs` / `ble_get_config_detail` | List / view saved presets |
| `ble_rename_config` / `ble_delete_config` | Rename / delete a preset |
| `ble_set_interaction` / `ble_clear_interactions` | Custom BLE write-response rules |
| `ble_list_sessions` / `ble_get_session` | Session history / detail |
| `ble_get_logs` | Persistent communication logs (connect/write/notify/error) |
| `ble_get_version` | MCP + Skill versions, changelog, Skill URL (for update detection) |
### Example
```json
{
"method": "tools/call",
"params": {
"name": "ble_start",
"arguments": {
"deviceType": "ftms",
"ftms": { "simulation": { "enabled": true, "scenario": { "type": "ride_script" } } }
}
}
}
```
---
## Dynamic Simulation
Enable via `simulation` on FTMS / CyclingPower / CSC:
```json
"simulation": {
"enabled": true,
"riderWeightKg": 75, "bikeWeightKg": 8, "crr": 0.004, "cdA": 0.35,
"fatigueFactor": 0.0005, "cadenceCoupling": "proportional", "microPauseProbability": 0.008,
"autoStart": true,
"scenario": { "type": "ride_script" }
}
```
| Param | Default | Description |
|---|---|---|
| `enabled` | `false` | Master switch |
| `riderWeightKg` / `bikeWeightKg` | 75 / 8 | Speed physics (rider+bike mass) |
| `crr` / `cdA` | 0.004 / 0.35 | Rolling resistance / drag area |
| `fatigueFactor` | 0 | Power decay per minute (0 = none) |
| `cadenceCoupling` | `proportional` | `proportional` / `inverse` / `independent` |
| `microPauseProbability` | 0.005 | Per-tick coasting pauses |
| `autoStart` | `false` (ride_script defaults on) | Start riding without an app sending Start |
| `scenario` | `freeride` | Riding scenario (below) |
### Scenarios
- **`steady`** — natural micro-variation only
- **`freeride`** (default) — base power drifts ±20% every 60–180s
- **`intervals`** — high/low power alternation
- **`warmup_main_cooldown`** — warmup → main → cooldown
- **`ride_script`** — scripted ride: a sequence of riding actions (**start / cruise / climb / sprint / coast / stop**), each with target power, grade and optional cadence; auto-starts, loops with `repeat: true`, `stop` zeroes power/cadence/speed. Omit `phases` to use the built-in default script.
---
## Hot Update & Stable Serial
- **Hot update**: `ble_configure` while running + same device type applies params **in place** — the connected phone keeps its link and receives updated values on the next notify. Device-type changes still restart (new serial forces re-discovery).
- **Stable serial**: `SIM_SERIAL_STABLE=1` keeps the serial stable across same-type sessions (`SIM001-FTMS`), so a phone can reconnect after stop/start **without forgetting the device**.
---
## Environment Variables
| Var | Default | Description |
|---|---|---|
| `MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
| `MCP_PORT` / `MCP_HOST` | `3300` / `0.0.0.0` | HTTP listen address |
| `SIM_DEVICE_PREFIX` | `OPEN_RIDE` | Advertised name prefix |
| `SIM_INSTANCE_ID_MODE` | — | Instance ID mode for the device name |
| `SIM_SERIAL_STABLE` | unset | `1` = stable serial for same-type sessions |
| `SKILL_PUBLIC_URL` | `/skill/SKILL.md` | Public Skill URL (returned by `ble_get_version`) |
---
### Source Layout
```
src/
├── index.ts # entry, lifecycle
├── mcp-server.ts # 17 MCP tool definitions & handlers
├── ble-controller.ts # BlueZ D-Bus lifecycle, advertisement, hot-update
├── database.ts # SQLite layer (sessions / saved_configs / logs)
├── config.ts # config merge & defaults
├── version.ts # version single-source
├── simulator.ts # HeartRate / Battery simulators
├── cycling-simulator.ts # power/cadence simulators + physics + ride_script engine
└── devices/ # heart-rate / cycling-power / csc / ftms GATT devices
```
BLE peripheral access runs over the **host BlueZ D-Bus** (the container mounts the host D-Bus socket). See `docs/design/*.md` for design records.
---
## License
[MIT](LICENSE) © BLE Simulator Contributors
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues