ThingsBoard Device Simulator Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a standalone local tool tools/tb-simulator/ with a React + Ant Design GUI and a Bun MQTT bridge that simulates ThingsBoard devices (telemetry publishing + RPC control) over real MQTT.

Architecture: Two parts in one folder. A Bun bridge process holds mqtt.js connections, runs telemetry timers, auto-replies to server-side RPC, and owns a local JSON persistence file; it exposes a WebSocket (/ws). A React + Vite + Ant Design GUI is the control panel and talks to the bridge only over that WebSocket. In start mode a single Bun process serves the built GUI and the WebSocket.

Tech Stack: Bun 1.3, TypeScript 5.9, React 19 + Vite 7 + Ant Design 6, mqtt (mqtt.js), Vitest for tests, Biome (root config) for lint/format.

Conventions (non-negotiable): TDD, SOLID, no any, @/ alias imports only (no relative ../), Bun (never npm/yarn), Biome (never ESLint/Prettier). The tool must not import from backend/ or frontend/. Tests run via bun run test (Vitest), never bun test.

Spec: docs/superpowers/specs/2026-07-12-thingsboard-device-simulator-design.md


File Structure

tools/tb-simulator/
  package.json            # standalone package (NOT added to root workspaces)
  tsconfig.json           # @/* -> ./* path alias
  vite.config.ts          # GUI build + dev proxy /ws + Vitest config + alias
  index.html              # Vite entry
  .gitignore              # node_modules, dist, simulator-data.json
  README.md
  shared/                 # code shared by server + web (pure, no I/O)
    types.ts              # DeviceConfig, TelemetryKey, BrokerSettings, SimulatorData, ConnectionStatus, LogEntry
    protocol.ts           # Command + ServerEvent + RuntimeState WebSocket message unions
    telemetry.ts          # generateTelemetryValue, buildTelemetryPayload
    rpc.ts                # topic constants + parseRpcRequestId / rpcResponseTopic / clientRpcTopic
  server/
    persistence.ts        # loadData / saveData / DEFAULT_DATA
    mqtt-adapter.ts       # real mqtt.js MqttFactory (thin, manually verified)
    device-session.ts     # DeviceSession: one device's mqtt lifecycle, telemetry, rpc auto-reply
    simulator.ts          # Simulator: owns state + sessions, applies Command, emits ServerEvent
    ws-server.ts          # Bun.serve WebSocket + static file serving
    index.ts              # entry: wire simulator + ws-server, open browser in start mode
  web/
    main.tsx              # React root
    App.tsx               # layout shell
    store.ts              # WsStore: connects to /ws, holds RuntimeState, useSyncExternalStore hook
    components/
      TopBar.tsx          # broker settings + connect-all / disconnect-all
      DeviceList.tsx      # device cards, add/delete, selection
      DeviceDetail.tsx    # tabs container
      ConnectionTab.tsx   # name, token, connect/disconnect
      TelemetryTab.tsx    # dynamic telemetry keys + interval + start/stop/publish-once
      ControlTab.tsx      # control toggle, auto-reply body, live log, manual publish
  test/
    setup.ts              # RTL jsdom setup (only for the one UI test)

Design notes for the implementer:

  • DeviceSession and Simulator take an injected MqttFactory so tests use a fake in-memory client and never touch a network.
  • All GUI ↔ bridge traffic is JSON Command (GUI→bridge) and ServerEvent (bridge→GUI) messages defined in shared/protocol.ts.
  • Persistence writes on every state-changing command.

Task 1: Scaffold the standalone tool

Files:

  • Create: tools/tb-simulator/package.json

  • Create: tools/tb-simulator/tsconfig.json

  • Create: tools/tb-simulator/vite.config.ts

  • Create: tools/tb-simulator/index.html

  • Create: tools/tb-simulator/.gitignore

  • Create: tools/tb-simulator/web/main.tsx

  • Create: tools/tb-simulator/web/App.tsx

  • Step 1: Create package.json

{
  "name": "tb-simulator",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "concurrently -k \"vite\" \"bun run --watch server/index.ts\"",
    "build": "vite build",
    "start": "vite build && bun run server/index.ts",
    "server": "bun run server/index.ts",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "biome check ."
  },
  "dependencies": {
    "antd": "^6.2.0",
    "mqtt": "^5.10.1",
    "react": "^19.2.3",
    "react-dom": "^19.2.3"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^6.6.3",
    "@testing-library/react": "^16.1.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^5.1.2",
    "concurrently": "^9.1.0",
    "jsdom": "^25.0.1",
    "typescript": "~5.9.3",
    "vite": "^7.3.1",
    "vitest": "^3.0.0"
  }
}
  • Step 2: Create tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "verbatimModuleSyntax": true,
    "types": ["vitest/globals", "@testing-library/jest-dom"],
    "skipLibCheck": true,
    "baseUrl": ".",
    "paths": { "@/*": ["./*"] }
  },
  "include": ["shared", "server", "web", "test", "vite.config.ts"]
}
  • Step 3: Create vite.config.ts (also holds Vitest config + alias)
import { resolve } from 'node:path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  root: '.',
  plugins: [react()],
  resolve: { alias: { '@': resolve(__dirname, '.') } },
  server: { port: 5180, proxy: { '/ws': { target: 'ws://localhost:5181', ws: true } } },
  build: { outDir: 'dist' },
  test: {
    globals: true,
    environment: 'node',
    setupFiles: [],
  },
});
  • Step 4: Create index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ThingsBoard Device Simulator</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/web/main.tsx"></script>
  </body>
</html>
  • Step 5: Create .gitignore
node_modules
dist
simulator-data.json
  • Step 6: Create placeholder web/App.tsx and web/main.tsx

web/App.tsx:

export function App() {
  return <div>ThingsBoard Device Simulator</div>;
}

web/main.tsx:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from '@/web/App';

const root = document.getElementById('root');
if (!root) throw new Error('#root not found');
createRoot(root).render(
  <StrictMode>
    <App />
  </StrictMode>,
);
  • Step 7: Install and verify build

Run:

cd tools/tb-simulator && bun install && bun run build

Expected: install succeeds; vite build completes and writes dist/.

  • Step 8: Commit
git add tools/tb-simulator
git commit -m "chore(simulator): scaffold standalone tb-simulator tool (#201)"

Task 2: Shared types + telemetry value generator

Files:

  • Create: tools/tb-simulator/shared/types.ts

  • Create: tools/tb-simulator/shared/telemetry.ts

  • Test: tools/tb-simulator/shared/telemetry.test.ts

  • Step 1: Create shared/types.ts

export type TelemetryValueType = 'int' | 'float';

export interface TelemetryKey {
  key: string;
  min: number;
  max: number;
  type: TelemetryValueType;
  precision: number;
}

export interface DeviceConfig {
  id: string;
  name: string;
  token: string;
  controlEnabled: boolean;
  autoReplyBody: Record<string, unknown>;
  intervalMs: number;
  telemetryKeys: TelemetryKey[];
}

export interface BrokerSettings {
  host: string;
  port: number;
}

export interface SimulatorData {
  broker: BrokerSettings;
  devices: DeviceConfig[];
}

export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';

export interface LogEntry {
  ts: number;
  direction: 'in' | 'out';
  topic: string;
  payload: string;
}
  • Step 2: Write the failing test shared/telemetry.test.ts
import { describe, expect, it } from 'vitest';
import type { TelemetryKey } from '@/shared/types';
import { buildTelemetryPayload, generateTelemetryValue } from '@/shared/telemetry';

const key = (over: Partial<TelemetryKey>): TelemetryKey => ({
  key: 'temperature',
  min: 10,
  max: 10,
  type: 'float',
  precision: 2,
  ...over,
});

describe('generateTelemetryValue', () => {
  it('returns the exact bound when min equals max', () => {
    expect(generateTelemetryValue(key({ min: 27, max: 27 }))).toBe(27);
  });

  it('returns an integer for int type', () => {
    const v = generateTelemetryValue(key({ min: 0, max: 100, type: 'int' }));
    expect(Number.isInteger(v)).toBe(true);
  });

  it('respects float precision', () => {
    const v = generateTelemetryValue(key({ min: 6.8, max: 6.8, type: 'float', precision: 1 }));
    expect(v).toBe(6.8);
  });

  it('stays within [min, max]', () => {
    for (let i = 0; i < 200; i++) {
      const v = generateTelemetryValue(key({ min: 5, max: 6, type: 'float', precision: 3 }));
      expect(v).toBeGreaterThanOrEqual(5);
      expect(v).toBeLessThanOrEqual(6);
    }
  });
});

describe('buildTelemetryPayload', () => {
  it('maps each key name to a generated value', () => {
    const payload = buildTelemetryPayload([
      key({ key: 'ph', min: 7, max: 7 }),
      key({ key: 'do', min: 5, max: 5 }),
    ]);
    expect(payload).toEqual({ ph: 7, do: 5 });
  });
});
  • Step 3: Run test to verify it fails

Run: cd tools/tb-simulator && bun run test -- telemetry
Expected: FAIL - cannot resolve @/shared/telemetry.

  • Step 4: Implement shared/telemetry.ts
import type { TelemetryKey } from '@/shared/types';

export function generateTelemetryValue(key: TelemetryKey): number {
  const raw = key.min + Math.random() * (key.max - key.min);
  if (key.type === 'int') return Math.round(raw);
  const factor = 10 ** key.precision;
  return Math.round(raw * factor) / factor;
}

export function buildTelemetryPayload(keys: TelemetryKey[]): Record<string, number> {
  const payload: Record<string, number> = {};
  for (const key of keys) {
    payload[key.key] = generateTelemetryValue(key);
  }
  return payload;
}
  • Step 5: Run test to verify it passes

Run: cd tools/tb-simulator && bun run test -- telemetry
Expected: PASS (5 tests).

  • Step 6: Commit
git add tools/tb-simulator/shared
git commit -m "feat(simulator): add shared types and telemetry value generator (#201)"

Task 3: RPC topic helpers

Files:

  • Create: tools/tb-simulator/shared/rpc.ts

  • Test: tools/tb-simulator/shared/rpc.test.ts

  • Step 1: Write the failing test shared/rpc.test.ts

import { describe, expect, it } from 'vitest';
import {
  ATTRIBUTES_TOPIC,
  RPC_REQUEST_SUB,
  TELEMETRY_TOPIC,
  clientRpcTopic,
  parseRpcRequestId,
  rpcResponseTopic,
} from '@/shared/rpc';

describe('rpc topic helpers', () => {
  it('exposes the ThingsBoard device topics', () => {
    expect(TELEMETRY_TOPIC).toBe('v1/devices/me/telemetry');
    expect(ATTRIBUTES_TOPIC).toBe('v1/devices/me/attributes');
    expect(RPC_REQUEST_SUB).toBe('v1/devices/me/rpc/request/+');
  });

  it('parses the request id from an rpc request topic', () => {
    expect(parseRpcRequestId('v1/devices/me/rpc/request/42')).toBe('42');
  });

  it('returns null for a non-rpc-request topic', () => {
    expect(parseRpcRequestId('v1/devices/me/telemetry')).toBeNull();
  });

  it('builds the matching response topic', () => {
    expect(rpcResponseTopic('42')).toBe('v1/devices/me/rpc/response/42');
  });

  it('builds a client-side rpc request topic', () => {
    expect(clientRpcTopic('7')).toBe('v1/devices/me/rpc/request/7');
  });
});
  • Step 2: Run test to verify it fails

Run: cd tools/tb-simulator && bun run test -- rpc
Expected: FAIL - cannot resolve @/shared/rpc.

  • Step 3: Implement shared/rpc.ts
export const TELEMETRY_TOPIC = 'v1/devices/me/telemetry';
export const ATTRIBUTES_TOPIC = 'v1/devices/me/attributes';
export const RPC_REQUEST_SUB = 'v1/devices/me/rpc/request/+';

const RPC_REQUEST_RE = /^v1\/devices\/me\/rpc\/request\/(.+)$/;

export function parseRpcRequestId(topic: string): string | null {
  const match = RPC_REQUEST_RE.exec(topic);
  return match ? match[1] : null;
}

export function rpcResponseTopic(id: string): string {
  return `v1/devices/me/rpc/response/${id}`;
}

export function clientRpcTopic(id: string): string {
  return `v1/devices/me/rpc/request/${id}`;
}
  • Step 4: Run test to verify it passes

Run: cd tools/tb-simulator && bun run test -- rpc
Expected: PASS (5 tests).

  • Step 5: Commit
git add tools/tb-simulator/shared/rpc.ts tools/tb-simulator/shared/rpc.test.ts
git commit -m "feat(simulator): add ThingsBoard rpc topic helpers (#201)"

Task 4: Persistence

Files:

  • Create: tools/tb-simulator/server/persistence.ts

  • Test: tools/tb-simulator/server/persistence.test.ts

  • Step 1: Write the failing test server/persistence.test.ts

import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { SimulatorData } from '@/shared/types';
import { DEFAULT_DATA, loadData, saveData } from '@/server/persistence';

let dir: string;
let file: string;

beforeEach(() => {
  dir = mkdtempSync(join(tmpdir(), 'tb-sim-'));
  file = join(dir, 'simulator-data.json');
});

afterEach(() => {
  rmSync(dir, { recursive: true, force: true });
});

describe('persistence', () => {
  it('returns default data when the file is missing', () => {
    expect(loadData(file)).toEqual(DEFAULT_DATA);
  });

  it('round-trips saved data', () => {
    const data: SimulatorData = {
      broker: { host: 'tb.example.com', port: 8883 },
      devices: [
        {
          id: 'a',
          name: 'Pond A',
          token: 'TОKEN',
          controlEnabled: true,
          autoReplyBody: { success: true },
          intervalMs: 5000,
          telemetryKeys: [{ key: 'temperature', min: 27, max: 30, type: 'float', precision: 1 }],
        },
      ],
    };
    saveData(file, data);
    expect(loadData(file)).toEqual(data);
  });

  it('does not mutate DEFAULT_DATA across calls', () => {
    const a = loadData(file);
    a.devices.push({
      id: 'x',
      name: 'x',
      token: 'x',
      controlEnabled: false,
      autoReplyBody: {},
      intervalMs: 1000,
      telemetryKeys: [],
    });
    expect(loadData(file).devices).toHaveLength(0);
  });
});
  • Step 2: Run test to verify it fails

Run: cd tools/tb-simulator && bun run test -- persistence
Expected: FAIL - cannot resolve @/server/persistence.

  • Step 3: Implement server/persistence.ts
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import type { SimulatorData } from '@/shared/types';

export const DEFAULT_DATA: SimulatorData = {
  broker: { host: 'localhost', port: 1883 },
  devices: [],
};

export function loadData(path: string): SimulatorData {
  if (!existsSync(path)) return structuredClone(DEFAULT_DATA);
  const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<SimulatorData>;
  return {
    broker: parsed.broker ?? structuredClone(DEFAULT_DATA.broker),
    devices: parsed.devices ?? [],
  };
}

export function saveData(path: string, data: SimulatorData): void {
  writeFileSync(path, JSON.stringify(data, null, 2), 'utf8');
}
  • Step 4: Run test to verify it passes

Run: cd tools/tb-simulator && bun run test -- persistence
Expected: PASS (3 tests).

  • Step 5: Commit
git add tools/tb-simulator/server/persistence.ts tools/tb-simulator/server/persistence.test.ts
git commit -m "feat(simulator): add local json persistence (#201)"

Task 5: DeviceSession (MQTT lifecycle + telemetry + RPC auto-reply)

Files:

  • Create: tools/tb-simulator/server/device-session.ts
  • Test: tools/tb-simulator/server/device-session.test.ts

Design: DeviceSession never imports mqtt directly. It receives a MqttFactory that returns a SimMqttClient. The real factory (Task 7) wraps mqtt.js; tests inject a fake.

  • Step 1: Write the failing test server/device-session.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { BrokerSettings, ConnectionStatus, DeviceConfig, LogEntry } from '@/shared/types';
import {
  DeviceSession,
  type MessageHandler,
  type MqttFactory,
  type SimMqttClient,
} from '@/server/device-session';

class FakeClient implements SimMqttClient {
  published: Array<{ topic: string; payload: string }> = [];
  subscribed: string[] = [];
  private handlers: { connect?: () => void; message?: MessageHandler } = {};
  publish(topic: string, payload: string): void {
    this.published.push({ topic, payload });
  }
  subscribe(topic: string): void {
    this.subscribed.push(topic);
  }
  end(_force: boolean, cb: () => void): void {
    cb();
  }
  on(event: 'connect' | 'error' | 'close' | 'message', cb: (...args: never[]) => void): void {
    if (event === 'connect') this.handlers.connect = cb as () => void;
    if (event === 'message') this.handlers.message = cb as MessageHandler;
  }
  emitConnect(): void {
    this.handlers.connect?.();
  }
  emitMessage(topic: string, payload: string): void {
    this.handlers.message?.(topic, new TextEncoder().encode(payload));
  }
}

const broker: BrokerSettings = { host: 'localhost', port: 1883 };

const config = (over: Partial<DeviceConfig> = {}): DeviceConfig => ({
  id: 'd1',
  name: 'Device 1',
  token: 'TOKEN',
  controlEnabled: false,
  autoReplyBody: { success: true },
  intervalMs: 1000,
  telemetryKeys: [{ key: 'temperature', min: 27, max: 27, type: 'float', precision: 0 }],
  ...over,
});

function makeSession(cfg: DeviceConfig) {
  const client = new FakeClient();
  const factory: MqttFactory = () => client;
  const statuses: Array<{ status: ConnectionStatus; error?: string }> = [];
  const logs: LogEntry[] = [];
  const running: boolean[] = [];
  const session = new DeviceSession(cfg, broker, factory, {
    onStatus: (status, error) => statuses.push({ status, error }),
    onLog: (entry) => logs.push(entry),
    onTelemetryRunning: (r) => running.push(r),
  });
  return { client, session, statuses, logs, running };
}

describe('DeviceSession', () => {
  beforeEach(() => vi.useRealTimers());

  it('subscribes to rpc requests only when control is enabled', () => {
    const off = makeSession(config({ controlEnabled: false }));
    off.session.connect();
    off.client.emitConnect();
    expect(off.client.subscribed).toEqual([]);

    const on = makeSession(config({ controlEnabled: true }));
    on.session.connect();
    on.client.emitConnect();
    expect(on.client.subscribed).toEqual(['v1/devices/me/rpc/request/+']);
  });

  it('reports connecting then connected', () => {
    const s = makeSession(config());
    s.session.connect();
    s.client.emitConnect();
    expect(s.statuses.map((x) => x.status)).toEqual(['connecting', 'connected']);
  });

  it('auto-replies to a server-side rpc with the configured body', () => {
    const s = makeSession(config({ controlEnabled: true, autoReplyBody: { ok: 1 } }));
    s.session.connect();
    s.client.emitConnect();
    s.client.emitMessage('v1/devices/me/rpc/request/99', '{"method":"setGPIO"}');
    expect(s.client.published).toContainEqual({
      topic: 'v1/devices/me/rpc/response/99',
      payload: '{"ok":1}',
    });
    expect(s.logs.filter((l) => l.direction === 'in')).toHaveLength(1);
    expect(s.logs.filter((l) => l.direction === 'out')).toHaveLength(1);
  });

  it('publishes telemetry immediately and on the interval', () => {
    vi.useFakeTimers();
    const s = makeSession(config({ intervalMs: 1000 }));
    s.session.connect();
    s.client.emitConnect();
    s.session.startTelemetry();
    expect(s.client.published).toHaveLength(1);
    vi.advanceTimersByTime(2000);
    expect(s.client.published).toHaveLength(3);
    expect(s.client.published[0]).toEqual({
      topic: 'v1/devices/me/telemetry',
      payload: '{"temperature":27}',
    });
    expect(s.running).toContain(true);
    s.session.stopTelemetry();
    expect(s.running).toContain(false);
  });
});
  • Step 2: Run test to verify it fails

Run: cd tools/tb-simulator && bun run test -- device-session
Expected: FAIL - cannot resolve @/server/device-session.

  • Step 3: Implement server/device-session.ts
import type { BrokerSettings, ConnectionStatus, DeviceConfig, LogEntry } from '@/shared/types';
import {
  ATTRIBUTES_TOPIC,
  RPC_REQUEST_SUB,
  TELEMETRY_TOPIC,
  clientRpcTopic,
  parseRpcRequestId,
  rpcResponseTopic,
} from '@/shared/rpc';
import { buildTelemetryPayload } from '@/shared/telemetry';

export type MessageHandler = (topic: string, payload: Uint8Array) => void;

export interface SimMqttClient {
  publish(topic: string, payload: string): void;
  subscribe(topic: string): void;
  end(force: boolean, cb: () => void): void;
  on(event: 'connect', cb: () => void): void;
  on(event: 'error', cb: (err: Error) => void): void;
  on(event: 'close', cb: () => void): void;
  on(event: 'message', cb: MessageHandler): void;
}

export type MqttFactory = (broker: BrokerSettings, token: string) => SimMqttClient;

export interface SessionCallbacks {
  onStatus: (status: ConnectionStatus, error?: string) => void;
  onLog: (entry: LogEntry) => void;
  onTelemetryRunning: (running: boolean) => void;
}

export type ManualPublishKind = 'attributes' | 'clientRpc';

export class DeviceSession {
  private client: SimMqttClient | null = null;
  private telemetryTimer: ReturnType<typeof setInterval> | null = null;

  constructor(
    private config: DeviceConfig,
    private broker: BrokerSettings,
    private readonly factory: MqttFactory,
    private readonly cb: SessionCallbacks,
  ) {}

  connect(): void {
    this.cb.onStatus('connecting');
    const client = this.factory(this.broker, this.config.token);
    this.client = client;
    client.on('connect', () => {
      this.cb.onStatus('connected');
      if (this.config.controlEnabled) client.subscribe(RPC_REQUEST_SUB);
    });
    client.on('error', (err) => this.cb.onStatus('error', err.message));
    client.on('close', () => this.cb.onStatus('disconnected'));
    client.on('message', (topic, payload) => this.handleMessage(topic, payload));
  }

  private handleMessage(topic: string, payload: Uint8Array): void {
    const text = new TextDecoder().decode(payload);
    this.log('in', topic, text);
    const id = parseRpcRequestId(topic);
    if (id === null || !this.client) return;
    const responseTopic = rpcResponseTopic(id);
    const body = JSON.stringify(this.config.autoReplyBody);
    this.client.publish(responseTopic, body);
    this.log('out', responseTopic, body);
  }

  publishOnce(): void {
    if (!this.client) return;
    const body = JSON.stringify(buildTelemetryPayload(this.config.telemetryKeys));
    this.client.publish(TELEMETRY_TOPIC, body);
    this.log('out', TELEMETRY_TOPIC, body);
  }

  startTelemetry(): void {
    if (this.telemetryTimer) return;
    this.publishOnce();
    this.telemetryTimer = setInterval(() => this.publishOnce(), this.config.intervalMs);
    this.cb.onTelemetryRunning(true);
  }

  stopTelemetry(): void {
    if (this.telemetryTimer) {
      clearInterval(this.telemetryTimer);
      this.telemetryTimer = null;
    }
    this.cb.onTelemetryRunning(false);
  }

  manualPublish(kind: ManualPublishKind, payload: Record<string, unknown>): void {
    if (!this.client) return;
    const topic = kind === 'attributes' ? ATTRIBUTES_TOPIC : clientRpcTopic(String(Date.now()));
    const body = JSON.stringify(payload);
    this.client.publish(topic, body);
    this.log('out', topic, body);
  }

  disconnect(): void {
    this.stopTelemetry();
    this.client?.end(true, () => undefined);
    this.client = null;
    this.cb.onStatus('disconnected');
  }

  updateConfig(config: DeviceConfig): void {
    this.config = config;
  }

  private log(direction: 'in' | 'out', topic: string, payload: string): void {
    this.cb.onLog({ ts: Date.now(), direction, topic, payload });
  }
}
  • Step 4: Run test to verify it passes

Run: cd tools/tb-simulator && bun run test -- device-session
Expected: PASS (4 tests).

  • Step 5: Commit
git add tools/tb-simulator/server/device-session.ts tools/tb-simulator/server/device-session.test.ts
git commit -m "feat(simulator): add DeviceSession mqtt lifecycle (#201)"

Task 6: Simulator manager (state + sessions + command handling)

Files:

  • Create: tools/tb-simulator/shared/protocol.ts

  • Create: tools/tb-simulator/server/simulator.ts

  • Test: tools/tb-simulator/server/simulator.test.ts

  • Step 1: Create shared/protocol.ts

import type {
  BrokerSettings,
  ConnectionStatus,
  DeviceConfig,
  LogEntry,
  SimulatorData,
} from '@/shared/types';

export type Command =
  | { type: 'setBroker'; broker: BrokerSettings }
  | { type: 'upsertDevice'; device: DeviceConfig }
  | { type: 'deleteDevice'; id: string }
  | { type: 'connect'; id: string }
  | { type: 'disconnect'; id: string }
  | { type: 'startTelemetry'; id: string }
  | { type: 'stopTelemetry'; id: string }
  | { type: 'publishOnce'; id: string }
  | {
      type: 'manualPublish';
      id: string;
      kind: 'attributes' | 'clientRpc';
      payload: Record<string, unknown>;
    };

export interface RuntimeState {
  data: SimulatorData;
  statuses: Record<string, ConnectionStatus>;
  telemetryRunning: Record<string, boolean>;
}

export type ServerEvent =
  | { type: 'snapshot'; state: RuntimeState }
  | { type: 'dataChanged'; data: SimulatorData }
  | { type: 'status'; id: string; status: ConnectionStatus; error?: string }
  | { type: 'telemetryRunning'; id: string; running: boolean }
  | { type: 'log'; id: string; entry: LogEntry };
  • Step 2: Write the failing test server/simulator.test.ts
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ServerEvent } from '@/shared/protocol';
import type { DeviceConfig } from '@/shared/types';
import type { MqttFactory, SimMqttClient } from '@/server/device-session';
import { Simulator } from '@/server/simulator';

class FakeClient implements SimMqttClient {
  published: Array<{ topic: string; payload: string }> = [];
  private connectCb?: () => void;
  publish(topic: string, payload: string): void {
    this.published.push({ topic, payload });
  }
  subscribe(): void {}
  end(_force: boolean, cb: () => void): void {
    cb();
  }
  on(event: 'connect' | 'error' | 'close' | 'message', cb: (...args: never[]) => void): void {
    if (event === 'connect') this.connectCb = cb as () => void;
  }
  fireConnect(): void {
    this.connectCb?.();
  }
}

const device: DeviceConfig = {
  id: 'd1',
  name: 'Device 1',
  token: 'TOKEN',
  controlEnabled: false,
  autoReplyBody: { success: true },
  intervalMs: 1000,
  telemetryKeys: [],
};

let dir: string;
let file: string;

beforeEach(() => {
  dir = mkdtempSync(join(tmpdir(), 'tb-sim-'));
  file = join(dir, 'simulator-data.json');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));

function makeSimulator() {
  const clients: FakeClient[] = [];
  const factory: MqttFactory = () => {
    const c = new FakeClient();
    clients.push(c);
    return c;
  };
  const events: ServerEvent[] = [];
  const sim = new Simulator(file, factory, (e) => events.push(e));
  return { sim, clients, events };
}

describe('Simulator', () => {
  it('persists an upserted device and emits dataChanged', () => {
    const { sim, events } = makeSimulator();
    sim.handle({ type: 'upsertDevice', device });
    expect(sim.snapshot().data.devices).toHaveLength(1);
    expect(events.some((e) => e.type === 'dataChanged')).toBe(true);

    const reloaded = makeSimulator();
    expect(reloaded.sim.snapshot().data.devices).toHaveLength(1);
  });

  it('connects a device and forwards its status events', () => {
    const { sim, clients, events } = makeSimulator();
    sim.handle({ type: 'upsertDevice', device });
    sim.handle({ type: 'connect', id: 'd1' });
    clients[0].fireConnect();
    const statuses = events.filter((e) => e.type === 'status').map((e) => e.status);
    expect(statuses).toContain('connecting');
    expect(statuses).toContain('connected');
    expect(sim.snapshot().statuses.d1).toBe('connected');
  });

  it('removes a device and disconnects its session', () => {
    const { sim } = makeSimulator();
    sim.handle({ type: 'upsertDevice', device });
    sim.handle({ type: 'connect', id: 'd1' });
    sim.handle({ type: 'deleteDevice', id: 'd1' });
    expect(sim.snapshot().data.devices).toHaveLength(0);
    expect(sim.snapshot().statuses.d1).toBeUndefined();
  });
});
  • Step 3: Run test to verify it fails

Run: cd tools/tb-simulator && bun run test -- simulator
Expected: FAIL - cannot resolve @/server/simulator.

  • Step 4: Implement server/simulator.ts
import type { ServerEvent } from '@/shared/protocol';
import type { Command, RuntimeState } from '@/shared/protocol';
import type { ConnectionStatus, DeviceConfig, SimulatorData } from '@/shared/types';
import { DeviceSession, type MqttFactory } from '@/server/device-session';
import { loadData, saveData } from '@/server/persistence';

type Emit = (event: ServerEvent) => void;

export class Simulator {
  private data: SimulatorData;
  private readonly sessions = new Map<string, DeviceSession>();
  private readonly statuses = new Map<string, ConnectionStatus>();
  private readonly telemetryRunning = new Map<string, boolean>();

  constructor(
    private readonly dataPath: string,
    private readonly factory: MqttFactory,
    private readonly emit: Emit,
  ) {
    this.data = loadData(dataPath);
  }

  snapshot(): RuntimeState {
    return {
      data: this.data,
      statuses: Object.fromEntries(this.statuses),
      telemetryRunning: Object.fromEntries(this.telemetryRunning),
    };
  }

  handle(command: Command): void {
    switch (command.type) {
      case 'setBroker':
        this.data = { ...this.data, broker: command.broker };
        return this.persistAndBroadcast();
      case 'upsertDevice':
        return this.upsertDevice(command.device);
      case 'deleteDevice':
        return this.deleteDevice(command.id);
      case 'connect':
        return this.session(command.id)?.connect();
      case 'disconnect':
        return this.session(command.id)?.disconnect();
      case 'startTelemetry':
        return this.session(command.id)?.startTelemetry();
      case 'stopTelemetry':
        return this.session(command.id)?.stopTelemetry();
      case 'publishOnce':
        return this.session(command.id)?.publishOnce();
      case 'manualPublish':
        return this.session(command.id)?.manualPublish(command.kind, command.payload);
    }
  }

  private upsertDevice(device: DeviceConfig): void {
    const devices = [...this.data.devices];
    const index = devices.findIndex((d) => d.id === device.id);
    if (index === -1) devices.push(device);
    else devices[index] = device;
    this.data = { ...this.data, devices };
    this.sessions.get(device.id)?.updateConfig(device);
    this.persistAndBroadcast();
  }

  private deleteDevice(id: string): void {
    this.sessions.get(id)?.disconnect();
    this.sessions.delete(id);
    this.statuses.delete(id);
    this.telemetryRunning.delete(id);
    this.data = { ...this.data, devices: this.data.devices.filter((d) => d.id !== id) };
    this.persistAndBroadcast();
  }

  private session(id: string): DeviceSession | undefined {
    const existing = this.sessions.get(id);
    if (existing) return existing;
    const config = this.data.devices.find((d) => d.id === id);
    if (!config) return undefined;
    const session = new DeviceSession(config, this.data.broker, this.factory, {
      onStatus: (status, error) => {
        this.statuses.set(id, status);
        this.emit({ type: 'status', id, status, error });
      },
      onLog: (entry) => this.emit({ type: 'log', id, entry }),
      onTelemetryRunning: (running) => {
        this.telemetryRunning.set(id, running);
        this.emit({ type: 'telemetryRunning', id, running });
      },
    });
    this.sessions.set(id, session);
    return session;
  }

  private persistAndBroadcast(): void {
    saveData(this.dataPath, this.data);
    this.emit({ type: 'dataChanged', data: this.data });
  }
}
  • Step 5: Run test to verify it passes

Run: cd tools/tb-simulator && bun run test -- simulator
Expected: PASS (3 tests).

  • Step 6: Commit
git add tools/tb-simulator/shared/protocol.ts tools/tb-simulator/server/simulator.ts tools/tb-simulator/server/simulator.test.ts
git commit -m "feat(simulator): add Simulator manager with command handling (#201)"

Task 7: MQTT adapter, WebSocket server, and entry point

Files:

  • Create: tools/tb-simulator/server/mqtt-adapter.ts
  • Create: tools/tb-simulator/server/ws-server.ts
  • Create: tools/tb-simulator/server/index.ts

These are thin I/O wiring. mqtt-adapter.ts and the socket serving are verified manually against a real broker in Task 11; ws-server.ts gets a message-dispatch unit test.

  • Step 1: Implement server/mqtt-adapter.ts
import mqtt from 'mqtt';
import type { MqttFactory, SimMqttClient } from '@/server/device-session';

export const realMqttFactory: MqttFactory = (broker, token): SimMqttClient => {
  const client = mqtt.connect(`mqtt://${broker.host}:${broker.port}`, {
    username: token,
    password: '',
    reconnectPeriod: 2000,
  });
  return client as unknown as SimMqttClient;
};
  • Step 2: Write the failing test server/ws-server.test.ts
import { describe, expect, it, vi } from 'vitest';
import type { Command } from '@/shared/protocol';
import { parseCommand } from '@/server/ws-server';

describe('parseCommand', () => {
  it('parses a valid command message', () => {
    const cmd = parseCommand(JSON.stringify({ type: 'connect', id: 'd1' } satisfies Command));
    expect(cmd).toEqual({ type: 'connect', id: 'd1' });
  });

  it('returns null for invalid json', () => {
    expect(parseCommand('not json')).toBeNull();
  });

  it('returns null when type is missing', () => {
    expect(parseCommand(JSON.stringify({ id: 'd1' }))).toBeNull();
  });
});
  • Step 3: Run test to verify it fails

Run: cd tools/tb-simulator && bun run test -- ws-server
Expected: FAIL - cannot resolve @/server/ws-server.

  • Step 4: Implement server/ws-server.ts

The bridge emits ServerEvents from Simulator (which was given an emit callback in index.ts). startWsServer registers its own broadcast as one of those emit listeners via options.registerBroadcast, tracks connected sockets, sends a snapshot on open, and dispatches incoming Commands to sim.handle.

import type { Command, ServerEvent } from '@/shared/protocol';
import type { Simulator } from '@/server/simulator';

const KNOWN_TYPES = new Set<Command['type']>([
  'setBroker',
  'upsertDevice',
  'deleteDevice',
  'connect',
  'disconnect',
  'startTelemetry',
  'stopTelemetry',
  'publishOnce',
  'manualPublish',
]);

export function parseCommand(raw: string): Command | null {
  let value: unknown;
  try {
    value = JSON.parse(raw);
  } catch {
    return null;
  }
  if (typeof value !== 'object' || value === null) return null;
  const type = (value as { type?: unknown }).type;
  if (typeof type !== 'string' || !KNOWN_TYPES.has(type as Command['type'])) return null;
  return value as Command;
}

export interface WsServerOptions {
  port: number;
  staticDir: string | null;
  registerBroadcast: (broadcast: (event: ServerEvent) => void) => void;
}

export function startWsServer(sim: Simulator, options: WsServerOptions): void {
  const sockets = new Set<Bun.ServerWebSocket<undefined>>();
  const send = (ws: Bun.ServerWebSocket<undefined>, event: ServerEvent) =>
    ws.send(JSON.stringify(event));

  options.registerBroadcast((event) => {
    for (const ws of sockets) send(ws, event);
  });

  Bun.serve({
    port: options.port,
    async fetch(req, server) {
      const url = new URL(req.url);
      if (url.pathname === '/ws') {
        if (server.upgrade(req)) return undefined;
        return new Response('upgrade failed', { status: 500 });
      }
      if (!options.staticDir) return new Response('dev mode: use vite', { status: 404 });
      const path = url.pathname === '/' ? '/index.html' : url.pathname;
      const file = Bun.file(`${options.staticDir}${path}`);
      if (await file.exists()) return new Response(file);
      return new Response(Bun.file(`${options.staticDir}/index.html`));
    },
    websocket: {
      open(ws) {
        sockets.add(ws);
        send(ws, { type: 'snapshot', state: sim.snapshot() });
      },
      close(ws) {
        sockets.delete(ws);
      },
      message(_ws, raw) {
        const cmd = parseCommand(typeof raw === 'string' ? raw : raw.toString());
        if (cmd) sim.handle(cmd);
      },
    },
  });
}
  • Step 5: Implement server/index.ts

index.ts owns the single emit fan-out: Simulator pushes events into emit, and startWsServer registers its socket-broadcast as a listener. This keeps Simulator transport-agnostic (as unit-tested in Task 6) and ws-server state-agnostic.

import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ServerEvent } from '@/shared/protocol';
import { realMqttFactory } from '@/server/mqtt-adapter';
import { Simulator } from '@/server/simulator';
import { startWsServer } from '@/server/ws-server';

const here = dirname(fileURLToPath(import.meta.url));
const dataPath = join(here, '..', 'simulator-data.json');
const distDir = join(here, '..', 'dist');
const port = Number(process.env.SIM_PORT ?? '5181');

const listeners = new Set<(event: ServerEvent) => void>();
const emit = (event: ServerEvent) => {
  for (const listener of listeners) listener(event);
};

const sim = new Simulator(dataPath, realMqttFactory, emit);
const isStart = process.env.SIM_SERVE_STATIC === '1';

startWsServer(sim, {
  port,
  staticDir: isStart ? distDir : null,
  registerBroadcast: (broadcast) => listeners.add(broadcast),
});

console.log(`[tb-simulator] bridge listening on ws://localhost:${port}/ws`);
if (isStart) {
  const openUrl = `http://localhost:${port}`;
  console.log(`[tb-simulator] GUI at ${openUrl}`);
  Bun.spawn(['open', openUrl]).exited.catch(() => undefined);
}
  • Step 6: Update package.json start script to serve static

Change the start script to:

    "start": "vite build && SIM_SERVE_STATIC=1 bun run server/index.ts",
  • Step 7: Run the ws-server unit test

Run: cd tools/tb-simulator && bun run test -- ws-server
Expected: PASS (3 tests).

  • Step 8: Smoke-test the bridge starts

Run: cd tools/tb-simulator && SIM_PORT=5181 timeout 3 bun run server/index.ts || true
Expected: prints [tb-simulator] bridge listening on ws://localhost:5181/ws and exits after timeout with no crash.

  • Step 9: Commit
git add tools/tb-simulator/server tools/tb-simulator/package.json
git commit -m "feat(simulator): add mqtt adapter, websocket bridge, and entry point (#201)"

Task 8: GUI store + app shell + top bar

Files:

  • Create: tools/tb-simulator/web/store.ts

  • Modify: tools/tb-simulator/web/App.tsx

  • Create: tools/tb-simulator/web/components/TopBar.tsx

  • Step 1: Implement web/store.ts

import { useSyncExternalStore } from 'react';
import type { Command, RuntimeState, ServerEvent } from '@/shared/protocol';
import type { LogEntry } from '@/shared/types';

const EMPTY_STATE: RuntimeState = {
  data: { broker: { host: 'localhost', port: 1883 }, devices: [] },
  statuses: {},
  telemetryRunning: {},
};

// Stable reference so useSyncExternalStore's getSnapshot does not loop for empty logs.
const EMPTY_LOGS: LogEntry[] = [];
const MAX_LOG = 200;

class WsStore {
  private state: RuntimeState = EMPTY_STATE;
  private logs: Record<string, LogEntry[]> = {};
  private socket: WebSocket | null = null;
  private readonly listeners = new Set<() => void>();

  connect(): void {
    if (this.socket) return;
    const socket = new WebSocket(`ws://${location.host}/ws`);
    this.socket = socket;
    socket.onmessage = (ev) => this.onEvent(JSON.parse(ev.data) as ServerEvent);
    socket.onclose = () => {
      this.socket = null;
      setTimeout(() => this.connect(), 1000);
    };
  }

  send(command: Command): void {
    this.socket?.send(JSON.stringify(command));
  }

  getState = (): RuntimeState => this.state;
  getLogs = (id: string): LogEntry[] => this.logs[id] ?? EMPTY_LOGS;

  subscribe = (listener: () => void): (() => void) => {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  };

  private onEvent(event: ServerEvent): void {
    switch (event.type) {
      case 'snapshot':
        this.state = event.state;
        break;
      case 'dataChanged':
        this.state = { ...this.state, data: event.data };
        break;
      case 'status':
        this.state = {
          ...this.state,
          statuses: { ...this.state.statuses, [event.id]: event.status },
        };
        break;
      case 'telemetryRunning':
        this.state = {
          ...this.state,
          telemetryRunning: { ...this.state.telemetryRunning, [event.id]: event.running },
        };
        break;
      case 'log': {
        const next = [...(this.logs[event.id] ?? []), event.entry].slice(-MAX_LOG);
        this.logs = { ...this.logs, [event.id]: next };
        break;
      }
    }
    for (const listener of this.listeners) listener();
  }
}

export const store = new WsStore();
store.connect();

export function useRuntimeState(): RuntimeState {
  return useSyncExternalStore(store.subscribe, store.getState);
}

export function useDeviceLogs(id: string): LogEntry[] {
  return useSyncExternalStore(store.subscribe, () => store.getLogs(id));
}
  • Step 2: Implement web/components/TopBar.tsx
import { Button, InputNumber, Input, Space, Typography } from 'antd';
import { useState } from 'react';
import { store, useRuntimeState } from '@/web/store';

export function TopBar() {
  const { data } = useRuntimeState();
  const [host, setHost] = useState(data.broker.host);
  const [port, setPort] = useState(data.broker.port);

  const saveBroker = () => store.send({ type: 'setBroker', broker: { host, port } });
  const connectAll = () => {
    for (const d of data.devices) store.send({ type: 'connect', id: d.id });
  };
  const disconnectAll = () => {
    for (const d of data.devices) store.send({ type: 'disconnect', id: d.id });
  };

  return (
    <Space style={{ width: '100%', justifyContent: 'space-between', padding: '12px 16px' }}>
      <Typography.Title level={4} style={{ margin: 0 }}>
        ThingsBoard Device Simulator
      </Typography.Title>
      <Space>
        <Input
          addonBefore="Host"
          value={host}
          onChange={(e) => setHost(e.target.value)}
          style={{ width: 220 }}
        />
        <InputNumber
          addonBefore="Port"
          value={port}
          min={1}
          max={65535}
          onChange={(v) => setPort(v ?? 1883)}
        />
        <Button onClick={saveBroker}>Save broker</Button>
        <Button type="primary" onClick={connectAll}>
          Connect all
        </Button>
        <Button onClick={disconnectAll}>Disconnect all</Button>
      </Space>
    </Space>
  );
}
  • Step 3: Implement the web/App.tsx shell
import { App as AntApp, ConfigProvider, Layout } from 'antd';
import { useState } from 'react';
import { DeviceDetail } from '@/web/components/DeviceDetail';
import { DeviceList } from '@/web/components/DeviceList';
import { TopBar } from '@/web/components/TopBar';

export function App() {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  return (
    <ConfigProvider>
      <AntApp>
        <Layout style={{ height: '100vh' }}>
          <Layout.Header style={{ background: '#fff', padding: 0, height: 'auto' }}>
            <TopBar />
          </Layout.Header>
          <Layout>
            <Layout.Sider width={300} theme="light" style={{ overflow: 'auto' }}>
              <DeviceList selectedId={selectedId} onSelect={setSelectedId} />
            </Layout.Sider>
            <Layout.Content style={{ padding: 16, overflow: 'auto' }}>
              {selectedId ? (
                <DeviceDetail deviceId={selectedId} />
              ) : (
                <div style={{ color: '#999' }}>Select or create a device</div>
              )}
            </Layout.Content>
          </Layout>
        </Layout>
      </AntApp>
    </ConfigProvider>
  );
}
  • Step 4: Verify the GUI compiles

Run: cd tools/tb-simulator && bunx tsc --noEmit
Expected: fails only with “Cannot find module ‘@/web/components/DeviceList’” and “DeviceDetail” (created in Tasks 9-10). No other type errors. (If you prefer a green check now, temporarily stub those two components; they are implemented next.)

  • Step 5: Commit
git add tools/tb-simulator/web
git commit -m "feat(simulator): add gui store, app shell, and top bar (#201)"

Task 9: Device list (create / select / delete)

Files:

  • Create: tools/tb-simulator/web/components/DeviceList.tsx

  • Step 1: Implement web/components/DeviceList.tsx

import { Badge, Button, Card, Space } from 'antd';
import type { ConnectionStatus } from '@/shared/types';
import { store, useRuntimeState } from '@/web/store';

const STATUS_COLOR: Record<ConnectionStatus, string> = {
  disconnected: 'default',
  connecting: 'processing',
  connected: 'success',
  error: 'error',
};

interface Props {
  selectedId: string | null;
  onSelect: (id: string) => void;
}

export function DeviceList({ selectedId, onSelect }: Props) {
  const { data, statuses, telemetryRunning } = useRuntimeState();

  const addDevice = () => {
    const id = crypto.randomUUID();
    store.send({
      type: 'upsertDevice',
      device: {
        id,
        name: `Device ${data.devices.length + 1}`,
        token: '',
        controlEnabled: false,
        autoReplyBody: { success: true },
        intervalMs: 5000,
        telemetryKeys: [],
      },
    });
    onSelect(id);
  };

  return (
    <div style={{ padding: 12 }}>
      <Button type="dashed" block onClick={addDevice} style={{ marginBottom: 12 }}>
        + Add device
      </Button>
      <Space direction="vertical" style={{ width: '100%' }}>
        {data.devices.map((d) => (
          <Card
            key={d.id}
            size="small"
            hoverable
            onClick={() => onSelect(d.id)}
            style={{ borderColor: d.id === selectedId ? '#1677ff' : undefined }}
          >
            <Space style={{ width: '100%', justifyContent: 'space-between' }}>
              <Space direction="vertical" size={0}>
                <Badge
                  status={
                    STATUS_COLOR[statuses[d.id] ?? 'disconnected'] as
                      | 'default'
                      | 'processing'
                      | 'success'
                      | 'error'
                  }
                  text={d.name}
                />
                <span style={{ fontSize: 12, color: '#999' }}>
                  {telemetryRunning[d.id] ? 'telemetry running' : 'idle'}
                  {d.controlEnabled ? ' - control on' : ''}
                </span>
              </Space>
              <Button
                size="small"
                danger
                onClick={(e) => {
                  e.stopPropagation();
                  store.send({ type: 'deleteDevice', id: d.id });
                }}
              >
                Delete
              </Button>
            </Space>
          </Card>
        ))}
      </Space>
    </div>
  );
}
  • Step 2: Verify it compiles

Run: cd tools/tb-simulator && bunx tsc --noEmit
Expected: fails only on missing @/web/components/DeviceDetail (Task 10). No other errors.

  • Step 3: Commit
git add tools/tb-simulator/web/components/DeviceList.tsx
git commit -m "feat(simulator): add device list with add/select/delete (#201)"

Task 10: Device detail tabs (connection, telemetry, control)

Files:

  • Create: tools/tb-simulator/web/components/DeviceDetail.tsx

  • Create: tools/tb-simulator/web/components/ConnectionTab.tsx

  • Create: tools/tb-simulator/web/components/TelemetryTab.tsx

  • Create: tools/tb-simulator/web/components/ControlTab.tsx

  • Create: tools/tb-simulator/test/setup.ts

  • Test: tools/tb-simulator/web/components/TelemetryTab.test.tsx

  • Step 1: Implement web/components/DeviceDetail.tsx

import { Tabs } from 'antd';
import { ConnectionTab } from '@/web/components/ConnectionTab';
import { ControlTab } from '@/web/components/ControlTab';
import { TelemetryTab } from '@/web/components/TelemetryTab';
import { useRuntimeState } from '@/web/store';

interface Props {
  deviceId: string;
}

export function DeviceDetail({ deviceId }: Props) {
  const { data } = useRuntimeState();
  const device = data.devices.find((d) => d.id === deviceId);
  if (!device) return <div>Device not found</div>;

  const items = [
    { key: 'connection', label: 'Connection', children: <ConnectionTab device={device} /> },
    { key: 'telemetry', label: 'Telemetry', children: <TelemetryTab device={device} /> },
  ];
  if (device.controlEnabled) {
    items.push({ key: 'control', label: 'Control', children: <ControlTab device={device} /> });
  }
  return <Tabs items={items} />;
}
  • Step 2: Implement web/components/ConnectionTab.tsx
import { Button, Form, Input, Space, Switch, Tag } from 'antd';
import type { DeviceConfig } from '@/shared/types';
import { store, useRuntimeState } from '@/web/store';

interface Props {
  device: DeviceConfig;
}

export function ConnectionTab({ device }: Props) {
  const { statuses } = useRuntimeState();
  const status = statuses[device.id] ?? 'disconnected';
  const update = (patch: Partial<DeviceConfig>) =>
    store.send({ type: 'upsertDevice', device: { ...device, ...patch } });

  return (
    <Form layout="vertical" style={{ maxWidth: 480 }}>
      <Form.Item label="Name">
        <Input value={device.name} onChange={(e) => update({ name: e.target.value })} />
      </Form.Item>
      <Form.Item label="Access token" help="Paste the device token from ThingsBoard">
        <Input value={device.token} onChange={(e) => update({ token: e.target.value })} />
      </Form.Item>
      <Form.Item label="Support control (server-side RPC)">
        <Switch
          checked={device.controlEnabled}
          onChange={(checked) => update({ controlEnabled: checked })}
        />
      </Form.Item>
      <Space>
        <Tag color={status === 'connected' ? 'green' : status === 'error' ? 'red' : 'default'}>
          {status}
        </Tag>
        <Button type="primary" onClick={() => store.send({ type: 'connect', id: device.id })}>
          Connect
        </Button>
        <Button onClick={() => store.send({ type: 'disconnect', id: device.id })}>Disconnect</Button>
      </Space>
    </Form>
  );
}
  • Step 3: Write the failing test web/components/TelemetryTab.test.tsx
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { DeviceConfig } from '@/shared/types';

const sent: unknown[] = [];
vi.mock('@/web/store', () => ({
  store: { send: (cmd: unknown) => sent.push(cmd) },
  useRuntimeState: () => ({
    data: { broker: { host: 'localhost', port: 1883 }, devices: [] },
    statuses: {},
    telemetryRunning: {},
  }),
}));

import { TelemetryTab } from '@/web/components/TelemetryTab';

const device: DeviceConfig = {
  id: 'd1',
  name: 'Device 1',
  token: 'T',
  controlEnabled: false,
  autoReplyBody: {},
  intervalMs: 5000,
  telemetryKeys: [],
};

describe('TelemetryTab', () => {
  it('sends an upsert with one more telemetry key when Add key is clicked', () => {
    sent.length = 0;
    render(<TelemetryTab device={device} />);
    fireEvent.click(screen.getByText('+ Add key'));
    const last = sent.at(-1) as { type: string; device: DeviceConfig };
    expect(last.type).toBe('upsertDevice');
    expect(last.device.telemetryKeys).toHaveLength(1);
  });
});
  • Step 4: Create test/setup.ts and register it in vite.config.ts

test/setup.ts:

import '@testing-library/jest-dom/vitest';

In vite.config.ts, change the test block so component tests use jsdom and the setup file:

  test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./test/setup.ts'],
    environmentMatchGlobs: [['web/**', 'jsdom']],
  },
  • Step 5: Run test to verify it fails

Run: cd tools/tb-simulator && bun run test -- TelemetryTab
Expected: FAIL - cannot resolve @/web/components/TelemetryTab.

  • Step 6: Implement web/components/TelemetryTab.tsx
import { Button, InputNumber, Input, Select, Space, Table } from 'antd';
import type { DeviceConfig, TelemetryKey } from '@/shared/types';
import { store, useRuntimeState } from '@/web/store';

interface Props {
  device: DeviceConfig;
}

export function TelemetryTab({ device }: Props) {
  const { telemetryRunning } = useRuntimeState();
  const running = telemetryRunning[device.id] ?? false;

  const update = (patch: Partial<DeviceConfig>) =>
    store.send({ type: 'upsertDevice', device: { ...device, ...patch } });

  const updateKey = (index: number, patch: Partial<TelemetryKey>) => {
    const telemetryKeys = device.telemetryKeys.map((k, i) => (i === index ? { ...k, ...patch } : k));
    update({ telemetryKeys });
  };

  const addKey = () =>
    update({
      telemetryKeys: [
        ...device.telemetryKeys,
        { key: `key${device.telemetryKeys.length + 1}`, min: 0, max: 100, type: 'float', precision: 2 },
      ],
    });

  const removeKey = (index: number) =>
    update({ telemetryKeys: device.telemetryKeys.filter((_, i) => i !== index) });

  const columns = [
    {
      title: 'Key',
      render: (_: unknown, _r: TelemetryKey, i: number) => (
        <Input value={device.telemetryKeys[i].key} onChange={(e) => updateKey(i, { key: e.target.value })} />
      ),
    },
    {
      title: 'Min',
      render: (_: unknown, _r: TelemetryKey, i: number) => (
        <InputNumber value={device.telemetryKeys[i].min} onChange={(v) => updateKey(i, { min: v ?? 0 })} />
      ),
    },
    {
      title: 'Max',
      render: (_: unknown, _r: TelemetryKey, i: number) => (
        <InputNumber value={device.telemetryKeys[i].max} onChange={(v) => updateKey(i, { max: v ?? 0 })} />
      ),
    },
    {
      title: 'Type',
      render: (_: unknown, _r: TelemetryKey, i: number) => (
        <Select
          value={device.telemetryKeys[i].type}
          style={{ width: 90 }}
          options={[
            { value: 'float', label: 'float' },
            { value: 'int', label: 'int' },
          ]}
          onChange={(v) => updateKey(i, { type: v })}
        />
      ),
    },
    {
      title: 'Precision',
      render: (_: unknown, _r: TelemetryKey, i: number) => (
        <InputNumber
          min={0}
          max={6}
          value={device.telemetryKeys[i].precision}
          onChange={(v) => updateKey(i, { precision: v ?? 0 })}
        />
      ),
    },
    {
      title: '',
      render: (_: unknown, _r: TelemetryKey, i: number) => (
        <Button size="small" danger onClick={() => removeKey(i)}>
          Remove
        </Button>
      ),
    },
  ];

  return (
    <Space direction="vertical" style={{ width: '100%' }}>
      <Space>
        <span>Interval (ms):</span>
        <InputNumber
          min={100}
          value={device.intervalMs}
          onChange={(v) => update({ intervalMs: v ?? 1000 })}
        />
        <Button onClick={addKey}>+ Add key</Button>
      </Space>
      <Table
        rowKey={(_, i) => String(i)}
        dataSource={device.telemetryKeys}
        columns={columns}
        pagination={false}
        size="small"
      />
      <Space>
        {running ? (
          <Button danger onClick={() => store.send({ type: 'stopTelemetry', id: device.id })}>
            Stop publishing
          </Button>
        ) : (
          <Button type="primary" onClick={() => store.send({ type: 'startTelemetry', id: device.id })}>
            Start publishing
          </Button>
        )}
        <Button onClick={() => store.send({ type: 'publishOnce', id: device.id })}>Publish once</Button>
      </Space>
    </Space>
  );
}
  • Step 7: Run test to verify it passes

Run: cd tools/tb-simulator && bun run test -- TelemetryTab
Expected: PASS (1 test).

  • Step 8: Implement web/components/ControlTab.tsx
import { Button, Card, Input, Space, Tag } from 'antd';
import { useState } from 'react';
import type { DeviceConfig } from '@/shared/types';
import { store, useDeviceLogs } from '@/web/store';

interface Props {
  device: DeviceConfig;
}

export function ControlTab({ device }: Props) {
  const logs = useDeviceLogs(device.id);
  const [autoReply, setAutoReply] = useState(JSON.stringify(device.autoReplyBody));
  const [manual, setManual] = useState('{"status":"ok"}');

  const saveAutoReply = () => {
    try {
      store.send({
        type: 'upsertDevice',
        device: { ...device, autoReplyBody: JSON.parse(autoReply) as Record<string, unknown> },
      });
    } catch {
      // ignore invalid json; the field shows the raw text
    }
  };

  const publishManual = (kind: 'attributes' | 'clientRpc') => {
    try {
      store.send({ type: 'manualPublish', id: device.id, kind, payload: JSON.parse(manual) });
    } catch {
      // ignore invalid json
    }
  };

  return (
    <Space direction="vertical" style={{ width: '100%' }}>
      <Card size="small" title="Auto-reply body (for incoming server-side RPC)">
        <Space.Compact style={{ width: '100%' }}>
          <Input value={autoReply} onChange={(e) => setAutoReply(e.target.value)} />
          <Button onClick={saveAutoReply}>Save</Button>
        </Space.Compact>
      </Card>

      <Card size="small" title="Manual publish (JSON)">
        <Space.Compact style={{ width: '100%' }}>
          <Input value={manual} onChange={(e) => setManual(e.target.value)} />
          <Button onClick={() => publishManual('attributes')}>Publish attributes</Button>
          <Button onClick={() => publishManual('clientRpc')}>Send client RPC</Button>
        </Space.Compact>
      </Card>

      <Card size="small" title="Live MQTT log">
        <div style={{ maxHeight: 320, overflow: 'auto', fontFamily: 'monospace', fontSize: 12 }}>
          {logs.length === 0 ? (
            <span style={{ color: '#999' }}>No messages yet</span>
          ) : (
            logs.map((l, i) => (
              <div key={`${l.ts}-${i}`}>
                <Tag color={l.direction === 'in' ? 'blue' : 'green'}>{l.direction}</Tag>
                <span>{l.topic}</span> <span style={{ color: '#666' }}>{l.payload}</span>
              </div>
            ))
          )}
        </div>
      </Card>
    </Space>
  );
}
  • Step 9: Verify full GUI compiles and all tests pass

Run:

cd tools/tb-simulator && bunx tsc --noEmit && bun run test

Expected: no type errors; all Vitest tests pass.

  • Step 10: Commit
git add tools/tb-simulator/web tools/tb-simulator/test tools/tb-simulator/vite.config.ts
git commit -m "feat(simulator): add device detail tabs and telemetry key editor (#201)"

Task 11: README, lint, and end-to-end verification against a real broker

Files:

  • Create: tools/tb-simulator/README.md

  • Step 1: Write README.md

# tb-simulator

A standalone local GUI to simulate ThingsBoard devices over real MQTT: publish
randomized telemetry and (optionally) auto-reply to server-side RPC.

Not connected to the HMP backend/frontend. Data is stored locally in
`simulator-data.json` (git-ignored), including device access tokens in plaintext -
keep it local.

## Run

```bash
cd tools/tb-simulator
bun install
bun run start      # builds the GUI, serves it + the MQTT bridge, opens the browser
```

For developing the tool itself:

```bash
bun run dev        # Vite dev server (GUI) + bridge with hot reload
```

## Use

1. Set the broker host/port in the top bar (default `localhost:1883`) and Save.
2. Add a device, paste its ThingsBoard access token, Connect.
3. Telemetry tab: add keys with min/max ranges + interval, Start publishing.
4. Toggle "Support control" to subscribe to server-side RPC; the Control tab shows a
   live log and lets you set the auto-reply body and manually publish.

## MQTT contract (ThingsBoard device API)

- Auth: MQTT username = access token, empty password.
- Telemetry: `v1/devices/me/telemetry`
- Server RPC in: `v1/devices/me/rpc/request/+`; reply out: `v1/devices/me/rpc/response/{id}`
- Attributes: `v1/devices/me/attributes`

## Test

```bash
bun run test       # Vitest (never `bun test`)
```
  • Step 2: Run Biome from the repo root

Run: cd /Users/hieunguyen/github/hieunvce/iot_platform && bun run check
Expected: no errors on tools/tb-simulator. Fix any reported issues (e.g. import ordering, useImportType).

  • Step 3: Run the full test suite

Run: cd tools/tb-simulator && bun run test
Expected: all tests pass (telemetry, rpc, persistence, device-session, simulator, ws-server, TelemetryTab).

  • Step 4: End-to-end verification against a real ThingsBoard

Manual (documented, not automated). Requires a reachable ThingsBoard broker and a device token.

  1. bun run start.
  2. Set broker host/port; add a device with a real token; Connect - status turns connected.
  3. Add telemetry keys (e.g. temperature 27-30 float, ph 6.8-8.0 float), Start publishing.
  4. In ThingsBoard, open the device’s Latest Telemetry - confirm values arrive and update on the interval.
  5. Toggle Support control; from ThingsBoard issue a server-side RPC to the device - confirm the Control tab logs the request and the configured auto-reply, and ThingsBoard receives the response.
  6. Record the results (pass/fail per step) in the PR description.
  • Step 5: Commit
git add tools/tb-simulator/README.md
git commit -m "docs(simulator): add tb-simulator readme (#201)"
  • Step 6: Open the PR
bunx -y gh-axi pr create --base master --head issue-201 \
  --title "Standalone ThingsBoard device simulator tool" \
  --body "Implements #201. Standalone tools/tb-simulator: React + Ant Design GUI + Bun MQTT bridge. See docs/superpowers/specs/2026-07-12-thingsboard-device-simulator-design.md. Include the Task 11 Step 4 manual E2E results here."

Notes on decisions deferred to the implementer

  • Bun WebSocket types: Bun.ServerWebSocket / Bun.serve typings come from bun-types. If not present, add bun-types to devDependencies and "types": ["bun-types", ...] in tsconfig.json. Do not import from bun for values.
  • antd v6 API: components used (Layout, Card, Form, Input, InputNumber, Select, Switch, Table, Tabs, Tag, Badge, Space, Button, ConfigProvider, App) are all available in v6. If any prop name changed, follow the v6 signature rather than downgrading.
  • @/ alias: every import in this tool uses @/.... There are no relative ../ imports.