matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
158 lines (133 loc) • 5.5 kB
text/typescript
import * as dgram from 'node:dgram';
import { Socket } from 'node:dgram';
import { Parser } from 'binary-parser/dist/binary_parser.js';
import crypto from 'node:crypto';
import CRC32 from 'crc-32';
import { AnsiLogger } from 'matterbridge/logger';
import { AbstractClient } from '../abstractClient.js';
import { RequestMessage, ResponseMessage } from '../../index.js';
import { MessageContext } from '../model/messageContext.js';
/* eslint-disable @typescript-eslint/no-explicit-any */
export class LocalNetworkUDPClient extends AbstractClient {
protected override clientName = 'LocalNetworkUDPClient';
protected override shouldReconnect = false;
private readonly PORT = 58866;
private server: Socket | undefined = undefined;
private readonly V10Parser: Parser;
private readonly L01Parser: Parser;
constructor(logger: AnsiLogger, context: MessageContext) {
super(logger, context);
this.V10Parser = new Parser()
.endianness('big')
.string('version', { length: 3 })
.uint32('seq')
.uint16('protocol')
.uint16('payloadLen')
.buffer('payload', { length: 'payloadLen' })
.uint32('crc32');
this.L01Parser = new Parser()
.endianness('big')
.string('version', { length: 3 })
.string('field1', { length: 4 })
.string('field2', { length: 2 })
.uint16('payloadLen')
.buffer('payload', { length: 'payloadLen' })
.uint32('crc32');
this.logger = logger;
}
public connect(): void {
try {
this.server = dgram.createSocket('udp4');
this.server.bind(this.PORT);
this.server.on('message', this.onMessage.bind(this));
this.server.on('error', this.onError.bind(this));
} catch (err) {
this.logger.error(`Failed to create UDP socket: ${err}`);
this.server = undefined;
}
}
public disconnect(): Promise<void> {
if (this.server) {
return new Promise<void>((resolve) => {
this.server?.close(() => {
this.server = undefined;
resolve();
});
});
}
return Promise.resolve();
}
public override send(duid: string, request: RequestMessage): Promise<void> {
this.logger.debug(`Sending request to ${duid}: ${JSON.stringify(request)}`);
return Promise.resolve();
}
private async onError(result: any) {
this.logger.error(`UDP socket error: ${result}`);
if (this.server) {
this.server.close();
this.server = undefined;
}
}
private async onMessage(buffer: Buffer) {
const message = await this.deserializeMessage(buffer);
this.logger.debug('Received message: ' + JSON.stringify(message));
}
private async deserializeMessage(buffer: Buffer): Promise<ResponseMessage | undefined> {
const version = buffer.toString('latin1', 0, 3);
if (version !== '1.0' && version !== 'L01' && version !== 'A01') {
throw new Error('unknown protocol version ' + version);
}
let data;
switch (version) {
case '1.0':
data = await this.deserializeV10Message(buffer);
return JSON.parse(data);
case 'L01':
data = await this.deserializeL01Message(buffer);
return JSON.parse(data);
case 'A01':
// TODO: Implement A01 deserialization
return undefined; // Placeholder for A01 deserialization
default:
throw new Error('unknown protocol version ' + version);
}
}
private async deserializeV10Message(message: Buffer<ArrayBufferLike>): Promise<string> {
const data = this.V10Parser.parse(message);
const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
const expectedCrc32 = data.crc32;
if (crc32 != expectedCrc32) {
throw new Error('wrong CRC32 ' + crc32 + ', expected ' + expectedCrc32);
}
const decipher = crypto.createDecipheriv('aes-128-ecb', Buffer.from('qWKYcdQWrbm9hPqe', 'utf8'), null);
decipher.setAutoPadding(false);
let decrypted = decipher.update(data.payload, 'binary', 'utf8');
decrypted += decipher.final('utf8');
const paddingLength = decrypted.charCodeAt(decrypted.length - 1);
return decrypted.slice(0, -paddingLength);
}
private async deserializeL01Message(message: Buffer<ArrayBufferLike>): Promise<string> {
const data = this.L01Parser.parse(message);
const crc32 = CRC32.buf(message.subarray(0, message.length - 4)) >>> 0;
const expectedCrc32 = data.crc32;
if (crc32 != expectedCrc32) {
throw new Error('wrong CRC32 ' + crc32 + ', expected ' + expectedCrc32);
}
const payload = data.payload;
const key = crypto.createHash('sha256').update(Buffer.from('qWKYcdQWrbm9hPqe', 'utf8')).digest();
const digestInput = message.subarray(0, 9);
const digest = crypto.createHash('sha256').update(digestInput).digest();
const iv = digest.subarray(0, 12);
const tag = payload.subarray(payload.length - 16);
const ciphertext = payload.subarray(0, payload.length - 16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
try {
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return decrypted.toString('utf8');
} catch (e: unknown) {
const message = e && typeof e === 'object' && 'message' in e ? (e as any).message : String(e);
throw new Error('failed to decrypt: ' + message + ' / iv: ' + iv.toString('hex') + ' / tag: ' + tag.toString('hex') + ' / encrypted: ' + ciphertext.toString('hex'));
}
}
}