178 lines
6.0 KiB
TypeScript
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));
|
|
}
|
|
}
|