import { createLogger, type Logger } from '../utils/index.js'; import { type LocalStore } from './local-store.service.js'; interface PairingRequestResponse { requestId: string; code: string; } interface PairingStatusResponse { status: 'pending' | 'confirmed'; code?: string; deviceId?: string; deviceToken?: string; homeId?: string; } /** * Pairing service — handles automatic device registration. * * Flow: * 1. Call backend POST /pairing/request → get a 6-digit code * 2. Display the code on screen (HDMI / captive portal) * 3. Poll GET /pairing/status/:requestId until confirmed * 4. Store credentials in LocalStore */ export class PairingService { private readonly logger: Logger; constructor( private readonly cloudUrl: string, private readonly store: LocalStore, ) { this.logger = createLogger('pairing', 'info'); } /** * Run the full pairing flow. * Returns device credentials once the user confirms on the app. */ async pair(deviceName: string): Promise<{ deviceId: string; deviceToken: string }> { // Step 1: Request a pairing code from the backend const backendUrl = this.cloudUrl.replace(/^ws/, 'http'); this.logger.info({ backendUrl }, 'Requesting pairing code from backend...'); const { requestId, code } = await this.requestPairing(backendUrl, deviceName); // Step 2: Display the code this.displayCode(code); // Step 3: Poll for confirmation this.logger.info('Waiting for user to confirm pairing on the app...'); const credentials = await this.pollForConfirmation(backendUrl, requestId); // Step 4: Store credentials this.store.setDevice(credentials.deviceId, credentials.deviceToken, credentials.homeId); this.store.markSetupComplete(); this.logger.info({ deviceId: credentials.deviceId }, '✅ Device paired successfully!'); return { deviceId: credentials.deviceId, deviceToken: credentials.deviceToken, }; } private async requestPairing(backendUrl: string, deviceName: string): Promise { const res = await fetch(`${backendUrl}/api/pairing/request`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deviceName }), }); if (!res.ok) { const body = await res.text(); throw new Error(`Pairing request failed (${res.status}): ${body}`); } return res.json() as Promise; } private async pollForConfirmation( backendUrl: string, requestId: string, ): Promise<{ deviceId: string; deviceToken: string; homeId: string }> { const pollIntervalMs = 3000; const maxAttempts = 200; // 10 minutes at 3s intervals for (let attempt = 0; attempt < maxAttempts; attempt++) { await this.sleep(pollIntervalMs); try { const res = await fetch(`${backendUrl}/api/pairing/status/${requestId}`); if (!res.ok) { if (res.status === 404) { throw new Error('Pairing request expired. Please restart the robot to try again.'); } continue; } const data = (await res.json()) as PairingStatusResponse; if (data.status === 'confirmed' && data.deviceId && data.deviceToken && data.homeId) { return { deviceId: data.deviceId, deviceToken: data.deviceToken, homeId: data.homeId, }; } } catch (err) { if (err instanceof Error && err.message.includes('expired')) { throw err; } this.logger.debug({ err }, 'Poll error (will retry)'); } } throw new Error('Pairing timed out. Please restart the robot to try again.'); } /** * Display the pairing code prominently. * For now: big ASCII art in the console (visible on HDMI). * Future: display on an OLED/LCD screen. */ private displayCode(code: string): void { const digits = code.split(''); const big = digits.map((d) => this.bigDigit(d)); // Build 5 lines of ASCII art const lines: string[] = []; for (let row = 0; row < 5; row++) { lines.push(big.map((d) => d[row]).join(' ')); } console.log(''); console.log(`╔${'═'.repeat(58)}╗`); console.log(`║${'PAIRING CODE'.padStart(35).padEnd(58)}║`); console.log(`╠${'═'.repeat(58)}╣`); console.log(`║${' '.repeat(58)}║`); for (const line of lines) { console.log(`║ ${line.padEnd(56)}║`); } console.log(`║${' '.repeat(58)}║`); console.log(`║${'Enter this code in the Ti-Pote app'.padStart(46).padEnd(58)}║`); console.log(`║${'to pair this robot to your account'.padStart(46).padEnd(58)}║`); console.log(`║${' '.repeat(58)}║`); console.log(`║${'Code expires in 10 minutes'.padStart(42).padEnd(58)}║`); console.log(`╚${'═'.repeat(58)}╝`); console.log(''); this.logger.info({ code }, '📱 Pairing code displayed — enter it in the Ti-Pote app'); } /** * 5-line ASCII art for a single digit (3 chars wide). */ private bigDigit(d: string): string[] { const digits: Record = { '0': [' ██ ', '█ █', '█ █', '█ █', ' ██ '], '1': [' █ ', ' ██ ', ' █ ', ' █ ', ' ███'], '2': [' ██ ', '█ █', ' █ ', ' █ ', '████'], '3': ['████', ' █', ' ██ ', ' █', '████'], '4': ['█ █', '█ █', '████', ' █', ' █'], '5': ['████', '█ ', '███ ', ' █', '███ '], '6': [' ██ ', '█ ', '███ ', '█ █', ' ██ '], '7': ['████', ' █', ' █ ', ' █ ', ' █ '], '8': [' ██ ', '█ █', ' ██ ', '█ █', ' ██ '], '9': [' ██ ', '█ █', ' ███', ' █', ' ██ '], }; return digits[d] || [' ', ' ', ' ', ' ', ' ']; } private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } }