Files
scrypted/plugins/tapo/src/tapo-api.ts
2024-01-09 13:52:51 -08:00

158 lines
5.2 KiB
TypeScript

import { Deferred } from '@scrypted/common/src/deferred';
import { authHttpFetch } from '@scrypted/common/src/http-auth-fetch';
import { readLine } from '@scrypted/common/src/read-stream';
import { parseHeaders, readBody, readMessage, writeMessage } from '@scrypted/common/src/rtsp-server';
import crypto from 'crypto';
import { Duplex, PassThrough, Writable } from 'stream';
import { BufferParser, StreamParser } from '../../../server/src/http-fetch-helpers';
import { digestAuthHeader } from './digest-auth';
export function getTapoAdminPassword(cloudPassword: string, useSHA256: boolean) {
if (useSHA256) {
return crypto.createHash('sha256').update(Buffer.from(cloudPassword)).digest('hex').toUpperCase();
}
return crypto.createHash('md5').update(Buffer.from(cloudPassword)).digest('hex').toUpperCase();
}
export class TapoAPI {
keyExchange: string;
stream: Duplex;
constructor() {
}
static async connect(options: {
address: string;
cloudPassword: string;
}) {
const url = `http://${options.address}/stream`;
// will fail with auth required.
const response = await authHttpFetch({
credential: undefined,
url: url,
ignoreStatusCode: true,
}, {
method: 'POST',
headers: {
'Content-Type': 'multipart/mixed; boundary=--client-stream-boundary--',
},
}, BufferParser);
if (response.statusCode !== 401)
throw new Error('Expected 401 status code for two way audio init')
const wwwAuthenticate = response.headers['www-authenticate'];
const useSHA256 = wwwAuthenticate.includes('encrypt_type="3"');
const password = getTapoAdminPassword(options.cloudPassword, useSHA256);
const auth = digestAuthHeader('POST', '/stream', wwwAuthenticate, 'admin', password, 0) + ', algorithm=MD5';
const response2 = await authHttpFetch({
credential: undefined,
url: url,
}, {
method: 'POST',
headers: {
'Authorization': auth,
'Content-Type': 'multipart/mixed; boundary=--client-stream-boundary--',
},
}, StreamParser)
const tapo = new TapoAPI();
tapo.keyExchange = response2.headers['key-exchange'] as string;
tapo.stream = response2.body.socket;
tapo.stream.on('close', () => console.error('stream closed'));
// this.stream.on('data', data => console.log('data', data));
// this.stream.resume();
return tapo;
}
async processMessages() {
const pt = new PassThrough();
this.stream.pipe(pt);
while (true) {
const line = await readLine(pt);
if (line.trim() !== '----device-stream-boundary--')
throw new Error('expected ----device-stream-boundary--');
const message = await readMessage(pt);
const headers = parseHeaders(['', ...message]);
const body = await readBody(pt, headers);
const empty = await readLine(pt);
if (!empty)
throw new Error('expected empty line');
console.log('message', headers, body?.toString());
if (headers['content-type']?.includes('application/json')) {
const json = JSON.parse(body.toString());
if (json.type === 'response') {
const { seq, params } = json;
const deferred = this.requests.get(seq);
if (deferred) {
this.requests.delete(seq);
deferred.resolve(params)
}
}
}
}
}
requests = new Map<number, Deferred<any>>();
seq = 0;
backchannelSessionId: string;
async startMpegTsBackchannel(): Promise<Writable> {
const response = await this.request({
talk: {
mode: "aec"
},
method: "get"
});
const { error_code } = response;
if (error_code)
throw new Error('unexpected error_code: ' + JSON.stringify(response));
this.backchannelSessionId = response.session_id;
const pt = new PassThrough();
pt.on('readable', () => {
let data: Buffer = pt.read();
if (!data)
return;
this.stream.write('----client-stream-boundary--\r\n');
writeMessage(this.stream, undefined, data, {
'Content-Type': 'audio/mp2t',
'X-If-Encrypt': '0',
'X-Session-Id': this.backchannelSessionId,
});
});
this.stream.on('close', () => pt.destroy());
return pt;
}
async request(params: any) {
const seq = ++this.seq;
const request = {
params,
seq,
type: "request"
};
const deferred = new Deferred<any>();
this.requests.set(seq, deferred);
this.stream.write('----client-stream-boundary--\r\n');
writeMessage(this.stream, undefined, Buffer.from(JSON.stringify(request)), {
'Content-Type': 'application/json',
});
return deferred.promise;
}
}