import type WebSocket from "ws"; import { WebSocketServer } from "ws" import { Logger } from "tslog"; import { EventEmitter, once } from "events"; import { Server as HttpServer, createServer, IncomingMessage as HttpIncomingMessage } from "http"; import { DeviceNotFoundError, EufySecurity, InvalidCountryCodeError, InvalidLanguageCodeError, InvalidPropertyValueError, libVersion, NotSupportedError, ReadOnlyPropertyError, StationNotFoundError, WrongStationError, PropertyNotSupportedError, InvalidPropertyError, InvalidCommandValueError, Device } from "eufy-security-client"; import { EventForwarder } from "./forward"; import type * as OutgoingMessages from "./outgoing_message"; import { IncomingMessage } from "./incoming_message"; import { version, minSchemaVersion, maxSchemaVersion } from "./const"; import { DeviceMessageHandler } from "./device/message_handler"; import { StationMessageHandler } from "./station/message_handler"; import { IncomingMessageStation } from "./station/incoming_message"; import { BaseError, ErrorCode, LivestreamAlreadyRunningError, LivestreamNotRunningError, SchemaIncompatibleError, UnknownCommandError } from "./error"; import { Instance } from "./instance"; import { IncomingMessageDevice } from "./device/incoming_message"; import { ServerCommand } from "./command"; import { DriverMessageHandler } from "./driver/message_handler"; import { IncomingMessageDriver } from "./driver/incoming_message"; import { dumpState } from "./state"; import { LoggingEventForwarder } from "./logging"; import { ServerEvent } from "./event"; export class Client { public receiveEvents = false; public receiveLogs = false; private _outstandingPing = false; public schemaVersion = minSchemaVersion; public receiveLivestream: { [index: string]: boolean; } = {}; private instanceHandlers: Record<Instance, (message: IncomingMessage) => Promise<OutgoingMessages.OutgoingResultMessageSuccess["result"]>> = { [Instance.station]: (message) => StationMessageHandler.handle( message as IncomingMessageStation, this.driver, this ), [Instance.driver]: (message) => DriverMessageHandler.handle( message as IncomingMessageDriver, this.driver, this, this.clientsController, this.logger ), [Instance.device]: (message) => DeviceMessageHandler.handle( message as IncomingMessageDevice, this.driver, this ), }; constructor(private socket: WebSocket, private driver: EufySecurity, private logger: Logger, private clientsController: ClientsController) { socket.on("pong", () => { this._outstandingPing = false; }); socket.on("message", (data: string) => this.receiveMessage(data)); } get isConnected(): boolean { return this.socket.readyState === this.socket.OPEN; } async receiveMessage(data: string): Promise<void> { let msg: IncomingMessage; try { msg = JSON.parse(data); } catch (err) { // We don't have the message ID. Just close it. this.logger.debug(`Unable to parse data: ${data}`); this.socket.close(); return; } try { if (msg.command === ServerCommand.setApiSchema) { // Handle schema version this.schemaVersion = msg.schemaVersion; if (this.schemaVersion < minSchemaVersion || this.schemaVersion > maxSchemaVersion) { throw new SchemaIncompatibleError(this.schemaVersion); } this.sendResultSuccess(msg.messageId, {}); return; } if (msg.command === ServerCommand.startListening) { this.sendResultSuccess(msg.messageId, { state: await dumpState(this.driver, this.schemaVersion), }); this.receiveEvents = true; return; } const instance = msg.command.split(".")[0] as Instance; if (this.instanceHandlers[instance]) { return this.sendResultSuccess( msg.messageId, await this.instanceHandlers[instance](msg) ); } throw new UnknownCommandError(msg.command); } catch (error: unknown) { if (error instanceof BaseError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, error.errorCode); } if (error instanceof DeviceNotFoundError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceNotFound); } if (error instanceof StationNotFoundError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.stationNotFound); } if (error instanceof NotSupportedError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceNotSupported); } if (error instanceof WrongStationError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceWrongStation); } if (error instanceof InvalidPropertyValueError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceInvalidPropertyValue); } if (error instanceof ReadOnlyPropertyError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceReadOnlyProperty); } if (error instanceof InvalidCountryCodeError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.invalidCountryCode); } if (error instanceof InvalidLanguageCodeError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.invalidLanguageCode); } if (error instanceof InvalidPropertyError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceInvalidProperty); } if (error instanceof LivestreamAlreadyRunningError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceLivestreamAlreadyRunning); } if (error instanceof LivestreamNotRunningError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceLivestreamNotRunning); } if (error instanceof PropertyNotSupportedError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.devicePropertyNotSupported); } if (error instanceof InvalidCommandValueError) { this.logger.error("Message error", error); return this.sendResultError(msg.messageId, ErrorCode.deviceInvalidCommandValue); } this.logger.error("Unexpected error", error as Error); this.sendResultError(msg.messageId, ErrorCode.unknownError); } } sendVersion(): void { this.sendData({ type: "version", driverVersion: libVersion, serverVersion: version, minSchemaVersion: minSchemaVersion, maxSchemaVersion: maxSchemaVersion, }); } sendResultSuccess(messageId: string, result: OutgoingMessages.OutgoingResultMessageSuccess["result"]): void { this.sendData({ type: "result", success: true, messageId, result, }); } sendResultError(messageId: string, errorCode: string): void { this.sendData({ type: "result", success: false, messageId, errorCode, }); } sendEvent(event: OutgoingMessages.OutgoingEvent): void { this.sendData({ type: "event", event, }); } sendData(data: OutgoingMessages.OutgoingMessage): void { this.socket.send(JSON.stringify(data)); } checkAlive(): void { if (this._outstandingPing) { this.disconnect(); return; } this._outstandingPing = true; this.socket.ping(); } disconnect(): void { this.socket.close(); } } export class ClientsController { public clients: Array<Client> = []; private pingInterval?: NodeJS.Timeout; private eventForwarder?: EventForwarder; private cleanupScheduled = false; private loggingEventForwarder?: LoggingEventForwarder; constructor(public driver: EufySecurity, private logger: Logger) {} addSocket(socket: WebSocket, request: HttpIncomingMessage): void { this.logger.debug(`New client with ip: ${request.socket.remoteAddress} port: ${request.socket.remotePort}`); const client = new Client(socket, this.driver, this.logger, this); socket.on("error", (error) => { this.logger.error(`Client with ip: ${request.socket.remoteAddress} port: ${request.socket.remotePort} socket error`, error); }); socket.on("close", (code, reason) => { this.logger.info(`Client disconnected with ip: ${request.socket.remoteAddress} port: ${request.socket.remotePort} code: ${code} reason: ${reason}`); this.scheduleClientCleanup(); }); client.sendVersion(); this.clients.push(client); if (this.pingInterval === undefined) { this.pingInterval = setInterval(() => { const newClients = []; for (const client of this.clients) { if (client.isConnected) { newClients.push(client); } else { client.disconnect(); } } this.clients = newClients; }, 30000); } if (this.eventForwarder === undefined) { this.eventForwarder = new EventForwarder(this); this.eventForwarder.start(); } } get loggingEventForwarderStarted(): boolean { return this.loggingEventForwarder?.started === true; } public restartLoggingEventForwarderIfNeeded(): void { this.loggingEventForwarder?.restartIfNeeded(); } public configureLoggingEventForwarder(): void { if (this.loggingEventForwarder === undefined) { this.loggingEventForwarder = new LoggingEventForwarder(this, this.logger); } if (!this.loggingEventForwarderStarted) { this.loggingEventForwarder?.start(); } } public cleanupLoggingEventForwarder(): void { if (this.clients.filter((cl) => cl.receiveLogs).length == 0 && this.loggingEventForwarderStarted) { this.loggingEventForwarder?.stop(); } } private scheduleClientCleanup(): void { if (this.cleanupScheduled) { return; } this.cleanupScheduled = true; setTimeout(() => this.cleanupClients(), 0); } private cleanupClients(): void { this.cleanupScheduled = false; const disconnectedClients = this.clients.filter((cl) => cl.isConnected === false); this.clients = this.clients.filter((cl) => cl.isConnected); disconnectedClients.forEach(client => { Object.keys(client.receiveLivestream).forEach(serialNumber => { this.driver.getDevice(serialNumber).then((device: Device) => { const station = this.driver.getStation(device.getStationSerial()); const streamingDevices = DeviceMessageHandler.getStreamingDevices(station.getSerial()); if (client.receiveLivestream[serialNumber] === true && streamingDevices.length === 1 && streamingDevices.includes(client)) { if (station.isLiveStreaming(device)) station.stopLivestream(device); } client.receiveLivestream[device.getSerial()] = false; DeviceMessageHandler.removeStreamingDevice(station.getSerial(), client); }).catch((error) => { this.logger.error(`Error doing cleanup of client`, error); }); }); }); this.cleanupLoggingEventForwarder(); } disconnect(): void { if (this.pingInterval !== undefined) { clearInterval(this.pingInterval); } this.pingInterval = undefined; this.clients.forEach((client) => { if (client.schemaVersion >= 10) { client.sendEvent({ source: "server", event: ServerEvent.shutdown, }); } }); this.clients.forEach((client) => client.disconnect()); this.clients = []; this.cleanupLoggingEventForwarder(); } } interface EufySecurityServerOptions { host: string; port: number; logger?: Logger; } export interface EufySecurityServer { start(): void; destroy(): void; on(event: "listening", listener: () => void): this; on(event: "error", listener: (error: Error) => void): this; } export class EufySecurityServer extends EventEmitter { private server?: HttpServer; private wsServer?: WebSocketServer; private sockets?: ClientsController; private logger: Logger; constructor(private driver: EufySecurity, private options: EufySecurityServerOptions) { super(); this.logger = options.logger ?? new Logger(); } async start(): Promise<void> { this.server = createServer(); this.wsServer = new WebSocketServer({ server: this.server }); this.sockets = new ClientsController(this.driver, this.logger); this.wsServer.on("connection", (socket, request) => this.sockets?.addSocket(socket, request)); this.logger.debug(`Starting server on host ${this.options.host}, port ${this.options.port}`); this.server.on("error", this.onError.bind(this)); this.server.listen(this.options.port, this.options.host); await once(this.server, "listening"); this.emit("listening"); this.logger.info(`Eufy Security server listening on host ${this.options.host}, port ${this.options.port}`); await this.driver.connect() } private onError(error: Error) { this.emit("error", error); this.logger.error(error); } async destroy(): Promise<void> { this.logger.debug(`Closing server...`); if (this.sockets) { this.sockets.disconnect(); } if (this.server) { this.server.close(); await once(this.server, "close"); } this.logger.info(`Server closed`); } }