ti-pote/apps/robot-client/src/services/pairing.service.ts
2026-04-08 18:37:08 +02:00

178 lines
6.0 KiB
TypeScript

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<PairingRequestResponse> {
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<PairingRequestResponse>;
}
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<string, string[]> = {
'0': [' ██ ', '█ █', '█ █', '█ █', ' ██ '],
'1': [' █ ', ' ██ ', ' █ ', ' █ ', ' ███'],
'2': [' ██ ', '█ █', ' █ ', ' █ ', '████'],
'3': ['████', ' █', ' ██ ', ' █', '████'],
'4': ['█ █', '█ █', '████', ' █', ' █'],
'5': ['████', '█ ', '███ ', ' █', '███ '],
'6': [' ██ ', '█ ', '███ ', '█ █', ' ██ '],
'7': ['████', ' █', ' █ ', ' █ ', ' █ '],
'8': [' ██ ', '█ █', ' ██ ', '█ █', ' ██ '],
'9': [' ██ ', '█ █', ' ███', ' █', ' ██ '],
};
return digits[d] || [' ', ' ', ' ', ' ', ' '];
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}