From 2197ffe8f4b1bfacbe9a50a5b503513c9d01ae3b Mon Sep 17 00:00:00 2001 From: mkurczewski Date: Fri, 20 Dec 2024 14:09:49 +0100 Subject: [PATCH] Implemented serialport service for API devices --- apps/app/src/main/index.ts | 6 +- apps/app/tsconfig.node.json | 10 +- apps/web/src/app/app.spec.tsx | 6 +- apps/web/src/app/serialport-test.ts | 142 +++++++++++++++++- .../main/src/lib/app-serial-port.ts | 110 ++++++++++++-- .../api-device-response-parser.test.ts | 124 +++++++++++++++ .../api-device/api-device-response-parser.ts | 113 ++++++++++++++ .../api-device/serial-port-api-device.ts | 33 ++++ .../lib/devices/request-parser.interface.ts | 77 ++++++++++ .../main/src/lib/init-serialport.ts | 26 ++-- .../main/src/lib/serialport-ipc.types.ts | 10 +- libs/app-serialport/models/src/index.ts | 2 + .../models/src/lib/serial-port-device.ts | 17 +++ .../models/src/lib/serialport-ipc-events.ts | 2 +- .../models/src/lib/serialport-requests.ts | 48 ++++++ .../renderer/src/lib/app-serial-port.ts | 17 ++- package-lock.json | 33 +++- package.json | 6 +- tsconfig.base.json | 2 +- 19 files changed, 728 insertions(+), 56 deletions(-) create mode 100644 libs/app-serialport/main/src/lib/devices/api-device/api-device-response-parser.test.ts create mode 100644 libs/app-serialport/main/src/lib/devices/api-device/api-device-response-parser.ts create mode 100644 libs/app-serialport/main/src/lib/devices/api-device/serial-port-api-device.ts create mode 100644 libs/app-serialport/main/src/lib/devices/request-parser.interface.ts create mode 100644 libs/app-serialport/models/src/lib/serial-port-device.ts create mode 100644 libs/app-serialport/models/src/lib/serialport-requests.ts diff --git a/apps/app/src/main/index.ts b/apps/app/src/main/index.ts index 94843cd4e1..b40f87bea0 100644 --- a/apps/app/src/main/index.ts +++ b/apps/app/src/main/index.ts @@ -32,6 +32,9 @@ function createWindow(): void { mainWindow.webContents.openDevTools() mainWindow.on("ready-to-show", () => { + initSerialPort(ipcMain, mainWindow.webContents) + initSql(ipcMain) + mainWindow.show() }) @@ -66,9 +69,6 @@ app.whenReady().then(() => { // IPC test ipcMain.on("ping", () => console.log("pong")) - initSerialPort(ipcMain) - initSql(ipcMain) - createWindow() app.on("activate", function () { diff --git a/apps/app/tsconfig.node.json b/apps/app/tsconfig.node.json index f7cb7dd9ff..02f8f5c5d9 100644 --- a/apps/app/tsconfig.node.json +++ b/apps/app/tsconfig.node.json @@ -4,17 +4,21 @@ "electron.vite.config.*", "src/main/**/*", "src/preload/**/*", - "../../libs/app-*/main/**/*", + "../../libs/app-*/main/**/*.ts", + ], + "exclude": [ + "../../libs/app-*/main/**/*.test.ts" ], "compilerOptions": { "composite": true, "resolveJsonModule": true, "types": [ + "node", "electron-vite/node", "./images.d.ts" ], "paths": { - "app-*": ["../../libs/app-*/src/index.ts"], + "app-*": ["../../libs/app-*/src/index.ts"] } - }, + } } diff --git a/apps/web/src/app/app.spec.tsx b/apps/web/src/app/app.spec.tsx index 390d4dc9b5..fde9974553 100644 --- a/apps/web/src/app/app.spec.tsx +++ b/apps/web/src/app/app.spec.tsx @@ -3,20 +3,20 @@ * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md */ -import { PortInfo } from "@serialport/bindings-interface" import { render } from "@testing-library/react" import App from "./app" +import { Device } from "app-serialport/models" jest.mock("app-serialport/renderer", () => { return { AppSerialPort: { - list: jest.fn().mockResolvedValue([ + onChange: jest.fn().mockResolvedValue([ { vendorId: "0e8d", productId: "2006", path: "/dev/ttyUSB0.KOM123456789", }, - ] as PortInfo[]), + ] as Device[]), write: jest.fn(), }, } diff --git a/apps/web/src/app/serialport-test.ts b/apps/web/src/app/serialport-test.ts index 86045f58cc..224d1cbc70 100644 --- a/apps/web/src/app/serialport-test.ts +++ b/apps/web/src/app/serialport-test.ts @@ -5,19 +5,151 @@ import { useCallback, useEffect, useRef } from "react" import { AppSerialPort } from "app-serialport/renderer" +import { ChangedDevices } from "app-serialport/models" +import { isEmpty } from "lodash" export const useSerialPortListener = () => { - const interval = useRef() + // const interval = useRef() + const ref = useRef(false) const listenPorts = useCallback(async () => { - const ports = await AppSerialPort.list() + if (!ref.current) { + ref.current = true + const onAttach = async (added: NonNullable) => { + const apiConfigurationResponse = await AppSerialPort.write(added.path, { + endpoint: "API_CONFIGURATION", + method: "GET", + body: {}, + options: { + connectionTimeOut: 30000, + }, + }) + const entitiesConfigurationResponse = await AppSerialPort.write( + added.path, + { + endpoint: "ENTITIES_CONFIGURATION", + method: "GET", + body: { + entityType: "contacts", + }, + options: { + connectionTimeOut: 30000, + }, + } + ) + console.log({ apiConfigurationResponse, entitiesConfigurationResponse }) + } + AppSerialPort.onChange((changes) => { + console.log(changes) + if (!isEmpty(changes.added)) { + void onAttach(changes.added) + } + }) + } - await AppSerialPort.write(ports[0].path, "Hello from the web app!") + // const req1 = () => + // AppSerialPort.write(ports[0].path, { + // endpoint: "API_CONFIGURATION", + // method: "GET", + // body: {}, + // options: { + // connectionTimeOut: 30000, + // }, + // }) + + // const req2 = () => + // AppSerialPort.write(ports[0].path, { + // endpoint: "ENTITIES_CONFIGURATION", + // method: "GET", + // body: { + // entityType: "contacts", + // }, + // options: { + // connectionTimeOut: 30000, + // }, + // }) + + // await AppSerialPort.write(ports[0].path, { + // endpoint: "API_CONFIGURATION", + // method: "GET", + // body: {}, + // options: { + // connectionTimeOut: 30000, + // }, + // }) + // await new Promise((resolve) => setTimeout(resolve, 100)) + // await AppSerialPort.write(ports[0].path, { + // endpoint: "ENTITIES_CONFIGURATION", + // method: "GET", + // body: { + // entityType: "contacts", + // }, + // options: { + // connectionTimeOut: 30000, + // }, + // }) + // await new Promise((resolve) => setTimeout(resolve, 100)) + // void AppSerialPort.write(ports[0].path, { + // endpoint: "ENTITIES_DATA", + // method: "GET", + // body: { + // entityType: "contacts", + // responseType: "json", + // }, + // options: { + // connectionTimeOut: 30000, + // }, + // }) + // await new Promise((resolve) => setTimeout(resolve, 100)) + // await AppSerialPort.write(ports[0].path, { + // endpoint: "ENTITIES_CONFIGURATION", + // method: "GET", + // body: { + // entityType: "contacts", + // }, + // options: { + // connectionTimeOut: 30000, + // }, + // }) + + Promise.all([ + // AppSerialPort.write(ports[0].path, { + // endpoint: "API_CONFIGURATION", + // method: "GET", + // body: {}, + // options: { + // connectionTimeOut: 30000, + // }, + // }), + // AppSerialPort.write(ports[0].path, { + // endpoint: "ENTITIES_CONFIGURATION", + // method: "GET", + // body: { + // entityType: "contacts", + // }, + // options: { + // connectionTimeOut: 30000, + // }, + // }), + // AppSerialPort.write(ports[0].path, { + // endpoint: "ENTITIES_DATA", + // method: "GET", + // body: { + // entityType: "contacts", + // responseType: "json", + // }, + // options: { + // connectionTimeOut: 30000, + // }, + // }), + ]).then((resp) => { + console.log(resp) + }) }, []) useEffect(() => { void listenPorts() - interval.current = setInterval(listenPorts, 2000) - return () => clearInterval(interval.current) + // interval.current = setInterval(listenPorts, 2000) + // return () => clearInterval(interval.current) }, [listenPorts]) } diff --git a/libs/app-serialport/main/src/lib/app-serial-port.ts b/libs/app-serialport/main/src/lib/app-serial-port.ts index cd58ffad0d..9dd0e0a2f1 100644 --- a/libs/app-serialport/main/src/lib/app-serial-port.ts +++ b/libs/app-serialport/main/src/lib/app-serial-port.ts @@ -4,12 +4,57 @@ */ import { SerialPort } from "serialport" +import { PortInfo } from "@serialport/bindings-interface" +import { SerialPortApiDevice } from "./devices/api-device/serial-port-api-device" +import { SerialPortDevice } from "./devices/request-parser.interface" +import { APIRequestData, ChangedDevices, Device } from "app-serialport/models" +import EventEmitter from "events" + +type DevicesChangeCallback = (data: ChangedDevices) => void + +const isKnownDevice = (port: PortInfo): port is Device => { + return port.productId !== undefined && port.vendorId !== undefined +} export class AppSerialPort { - private readonly instances: Map + private readonly instances = new Map() + supportedDevices = [SerialPortApiDevice] + attachedDevices: Device[] = [] + eventEmitter = new EventEmitter() constructor() { - this.instances = new Map() + void this.checkForPortChanges() + setInterval(() => { + void this.checkForPortChanges() + }, 2000) + } + + private async checkForPortChanges() { + const currentDevices = (await SerialPort.list()).filter((port) => { + if (!isKnownDevice(port)) { + return false + } + return this.supportedDevices.some((device) => { + return ( + device.matchingVendorIds.includes(port.vendorId) && + device.matchingProductIds.includes(port.productId) + ) + }) + }) as Device[] + + const removedDevices = this.attachedDevices.filter((device) => { + return !currentDevices.find((newDevice) => newDevice.path === device.path) + }) + + this.attachedDevices = currentDevices + + removedDevices.forEach((device) => { + this.removeInstance(device) + }) + + currentDevices.forEach((device) => { + this.ensureInstance(device.path) + }) } private instanceExists(path: string) { @@ -17,24 +62,71 @@ export class AppSerialPort { } private createInstance(path: string) { - const serialPort = new SerialPort({ path, baudRate: 9600 }) - this.instances.set(path, serialPort) + const instance = this.getInstanceForDevice(path) + if (instance) { + const serialPort = new instance({ path, baudRate: 9600 }) + this.instances.set(path, serialPort) + const change: Pick = { + added: this.getDeviceByPath(path), + } + this.eventEmitter.emit("devicesChanged", change) + } + } + + private removeInstance(device: Device) { + const serialPort = this.instances.get(device.path) + if (serialPort) { + serialPort.destroy() + this.instances.delete(device.path) + const change: Pick = { + removed: device, + } + this.eventEmitter.emit("devicesChanged", change) + } } private ensureInstance(path: string) { if (!this.instanceExists(path)) { this.createInstance(path) } - return this.instances.get(path) as SerialPort + return this.instances.get(path) + } + + private getDeviceByPath(path: string) { + return this.attachedDevices.find((device) => device.path === path) + } + + private getInstanceForDevice(path: string) { + const port = this.getDeviceByPath(path) + if (!port) { + return + } + return this.supportedDevices.find((device) => { + return ( + device.matchingVendorIds.includes(port.vendorId) && + device.matchingProductIds.includes(port.productId) + ) + }) } changeBaudRate(path: string, baudRate: number) { const serialPort = this.ensureInstance(path) - serialPort.update({ baudRate }) + serialPort?.update({ baudRate }) } - write(path: string, data: string) { - const serialPort = this.ensureInstance(path) - serialPort.write(data) + async request(path: string, data: APIRequestData) { + return this.ensureInstance(path)?.request(data) + } + + onDevicesChange(callback: DevicesChangeCallback) { + this.eventEmitter.on( + "devicesChanged", + (changes: Omit) => { + callback({ + all: this.attachedDevices, + ...changes, + }) + } + ) } } diff --git a/libs/app-serialport/main/src/lib/devices/api-device/api-device-response-parser.test.ts b/libs/app-serialport/main/src/lib/devices/api-device/api-device-response-parser.test.ts new file mode 100644 index 0000000000..fdcebfd154 --- /dev/null +++ b/libs/app-serialport/main/src/lib/devices/api-device/api-device-response-parser.test.ts @@ -0,0 +1,124 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { SerialPortStream } from "@serialport/stream" +import { MockBinding } from "@serialport/binding-mock" +import { ApiDeviceResponseParser } from "./api-device-response-parser" +import { waitFor } from "@testing-library/react" + +let serialport: SerialPortStream +let parser: ApiDeviceResponseParser + +beforeEach(() => { + MockBinding.createPort("/dev/ROBOT", { echo: true, record: true }) + + serialport = new SerialPortStream({ + binding: MockBinding, + path: "/dev/ROBOT", + baudRate: 9600, + }) + parser = serialport.pipe(new ApiDeviceResponseParser({ matcher: /#\d/g })) +}) + +afterEach(() => { + MockBinding.reset() + serialport.destroy() +}) + +describe("ApiDeviceParser", () => { + it("handles single chunk being full response", async () => { + const dataListener = jest.fn() + + parser.on("data", dataListener) + serialport.push("#3Foo") + + await waitFor(() => { + expect(dataListener).toHaveBeenCalledWith(Buffer.from("Foo")) + }) + }) + + it("handles response at the beginning of the chunk", async () => { + const dataListener = jest.fn() + + parser.on("data", dataListener) + serialport.push("#3FooBarBaz") + + await waitFor(() => { + expect(dataListener).toHaveBeenNthCalledWith(1, Buffer.from("Foo")) + }) + }) + + it("handles response at the end of the chunk", async () => { + const dataListener = jest.fn() + + parser.on("data", dataListener) + serialport.push("oo#3Bar") + + await waitFor(() => { + expect(dataListener).toHaveBeenNthCalledWith(1, Buffer.from("Bar")) + }) + }) + + it("handles response in the middle of the chunk", async () => { + const dataListener = jest.fn() + + parser.on("data", dataListener) + serialport.push("oo#3Bar#3Baz") + + await waitFor(() => { + expect(dataListener).toHaveBeenNthCalledWith(1, Buffer.from("Bar")) + }) + }) + + it("handles multiple responses in a single chunk", async () => { + const dataListener = jest.fn() + + parser.on("data", dataListener) + serialport.push("#3Foo#3Bar#3Baz") + + await waitFor(() => { + expect(dataListener).toHaveBeenNthCalledWith(1, Buffer.from("Foo")) + expect(dataListener).toHaveBeenNthCalledWith(2, Buffer.from("Bar")) + expect(dataListener).toHaveBeenNthCalledWith(3, Buffer.from("Baz")) + }) + + expect(dataListener).toHaveBeenCalledTimes(3) + }) + + it("handles response divided by chunks", async () => { + const dataListener = jest.fn() + + parser.on("data", dataListener) + serialport.push("#3F") + serialport.push("oo") + + await waitFor(() => { + expect(dataListener).toHaveBeenCalledWith(Buffer.from("Foo")) + }) + + expect(dataListener).toHaveBeenCalledTimes(1) + }) + + it("handles multiple responses divided by chunks", async () => { + const dataListener = jest.fn().mockImplementation((chunk) => { + return chunk + }) + + parser.on("data", dataListener) + serialport.push("#3F") + serialport.push("oo#3B") + serialport.push("a") + serialport.push("r") + serialport.push("#3Baz") + + await waitFor(() => { + expect(dataListener).toHaveBeenNthCalledWith(1, Buffer.from("Foo")) + expect(dataListener).toHaveBeenNthCalledWith(2, Buffer.from("Bar")) + expect(dataListener).toHaveBeenNthCalledWith(3, Buffer.from("Baz")) + }) + + expect(dataListener).toHaveBeenCalledTimes(3) + }) +}) diff --git a/libs/app-serialport/main/src/lib/devices/api-device/api-device-response-parser.ts b/libs/app-serialport/main/src/lib/devices/api-device/api-device-response-parser.ts new file mode 100644 index 0000000000..2d29431ab9 --- /dev/null +++ b/libs/app-serialport/main/src/lib/devices/api-device/api-device-response-parser.ts @@ -0,0 +1,113 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { Transform, TransformCallback, TransformOptions } from "stream" + +interface ApiDeviceParserOptions extends TransformOptions { + matcher: RegExp +} + +/** + * ApiDeviceParser is a Transform stream that processes incoming data chunks + * and emits complete responses based on a specified matcher pattern + * that points to the beginning of a response along with its expected length. + */ +export class ApiDeviceResponseParser extends Transform { + private readonly encoding: BufferEncoding = "utf8" + private readonly matcher: RegExp + private buffer = Buffer.alloc(0) + private bufferExpectedLength = 0 + + constructor({ matcher, ...options }: ApiDeviceParserOptions) { + super(options) + this.matcher = matcher + this.encoding = options.encoding || this.encoding + } + + private isFullResponse(buffer = this.buffer) { + return ( + this.bufferExpectedLength > 0 && + buffer.length === this.bufferExpectedLength + ) + } + + private sendResponse(buffer: Buffer, encoding = this.encoding) { + const data = buffer.toString().replace(this.matcher, "") + this.push(Buffer.from(data), encoding) + this.buffer = Buffer.alloc(0) + this.bufferExpectedLength = 0 + } + + _transform( + chunk: Buffer, + encoding = this.encoding, + callback: TransformCallback + ) { + const chunkData = chunk.toString() + const allMatches = Array.from(chunkData.matchAll(this.matcher)) + + // If chunk contains new response header + if (allMatches.length > 0) { + // Loop through all matches of the header + for (const [index, match] of Object.entries(allMatches)) { + // If the first found header is not at the beginning of the chunk + if (index === "0" && match.index > 0 && this.buffer.length > 0) { + // Get the payload before the first header and add it to the buffer of previous response + const payload = Buffer.concat([ + new Uint8Array(this.buffer), + new Uint8Array(Buffer.from(chunkData.slice(0, match.index))), + ]) + // Emit the previous response if it's full and clear the buffer + if (this.isFullResponse(payload)) { + this.sendResponse(payload, encoding) + } + } + + // Get the n-th header and attached payload of the response + const header = match[0] + // Calculate the expected length of the response + this.bufferExpectedLength = + parseInt(header.replaceAll(/\D/g, "")) + header.length + // Extract the payload from the chunk + const payload = Buffer.from( + chunkData.slice(match.index, match.index + this.bufferExpectedLength) + ) + + // If full response is sent, emit it and clear the buffer + if (this.isFullResponse(payload)) { + this.sendResponse(payload, encoding) + } + // Otherwise, save the payload to the buffer + else { + this.buffer = payload + } + } + } + // If chunk doesn't contain new response header + else { + // Concatenate the chunk with the saved buffer + const payload = Buffer.concat([ + new Uint8Array(this.buffer), + new Uint8Array(chunk), + ]) + // If full response is sent, emit it and clear the buffer + if (this.isFullResponse(payload)) { + this.sendResponse(payload, encoding) + } + // Otherwise, save the payload to the buffer and wait for the next chunk + else { + this.buffer = payload + } + } + callback() + } + + _flush(callback: TransformCallback) { + if (this.isFullResponse()) { + this.sendResponse(this.buffer) + } + callback() + } +} diff --git a/libs/app-serialport/main/src/lib/devices/api-device/serial-port-api-device.ts b/libs/app-serialport/main/src/lib/devices/api-device/serial-port-api-device.ts new file mode 100644 index 0000000000..1504e9c0e1 --- /dev/null +++ b/libs/app-serialport/main/src/lib/devices/api-device/serial-port-api-device.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { APIRequestData } from "app-serialport/models" +import { TextEncoder } from "util" +import { ApiDeviceResponseParser } from "./api-device-response-parser" +import { + SerialPortDevice, + SerialPortDeviceOptions, +} from "../request-parser.interface" + +interface Data extends APIRequestData { + rid?: number +} + +export class SerialPortApiDevice extends SerialPortDevice { + static matchingVendorIds = ["0e8d"] + static matchingProductIds = ["200a", "2006"] + + constructor(options: SerialPortDeviceOptions) { + super(options, new ApiDeviceResponseParser({ matcher: /#\d{9}/g })) + this.idKey = "rid" + } + + parseRequest(data: Data) { + const encoder = new TextEncoder() + const payload = JSON.stringify(data) + const header = String(encoder.encode(payload).length).padStart(9, "0") + return `#${header}${payload}` + } +} diff --git a/libs/app-serialport/main/src/lib/devices/request-parser.interface.ts b/libs/app-serialport/main/src/lib/devices/request-parser.interface.ts new file mode 100644 index 0000000000..b8dc6d60fd --- /dev/null +++ b/libs/app-serialport/main/src/lib/devices/request-parser.interface.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { SerialPort, SerialPortOpenOptions } from "serialport" +import { AutoDetectTypes } from "@serialport/bindings-cpp" +import { Transform } from "stream" +import { APIRequestData, APIResponseData } from "app-serialport/models" +import { uniqueId } from "lodash" +import EventEmitter from "events" +import PQueue from "p-queue" + +export type SerialPortDeviceOptions = SerialPortOpenOptions & { + queueInterval?: number + queueConcurrency?: number +} + +export class SerialPortDevice extends SerialPort { + private responseEmitter = new EventEmitter() + private queue: PQueue + matchingVendorIds: string[] = [] + matchingProductIds: string[] = [] + idKey = "id" + + constructor( + { + queueInterval = 1, + queueConcurrency = 1, + ...options + }: SerialPortDeviceOptions, + parser: Transform + ) { + super(options) + this.queue = new PQueue({ + concurrency: queueConcurrency, + interval: queueInterval, + }) + super.pipe(parser).on("data", (buffer: Buffer) => { + const data = JSON.parse(buffer.toString()) + if (this.idKey in data) { + this.responseEmitter.emit(`response-${data[this.idKey]}`, data) + } + }) + } + + private listenForResponse(id: number | string) { + return new Promise((resolve) => { + this.responseEmitter.once( + `response-${id}`, + (response: APIResponseData) => { + resolve(response) + } + ) + }) + } + + write(data: unknown) { + return super.write(this.parseRequest(data)) + } + + parseRequest(data: unknown) { + return data + } + + async request(data: APIRequestData) { + const id = parseInt(uniqueId()) + + return new Promise((resolve) => { + void this.queue.add(async () => { + this.write({ ...data, [this.idKey]: id }) + const response = await this.listenForResponse(id) + resolve(response) + }) + }) + } +} diff --git a/libs/app-serialport/main/src/lib/init-serialport.ts b/libs/app-serialport/main/src/lib/init-serialport.ts index 9efa86e6b0..143182a19f 100644 --- a/libs/app-serialport/main/src/lib/init-serialport.ts +++ b/libs/app-serialport/main/src/lib/init-serialport.ts @@ -3,24 +3,20 @@ * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md */ -import { IpcMain } from "electron" -import { SerialPort } from "serialport" -import { SerialportIpcEvents } from "app-serialport/models" +import { IpcMain, WebContents } from "electron" +import { APIRequestData, SerialportIpcEvents } from "app-serialport/models" import { AppSerialPort } from "./app-serial-port" -export const initSerialPort = (ipcMain: IpcMain) => { +export const initSerialPort = (ipcMain: IpcMain, webContents: WebContents) => { const serialport = new AppSerialPort() - ipcMain.handle(SerialportIpcEvents.List, async () => { - return (await SerialPort.list()).filter((port) => { - return ( - port.vendorId === "0e8d" && - port.productId && - ["200a", "2006"].includes(port.productId) - ) - }) - }) - ipcMain.handle(SerialportIpcEvents.Write, (_, path: string, data: string) => { - serialport.write(path, data) + serialport.onDevicesChange((data) => { + webContents.send(SerialportIpcEvents.Change, data) }) + ipcMain.handle( + SerialportIpcEvents.Write, + (_, path: string, data: APIRequestData) => { + return serialport.request(path, data) + } + ) } diff --git a/libs/app-serialport/main/src/lib/serialport-ipc.types.ts b/libs/app-serialport/main/src/lib/serialport-ipc.types.ts index 4ef51fd834..590e09d259 100644 --- a/libs/app-serialport/main/src/lib/serialport-ipc.types.ts +++ b/libs/app-serialport/main/src/lib/serialport-ipc.types.ts @@ -3,12 +3,14 @@ * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md */ -import { SerialportIpcEvents } from "app-serialport/models" -import { PortInfo } from "@serialport/bindings-interface" -import { IpcRenderer } from "electron" +import { ChangedDevices, SerialportIpcEvents } from "app-serialport/models" +import { IpcRenderer, IpcRendererEvent } from "electron" export interface IpcAppSerialport extends IpcRenderer { - invoke(channel: SerialportIpcEvents.List): Promise + on( + channel: SerialportIpcEvents.Change, + callback: (event: IpcRendererEvent, data: ChangedDevices) => void + ): this invoke( channel: SerialportIpcEvents.Write, path: string, diff --git a/libs/app-serialport/models/src/index.ts b/libs/app-serialport/models/src/index.ts index 120597e572..58b836e508 100644 --- a/libs/app-serialport/models/src/index.ts +++ b/libs/app-serialport/models/src/index.ts @@ -4,3 +4,5 @@ */ export * from "./lib/serialport-ipc-events" +export * from "./lib/serialport-requests" +export * from "./lib/serial-port-device" \ No newline at end of file diff --git a/libs/app-serialport/models/src/lib/serial-port-device.ts b/libs/app-serialport/models/src/lib/serial-port-device.ts new file mode 100644 index 0000000000..8357fdccf1 --- /dev/null +++ b/libs/app-serialport/models/src/lib/serial-port-device.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +import { PortInfo } from "@serialport/bindings-interface" + +export interface Device extends PortInfo { + productId: string + vendorId: string +} + +export interface ChangedDevices { + all: Device[] + added?: Device + removed?: Device +} \ No newline at end of file diff --git a/libs/app-serialport/models/src/lib/serialport-ipc-events.ts b/libs/app-serialport/models/src/lib/serialport-ipc-events.ts index 31c8f15a2b..45855b8ab5 100644 --- a/libs/app-serialport/models/src/lib/serialport-ipc-events.ts +++ b/libs/app-serialport/models/src/lib/serialport-ipc-events.ts @@ -4,6 +4,6 @@ */ export enum SerialportIpcEvents { - List = "serialport:list", + Change = "serialport:change", Write = "serialport:write", } \ No newline at end of file diff --git a/libs/app-serialport/models/src/lib/serialport-requests.ts b/libs/app-serialport/models/src/lib/serialport-requests.ts new file mode 100644 index 0000000000..de4086fc59 --- /dev/null +++ b/libs/app-serialport/models/src/lib/serialport-requests.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) Mudita sp. z o.o. All rights reserved. + * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md + */ + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const APIEndpoints = [ + "API_CONFIGURATION", + "FEATURE_CONFIGURATION", + "FEATURE_DATA", + "DATA_SYNC", + "MENU_CONFIGURATION", + "OUTBOX", + "PRE_BACKUP", + "POST_BACKUP", + "PRE_FILE_TRANSFER", + "FILE_TRANSFER", + "PRE_RESTORE", + "RESTORE", + "SYSTEM", + "PRE_DATA_TRANSFER", + "DATA_TRANSFER", + "ENTITIES_CONFIGURATION", + "ENTITIES_DATA", + "ENTITIES_METADATA", +] as const + +type APIEndpointType = (typeof APIEndpoints)[number] + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const APIMethods = ["GET", "POST", "PUT", "DELETE"] as const + +type APIMethodsType = (typeof APIMethods)[number] + +export interface APIRequestData { + endpoint: APIEndpointType + method: APIMethodsType + body?: Record + rid?: number + options?: { connectionTimeOut?: number } +} + +export interface APIResponseData { + rid: number + endpoint: APIEndpointType + status: number + body: Record +} diff --git a/libs/app-serialport/renderer/src/lib/app-serial-port.ts b/libs/app-serialport/renderer/src/lib/app-serial-port.ts index 468a89a594..9266638d5d 100644 --- a/libs/app-serialport/renderer/src/lib/app-serial-port.ts +++ b/libs/app-serialport/renderer/src/lib/app-serial-port.ts @@ -3,13 +3,22 @@ * For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md */ -import { SerialportIpcEvents } from "app-serialport/models" +import { + APIRequestData, + ChangedDevices, + SerialportIpcEvents, +} from "app-serialport/models" export const AppSerialPort = { - list: () => { - return window.electron.ipcRenderer.invoke(SerialportIpcEvents.List) + onChange: (callback: (changes: ChangedDevices) => void) => { + return window.electron.ipcRenderer.on( + SerialportIpcEvents.Change, + (_, changes) => { + callback(changes) + } + ) }, - write: (path: string, data: string) => { + write: (path: string, data: APIRequestData) => { return window.electron.ipcRenderer.invoke( SerialportIpcEvents.Write, path, diff --git a/package-lock.json b/package-lock.json index 609b89cf48..86f7f8d6fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.6.0", "bullmq": "^5.34.2", "electron-updater": "^6.1.7", + "lodash": "^4.17.21", "p-queue": "^8.0.1", "react": "18.3.1", "react-dom": "18.3.1", @@ -47,8 +48,9 @@ "@swc/core": "~1.5.7", "@swc/helpers": "~0.5.11", "@testing-library/react": "15.0.6", - "@types/jest": "^29.5.12", - "@types/node": "18.16.9", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.13", + "@types/node": "20.14.8", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", "@types/react-is": "18.3.0", @@ -8849,6 +8851,7 @@ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -8885,6 +8888,13 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -8914,9 +8924,19 @@ } }, "node_modules/@types/node": { - "version": "18.16.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.9.tgz", - "integrity": "sha512-IeB32oIV4oGArLrd7znD2rkHQ6EDCM+2Sr76dJnrHwv9OHBTTM6nuDLK9bmikXzPa0ZlWMWtRGo/Uw4mrzQedA==" + "version": "20.14.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.8.tgz", + "integrity": "sha512-DO+2/jZinXfROG7j7WKFn/3C6nFwxy2lLpgLjEXJz+0XKphZlTLJ14mo8Vfg8X5BWN6XjyESXq+LcYdT7tR3bA==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", @@ -21138,7 +21158,8 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" }, "node_modules/lodash.clonedeep": { "version": "4.5.0", diff --git a/package.json b/package.json index b2298c5c59..b2c2f17333 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "axios": "^1.6.0", "bullmq": "^5.34.2", "electron-updater": "^6.1.7", + "lodash": "^4.17.21", "p-queue": "^8.0.1", "react": "18.3.1", "react-dom": "18.3.1", @@ -60,8 +61,9 @@ "@swc/core": "~1.5.7", "@swc/helpers": "~0.5.11", "@testing-library/react": "15.0.6", - "@types/jest": "^29.5.12", - "@types/node": "18.16.9", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.13", + "@types/node": "20.14.8", "@types/react": "18.3.1", "@types/react-dom": "18.3.0", "@types/react-is": "18.3.0", diff --git a/tsconfig.base.json b/tsconfig.base.json index 09c7779385..ac2b05520d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -11,7 +11,7 @@ "importHelpers": true, "target": "es2015", "module": "esnext", - "lib": ["es2020", "dom"], + "lib": ["ES2021", "dom"], "skipLibCheck": true, "skipDefaultLibCheck": true, "baseUrl": ".",