Thingsboard Integration 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: Integrate Thingsboard API into the frontend with backend TB credentials endpoint, enabling device CRUD, RPC commands, attribute read/write, and telemetry reading.

Architecture: Backend exposes a NestJS module that returns per-user Thingsboard credentials. Frontend uses a Zustand store to persist TB auth state, a singleton ThingsboardService for TB API calls, and TBDevice class instances for device-specific operations. useThingsboardAuth hook auto-connects on backend login.

Tech Stack: NestJS, Zustand, Axios, React hooks


File Structure

backend/src/
├── thingsboard/
│   ├── thingsboard.controller.ts    # GET /thingsboard/credentials
│   ├── thingsboard.service.ts       # Fetch TB credentials per user
│   ├── thingsboard.module.ts        # Module + route registration
│   └── dto/
│       └── tb-credentials.dto.ts     # Response DTO

frontend/src/
├── stores/
│   └── thingsboard.store.ts         # Zustand store (persist)
├── services/
│   └── thingsboard/
│       ├── index.ts                  # Barrel export
│       ├── thingsboard.service.ts   # Singleton (init, login, logout, getClient)
│       └── tb-device.class.ts        # TBDevice class
└── hooks/
    └── useThingsboardAuth.ts         # Auto-login hook

Task 1: Backend — Thingsboard Module

Files:

  • Create: backend/src/thingsboard/thingsboard.module.ts
  • Create: backend/src/thingsboard/thingsboard.controller.ts
  • Create: backend/src/thingsboard/thingsboard.service.ts
  • Create: backend/src/thingsboard/dto/tb-credentials.dto.ts
  • Modify: backend/src/app.module.ts — add ThingsboardModule import

Steps

  • Step 1: Create DTO
// backend/src/thingsboard/dto/tb-credentials.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';

export class TbCredentialsDto {
  @IsString()
  @IsNotEmpty()
  thingsboardUrl: string;

  @IsString()
  @IsNotEmpty()
  username: string;

  @IsString()
  @IsNotEmpty()
  password: string;
}
  • Step 2: Create Service
// backend/src/thingsboard/thingsboard.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '@/users/users.service';
import { TbCredentialsDto } from './dto/tb-credentials.dto';

@Injectable()
export class ThingsboardService {
  constructor(
    private readonly configService: ConfigService,
    private readonly usersService: UsersService,
  ) {}

  async getCredentials(userId: string): Promise<TbCredentialsDto> {
    const user = await this.usersService.findById(userId);

    const thingsboardUrl =
      this.configService.get<string>('TB_API_URL') ||
      'http://localhost:9090/api/v1';

    return {
      thingsboardUrl,
      username: user.thingsboardUsername,
      password: user.thingsboardPassword,
    };
  }
}
  • Step 3: Create Controller
// backend/src/thingsboard/thingsboard.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '@/auth/guards/jwt-auth.guard';
import { CurrentUser } from '@/auth/decorators/current-user.decorator';
import { ThingsboardService } from './thingsboard.service';
import { TbCredentialsDto } from './dto/tb-credentials.dto';

@Controller('thingsboard')
@UseGuards(JwtAuthGuard)
export class ThingsboardController {
  constructor(private readonly thingsboardService: ThingsboardService) {}

  @Get('credentials')
  async getCredentials(
    @CurrentUser('id') userId: string,
  ): Promise<TbCredentialsDto> {
    return this.thingsboardService.getCredentials(userId);
  }
}
  • Step 4: Create Module
// backend/src/thingsboard/thingsboard.module.ts
import { Module } from '@nestjs/common';
import { ThingsboardController } from './thingsboard.controller';
import { ThingsboardService } from './thingsboard.service';
import { UsersModule } from '@/users/users.module';

@Module({
  imports: [UsersModule],
  controllers: [ThingsboardController],
  providers: [ThingsboardService],
  exports: [ThingsboardService],
})
export class ThingsboardModule {}
  • Step 5: Add ThingsboardModule to AppModule

In backend/src/app.module.ts, add import:

import { ThingsboardModule } from './thingsboard/thingsboard.module';

// In @Module({ imports: [...] }):
ThingsboardModule,
  • Step 6: Add TB fields to User entity

In backend/src/auth/entities/user.entity.ts, add columns:

@Column({ name: 'thingsboard_username', nullable: true })
thingsboardUsername: string;

@Column({ name: 'thingsboard_password', nullable: true })
thingsboardPassword: string;
  • Step 7: Run database migration (generate + run)
cd backend && bun run migration:generate -- thingsboard-tb-credentials && bun run migration:run
  • Step 8: Test the endpoint
curl -H "Authorization: Bearer <token>" http://localhost:3000/api/v1/thingsboard/credentials

Expected: {"thingsboardUrl":"...","username":"...","password":"..."}

  • Step 9: Commit
git add backend/src/thingsboard/ backend/src/auth/entities/user.entity.ts
git commit -m "feat(thingsboard): add backend module for TB credentials endpoint"

Task 2: Frontend — Thingsboard Store

Files:

  • Create: frontend/src/stores/thingsboard.store.ts
  • Modify: frontend/.env.example — add VITE_TB_URL

Steps

  • Step 1: Write the failing test

Create frontend/src/stores/thingsboard.store.spec.ts:

import { useThingsboardStore } from './thingsboard.store';

describe('useThingsboardStore', () => {
  beforeEach(() => {
    useThingsboardStore.setState({
      url: null,
      token: null,
      isConnected: false,
    });
  });

  it('should store url and token on init', () => {
    const { init } = useThingsboardStore.getState();
    init('http://localhost:9090/api/v1', 'tb-token-123');

    const state = useThingsboardStore.getState();
    expect(state.url).toBe('http://localhost:9090/api/v1');
    expect(state.token).toBe('tb-token-123');
    expect(state.isConnected).toBe(false);
  });

  it('should set isConnected to true', () => {
    const { setConnected } = useThingsboardStore.getState();
    setConnected(true);

    expect(useThingsboardStore.getState().isConnected).toBe(true);
  });

  it('should clear all state', () => {
    const store = useThingsboardStore.getState();
    store.init('http://localhost:9090/api/v1', 'tb-token-123');
    store.setConnected(true);

    store.clear();

    const state = useThingsboardStore.getState();
    expect(state.url).toBeNull();
    expect(state.token).toBeNull();
    expect(state.isConnected).toBe(false);
  });
});
  • Step 2: Run test to verify it fails
bun test -- frontend/src/stores/thingsboard.store.spec.ts

Expected: FAIL — file does not exist

  • Step 3: Write store implementation
// frontend/src/stores/thingsboard.store.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface ThingsboardState {
  url: string | null;
  token: string | null;
  isConnected: boolean;

  init(url: string, token: string): void;
  setConnected(connected: boolean): void;
  clear(): void;
}

export const useThingsboardStore = create<ThingsboardState>()(
  devtools(
    persist(
      (set) => ({
        url: null,
        token: null,
        isConnected: false,

        init: (url, token) => set({ url, token }),
        setConnected: (connected) => set({ isConnected: connected }),
        clear: () => set({ url: null, token: null, isConnected: false }),
      }),
      {
        name: 'thingsboard-storage',
      },
    ),
  ),
);
  • Step 4: Run test to verify it passes
bun test -- frontend/src/stores/thingsboard.store.spec.ts

Expected: PASS

  • Step 5: Add VITE_TB_URL to .env.example

Append to frontend/.env.example:

VITE_TB_URL=http://localhost:9090/api/v1
  • Step 6: Commit
git add frontend/src/stores/thingsboard.store.ts frontend/src/stores/thingsboard.store.spec.ts frontend/.env.example
git commit -m "feat(thingsboard): add thingsboard Zustand store"

Task 3: Frontend — ThingsboardService Module

Files:

  • Create: frontend/src/services/thingsboard/thingsboard.service.ts
  • Create: frontend/src/services/thingsboard/index.ts

Steps

  • Step 1: Write the failing test

Create frontend/src/services/thingsboard/thingsboard.service.spec.ts:

import { ThingsboardService } from './thingsboard.service';
import { useThingsboardStore } from '@/stores/thingsboard.store';

// Mock store
vi.mock('@/stores/thingsboard.store', () => ({
  useThingsboardStore: vi.fn(() => ({
    url: null,
    token: null,
    isConnected: false,
    init: vi.fn(),
    setConnected: vi.fn(),
    clear: vi.fn(),
  })),
}));

// Mock axios
vi.mock('axios', () => {
  const mockAxios = vi.fn(() => ({
    interceptors: {
      request: { use: vi.fn().mockReturnThis() },
      response: { use: vi.fn().mockReturnThis() },
    },
    defaults: { headers: { common: {} } },
  }));
  mockAxios.create = vi.fn(() => mockAxios);
  return { default: mockAxios };
});

describe('ThingsboardService', () => {
  beforeEach(() => {
    ThingsboardService.reset();
  });

  describe('init', () => {
    it('should store baseUrl', () => {
      ThingsboardService.init('http://localhost:9090/api/v1');
      expect(ThingsboardService.getClient()).toBeDefined();
    });
  });

  describe('reset', () => {
    it('should clear all state', () => {
      ThingsboardService.init('http://localhost:9090/api/v1');
      ThingsboardService.reset();
      expect(ThingsboardService.isConnected()).toBe(false);
    });
  });
});
  • Step 2: Run test to verify it fails
bun test -- frontend/src/services/thingsboard/thingsboard.service.spec.ts

Expected: FAIL — file does not exist

  • Step 3: Write ThingsboardService implementation
// frontend/src/services/thingsboard/thingsboard.service.ts
import axios, { type AxiosInstance } from 'axios';
import { useThingsboardStore } from '@/stores/thingsboard.store';
import { TBDevice } from './tb-device.class';

let _client: AxiosInstance | null = null;
let _baseUrl: string | null = null;

class ThingsboardService {
  static init(baseUrl: string): void {
    _baseUrl = baseUrl;

    _client = axios.create({
      baseURL: baseUrl,
      headers: {
        'Content-Type': 'application/json',
      },
    });

    _client.interceptors.request.use((config) => {
      const token = useThingsboardStore.getState().token;
      if (token) {
        config.headers['X-Authorization'] = `Bearer ${token}`;
      }
      return config;
    });

    _client.interceptors.response.use(
      (response) => response,
      (error) => {
        const normalized = {
          message:
            error.response?.data?.message || error.message || 'Thingsboard request failed',
          statusCode: error.response?.status || 500,
          cause: error,
        };
        return Promise.reject(normalized);
      },
    );
  }

  static async login(username: string, password: string): Promise<void> {
    if (!_client || !_baseUrl) {
      throw new Error('ThingsboardService not initialized');
    }

    const response = await _client.post('/api/auth/login', {
      username,
      password,
    });

    const token = response.data.token;
    useThingsboardStore.getState().init(_baseUrl, token);
  }

  static async logout(): Promise<void> {
    useThingsboardStore.getState().clear();
  }

  static getToken(): string | null {
    return useThingsboardStore.getState().token;
  }

  static isConnected(): boolean {
    return useThingsboardStore.getState().isConnected;
  }

  static getClient(): AxiosInstance {
    if (!_client) {
      throw new Error('ThingsboardService not initialized. Call init() first.');
    }
    return _client;
  }

  static device(tbDeviceId: string): TBDevice {
    if (!_client) {
      throw new Error('ThingsboardService not initialized. Call init() first.');
    }
    return new TBDevice(tbDeviceId, _client);
  }

  static reset(): void {
    _client = null;
    _baseUrl = null;
    useThingsboardStore.getState().clear();
  }
}

export { ThingsboardService };
  • Step 4: Run test to verify it passes
bun test -- frontend/src/services/thingsboard/thingsboard.service.spec.ts

Expected: PASS

  • Step 5: Create barrel export
// frontend/src/services/thingsboard/index.ts
export { ThingsboardService } from './thingsboard.service';
export { TBDevice } from './tb-device.class';
  • Step 6: Commit
git add frontend/src/services/thingsboard/
git commit -m "feat(thingsboard): add ThingsboardService singleton module"

Task 4: Frontend — TBDevice Class

Files:

  • Create: frontend/src/services/thingsboard/tb-device.class.ts
  • Test: frontend/src/services/thingsboard/tb-device.class.spec.ts

Steps

  • Step 1: Write the failing test

Create frontend/src/services/thingsboard/tb-device.class.spec.ts:

import { TBDevice } from './tb-device.class';
import type { AxiosInstance } from 'axios';

const createMockClient = () =>
  ({
    get: vi.fn(),
    post: vi.fn(),
    delete: vi.fn(),
  }) as unknown as AxiosInstance;

describe('TBDevice', () => {
  let mockClient: ReturnType<typeof createMockClient>;

  beforeEach(() => {
    mockClient = createMockClient();
  });

  describe('get', () => {
    it('should return device info', async () => {
      const deviceData = {
        id: { id: 'tb-device-123' },
        name: 'Test Device',
        type: 'default',
      };
      mockClient.get = vi.fn().mockResolvedValue({ data: deviceData });

      const device = new TBDevice('tb-device-123', mockClient);
      const result = await device.get();

      expect(result).toEqual(deviceData);
      expect(mockClient.get).toHaveBeenCalledWith('/api/device/tb-device-123');
    });
  });

  describe('delete', () => {
    it('should call delete endpoint', async () => {
      mockClient.delete = vi.fn().mockResolvedValue({ status: 200 });

      const device = new TBDevice('tb-device-123', mockClient);
      await device.delete();

      expect(mockClient.delete).toHaveBeenCalledWith('/api/device/tb-device-123');
    });
  });

  describe('getAttributes', () => {
    it('should call attributes endpoint with keys', async () => {
      const attrData = { key1: 'value1' };
      mockClient.get = vi.fn().mockResolvedValue({ data: attrData });

      const device = new TBDevice('tb-device-123', mockClient);
      const result = await device.getAttributes(['key1']);

      expect(result).toEqual(attrData);
      expect(mockClient.get).toHaveBeenCalledWith(
        '/api/plugins/telemetry/tb-device-123/values/attributes',
        { params: { keys: 'key1' } },
      );
    });
  });

  describe('saveAttributes', () => {
    it('should post attributes to scope endpoint', async () => {
      mockClient.post = vi.fn().mockResolvedValue({ status: 200 });

      const device = new TBDevice('tb-device-123', mockClient);
      await device.saveAttributes({ key1: 'value1' });

      expect(mockClient.post).toHaveBeenCalledWith(
        '/api/plugins/telemetry/tb-device-123/CLIENT_SCOPE',
        { key1: 'value1' },
      );
    });
  });

  describe('getLatestTelemetry', () => {
    it('should call timeseries endpoint without range params', async () => {
      const telemetryData = [{ key: 'temp', value: 25 }];
      mockClient.get = vi.fn().mockResolvedValue({ data: telemetryData });

      const device = new TBDevice('tb-device-123', mockClient);
      const result = await device.getLatestTelemetry(['temp']);

      expect(result).toEqual(telemetryData);
      expect(mockClient.get).toHaveBeenCalledWith(
        '/api/plugins/telemetry/tb-device-123/values/timeseries',
        { params: { keys: 'temp' } },
      );
    });
  });

  describe('getTelemetry', () => {
    it('should include fromTs and toTs params', async () => {
      const telemetryData = [{ key: 'temp', value: 25 }];
      mockClient.get = vi.fn().mockResolvedValue({ data: telemetryData });

      const device = new TBDevice('tb-device-123', mockClient);
      await device.getTelemetry(['temp'], 1000, 2000);

      expect(mockClient.get).toHaveBeenCalledWith(
        '/api/plugins/telemetry/tb-device-123/values/timeseries',
        { params: { keys: 'temp', startTs: 1000, endTs: 2000 } },
      );
    });
  });

  describe('rpc', () => {
    it('should send oneway RPC when waitForResponse is false', async () => {
      mockClient.post = vi.fn().mockResolvedValue({ status: 200 });

      const device = new TBDevice('tb-device-123', mockClient);
      await device.rpc('turnOn', { relay: 1 }, { waitForResponse: false });

      expect(mockClient.post).toHaveBeenCalledWith(
        '/api/plugins/rpc/oneway/tb-device-123',
        JSON.stringify({ method: 'turnOn', params: { relay: 1 } }),
      );
    });

    it('should send twoway RPC when waitForResponse is true', async () => {
      const rpcResponse = { result: 'ok' };
      mockClient.post = vi.fn().mockResolvedValue({ data: rpcResponse });

      const device = new TBDevice('tb-device-123', mockClient);
      const result = await device.rpc(
        'turnOn',
        { relay: 1 },
        { waitForResponse: true, timeout: 5000 },
      );

      expect(mockClient.post).toHaveBeenCalledWith(
        '/api/plugins/rpc/twoway/tb-device-123',
        JSON.stringify({ method: 'turnOn', params: { relay: 1 } }),
      );
      expect(result).toEqual(rpcResponse);
    });
  });
});
  • Step 2: Run test to verify it fails
bun test -- frontend/src/services/thingsboard/tb-device.class.spec.ts

Expected: FAIL — file does not exist

  • Step 3: Write TBDevice implementation
// frontend/src/services/thingsboard/tb-device.class.ts
import type { AxiosInstance } from 'axios';

export interface TBDeviceInfo {
  id: { id: string };
  name: string;
  type: string;
  label?: string;
  [key: string]: unknown;
}

export interface TelemetryDataPoint {
  ts: number;
  value: unknown;
}

export class TBDevice {
  private readonly tbDeviceId: string;
  private readonly client: AxiosInstance;

  constructor(tbDeviceId: string, client: AxiosInstance) {
    this.tbDeviceId = tbDeviceId;
    this.client = client;
  }

  async get(): Promise<TBDeviceInfo> {
    const response = await this.client.get<TBDeviceInfo>(
      `/api/device/${this.tbDeviceId}`,
    );
    return response.data;
  }

  async update(payload: Partial<TBDeviceInfo>): Promise<TBDeviceInfo> {
    const response = await this.client.post<TBDeviceInfo>('/api/device', {
      id: { id: this.tbDeviceId },
      ...payload,
    });
    return response.data;
  }

  async delete(): Promise<void> {
    await this.client.delete(`/api/device/${this.tbDeviceId}`);
  }

  async getAttributes(keys?: string[]): Promise<Record<string, unknown>> {
    const params = keys ? { keys: keys.join(',') } : undefined;
    const response = await this.client.get<Record<string, unknown>>(
      `/api/plugins/telemetry/${this.tbDeviceId}/values/attributes`,
      { params },
    );
    return response.data;
  }

  async saveAttributes(attributes: Record<string, unknown>): Promise<void> {
    await this.client.post(
      `/api/plugins/telemetry/${this.tbDeviceId}/CLIENT_SCOPE`,
      attributes,
    );
  }

  async getLatestTelemetry(keys?: string[]): Promise<Record<string, unknown>> {
    const params = keys ? { keys: keys.join(',') } : undefined;
    const response = await this.client.get<Record<string, unknown>>(
      `/api/plugins/telemetry/${this.tbDeviceId}/values/timeseries`,
      { params },
    );
    return response.data;
  }

  async getTelemetry(
    keys?: string[],
    fromTs?: number,
    toTs?: number,
  ): Promise<Record<string, unknown>> {
    const params: Record<string, unknown> = {};
    if (keys) params['keys'] = keys.join(',');
    if (fromTs) params['startTs'] = fromTs;
    if (toTs) params['endTs'] = toTs;

    const response = await this.client.get<Record<string, unknown>>(
      `/api/plugins/telemetry/${this.tbDeviceId}/values/timeseries`,
      { params },
    );
    return response.data;
  }

  async rpc(
    method: string,
    params?: Record<string, unknown>,
    options?: { waitForResponse?: boolean; timeout?: number },
  ): Promise<unknown> {
    const { waitForResponse = false, timeout = 5000 } = options ?? {};
    const endpoint = waitForResponse
      ? `/api/plugins/rpc/twoway/${this.tbDeviceId}`
      : `/api/plugins/rpc/oneway/${this.tbDeviceId}`;

    const body = JSON.stringify({ method, params });

    if (waitForResponse) {
      const response = await this.client.post(endpoint, body, {
        timeout,
        headers: { 'Content-Type': 'application/json' },
      });
      return response.data;
    }

    await this.client.post(endpoint, body, {
      timeout,
      headers: { 'Content-Type': 'application/json' },
    });
    return undefined;
  }
}
  • Step 4: Run test to verify it passes
bun test -- frontend/src/services/thingsboard/tb-device.class.spec.ts

Expected: PASS

  • Step 5: Commit
git add frontend/src/services/thingsboard/tb-device.class.ts frontend/src/services/thingsboard/tb-device.class.spec.ts
git commit -m "feat(thingsboard): add TBDevice class with CRUD, attributes, telemetry, and RPC"

Task 5: Frontend — useThingsboardAuth Hook + fetchTBcredentials

Files:

  • Create: frontend/src/hooks/useThingsboardAuth.ts
  • Create: frontend/src/services/thingsboard/fetch-tb-credentials.ts (helper)

Steps

  • Step 1: Write the failing test

Create frontend/src/hooks/useThingsboardAuth.spec.tsx:

import { renderHook, act, waitFor } from '@testing-library/react';
import { useThingsboardAuth } from './useThingsboardAuth';
import { useAuthStore } from '@/stores/auth.store';
import { useThingsboardStore } from '@/stores/thingsboard.store';
import { ThingsboardService } from '@/services/thingsboard';
import type { ReactNode } from 'react';
import { vi, describe, it, expect, beforeEach } from 'vitest';

vi.mock('@/services/thingsboard', () => ({
  ThingsboardService: {
    init: vi.fn(),
    login: vi.fn().mockResolvedValue(undefined),
    logout: vi.fn().mockResolvedValue(undefined),
    isConnected: () => false,
  },
}));

const wrapper = ({ children }: { children: ReactNode }) => (
  <>{children}</>
);

describe('useThingsboardAuth', () => {
  beforeEach(() => {
    useAuthStore.setState({ isAuthenticated: false });
    useThingsboardStore.setState({ isConnected: false, url: null, token: null });
    ThingsboardService.init.mockClear();
    ThingsboardService.login.mockClear();
  });

  it('should attempt TB login when backend auth succeeds', async () => {
    useAuthStore.setState({ isAuthenticated: true });
    // Mock fetch to return TB credentials
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: () =>
        Promise.resolve({
          thingsboardUrl: 'http://localhost:9090/api/v1',
          username: 'tbuser',
          password: 'tbpass',
        }),
    });

    const { result } = renderHook(() => useThingsboardAuth(), { wrapper });

    await waitFor(() => {
      expect(ThingsboardService.init).toHaveBeenCalledWith(
        'http://localhost:9090/api/v1',
      );
    });
    expect(ThingsboardService.login).toHaveBeenCalledWith('tbuser', 'tbpass');
  });

  it('should not trigger TB login when not authenticated', async () => {
    const { result } = renderHook(() => useThingsboardAuth(), { wrapper });

    expect(ThingsboardService.init).not.toHaveBeenCalled();
    expect(ThingsboardService.login).not.toHaveBeenCalled();
  });
});
  • Step 2: Run test to verify it fails
bun test -- frontend/src/hooks/useThingsboardAuth.spec.tsx

Expected: FAIL — file does not exist

  • Step 3: Write fetchTBcredentials helper
// frontend/src/services/thingsboard/fetch-tb-credentials.ts
interface TbCredentials {
  thingsboardUrl: string;
  username: string;
  password: string;
}

export async function fetchTBcredentials(): Promise<TbCredentials> {
  const response = await fetch(
    `${import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1'}/thingsboard/credentials`,
    {
      credentials: 'include',
      headers: {
        'Content-Type': 'application/json',
      },
    },
  );

  if (!response.ok) {
    throw {
      message: 'Failed to fetch Thingsboard credentials',
      statusCode: response.status,
    };
  }

  return response.json();
}
  • Step 4: Write useThingsboardAuth hook
// frontend/src/hooks/useThingsboardAuth.ts
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/auth.store';
import { useThingsboardStore } from '@/stores/thingsboard.store';
import { ThingsboardService } from '@/services/thingsboard';
import { fetchTBcredentials } from '@/services/thingsboard/fetch-tb-credentials';

export const useThingsboardAuth = () => {
  const { isAuthenticated } = useAuthStore();
  const thingsboardStore = useThingsboardStore();

  useEffect(() => {
    if (isAuthenticated && !thingsboardStore.isConnected) {
      fetchTBcredentials()
        .then(({ thingsboardUrl, username, password }) => {
          ThingsboardService.init(thingsboardUrl);
          return ThingsboardService.login(username, password);
        })
        .then(() => thingsboardStore.setConnected(true))
        .catch((error) => {
          console.error('Failed to connect to Thingsboard:', error);
        });
    }
  }, [isAuthenticated]);

  useEffect(() => {
    if (!isAuthenticated) {
      ThingsboardService.logout();
      thingsboardStore.clear();
    }
  }, [isAuthenticated]);
};
  • Step 5: Update ThingsboardService barrel export

Update frontend/src/services/thingsboard/index.ts:

export { ThingsboardService } from './thingsboard.service';
export { TBDevice } from './tb-device.class';
export { fetchTBcredentials } from './fetch-tb-credentials';
  • Step 6: Run test to verify it passes
bun test -- frontend/src/hooks/useThingsboardAuth.spec.tsx

Expected: PASS (may need minor test adjustment)

  • Step 7: Commit
git add frontend/src/hooks/useThingsboardAuth.ts frontend/src/services/thingsboard/fetch-tb-credentials.ts frontend/src/services/thingsboard/index.ts
git commit -m "feat(thingsboard): add useThingsboardAuth hook for auto-login"

Task 6: Integration — Wire up useThingsboardAuth in App

Files:

  • Modify: frontend/src/App.tsx or main layout — add useThingsboardAuth hook call

Steps

  • Step 1: Find and read App.tsx
cat frontend/src/App.tsx
  • Step 2: Add useThingsboardAuth call

In the root component (or AuthLayout), add:

// In App.tsx or root layout component
import { useThingsboardAuth } from '@/hooks/useThingsboardAuth';

// Inside the component:
useThingsboardAuth();
  • Step 3: Run check
bun run check

Expected: No errors

  • Step 4: Commit
git add frontend/src/App.tsx
git commit -m "feat(thingsboard): wire up useThingsboardAuth in App"

Spec Coverage Check

Spec Section Task
Backend TB credentials endpoint Task 1
Thingsboard Store (Zustand) Task 2
ThingsboardService singleton Task 3
TBDevice class Task 4
useThingsboardAuth hook Task 5
Wire up in App Task 6
VITE_TB_URL env var Task 2
TB auth header X-Authorization Task 3
RPC one-way / two-way Task 4
Telemetry range + latest Task 4
Attributes get/save Task 4

All spec items covered. No placeholders found.


Next Step

Execution via subagent-driven-development or inline execution.